v1.20: Mieter pflegen eigene Stammdaten (Telefon, E-Mail, Bankverbindung)

This commit is contained in:
2026-10-04 13:21:47 +02:00
parent 1385e349f2
commit 26372d563c
8 changed files with 265 additions and 0 deletions
+16
View File
@@ -189,6 +189,22 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.20):** Stammdaten-Selbstpflege. users-Tabelle um
telefon/kontoinhaber/iban/bankname erweitert (ALTER ... ADD COLUMN IF NOT
EXISTS). PATCH /api/auth/profil (auth, eigene Daten): E-Mail (Pflicht +
Eindeutigkeitsprüfung case-insensitiv), Telefon, Kontoinhaber, IBAN
(Leerzeichen werden entfernt/großgeschrieben, Formatprüfung
^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$), Bankname. oeffentlicherBenutzer liefert die
neuen Felder mit (gelten damit auch für /api/auth/me, Bootstrap benutzer,
Benutzerverwaltung). Frontend: neue Seite /profil (Nav „Mein Profil", beide
Rollen): Ansichtskarte Konto & Wohnung (Name/Benutzername/Wohneit/ Mitglied
seit, readonly), editierbare Kontaktdaten (E-Mail, Telefon) und
Bankverbindung (nur Mieter, Hinweis auf Kaution). Store: aktualisiereProfil
→ PATCH, setMe mit Server-Antwort; Erfolgs-/Fehlermeldung inline. E2E
verifiziert: Speichern als a.berger (IBAN normalisiert), Auslesen via
/api/auth/me, Negativtests ungültige IBAN + fremde E-Mail werden sauber
abgelehnt. Demo-Daten der a.berger bleiben als Schaufenster-Datensatz.
**Erledigt 04.10. (v1.19):** Schadensmeldungen mit Kommunikation verknüpft.
threads-Tabelle um `ticket_id TEXT REFERENCES tickets(id)` erweitert (ALTER +
Index idx_threads_ticket). POST /api/tickets legt den Auto-Thread bereits mit
+5
View File
@@ -106,6 +106,11 @@ CREATE TABLE IF NOT EXISTS users (
erstellt_am TEXT NOT NULL,
letzter_login TEXT
);
-- Stammdaten zur Selbstpflege durch den Nutzer (v1.20)
ALTER TABLE users ADD COLUMN IF NOT EXISTS telefon TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS kontoinhaber TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS iban TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS bankname TEXT;
CREATE TABLE IF NOT EXISTS registration_codes (
code TEXT PRIMARY KEY,
+29
View File
@@ -135,6 +135,10 @@ const oeffentlicherBenutzer = (b) => ({
aktiv: b.aktiv,
erstelltAm: b.erstellt_am,
letzterLogin: b.letzter_login ?? undefined,
telefon: b.telefon ?? null,
kontoinhaber: b.kontoinhaber ?? null,
iban: b.iban ?? null,
bankname: b.bankname ?? null,
})
async function sessionAnlegen(res, userId, req) {
@@ -238,6 +242,31 @@ app.get('/api/auth/me', auth, (req, res) => {
res.json({ benutzer: oeffentlicherBenutzer(req.user) })
})
// Stammdaten-Selbstpflege (v1.20): Telefon, E-Mail, Bankverbindung
app.patch('/api/auth/profil', auth, async (req, res, next) => {
try {
const { email, telefon, kontoinhaber, iban, bankname } = req.body || {}
const emailNeu = String(email || '').trim()
if (!/^\S+@\S+\.\S+$/.test(emailNeu))
return res.status(400).json({ fehler: 'Gültige E-Mail-Adresse erforderlich.' })
const { rows: fremde } = await pool.query(
'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [emailNeu, req.user.id],
)
if (fremde.length)
return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' })
const ibanNeu = String(iban || '').trim().replace(/\s+/g, '').toUpperCase()
if (ibanNeu && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(ibanNeu))
return res.status(400).json({ fehler: 'Ungültige IBAN.' })
const { rows } = await pool.query(
`UPDATE users SET email = $1, telefon = $2, kontoinhaber = $3, iban = $4, bankname = $5
WHERE id = $6 RETURNING *`,
[emailNeu, String(telefon || '').trim() || null, String(kontoinhaber || '').trim() || null,
ibanNeu || null, String(bankname || '').trim() || null, req.user.id],
)
res.json({ benutzer: oeffentlicherBenutzer(rows[0]) })
} catch (e) { next(e) }
})
app.post('/api/auth/register', async (req, res, next) => {
try {
const { code, benutzername, name, email, passwortHash } = req.body || {}
+2
View File
@@ -12,6 +12,7 @@ import Zaehler from './pages/Zaehler'
import Kosten from './pages/Kosten'
import Bestand from './pages/Bestand'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
import Profil from './pages/Profil'
/** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */
class FehlerGrenze extends Component<{ children: ReactNode }, { fehler: boolean }> {
@@ -88,6 +89,7 @@ export default function App() {
<Route path="/bestand" element={<Bestand />} />
<Route path="/dokumente" element={<Documents />} />
<Route path="/nachrichten" element={<Messages />} />
<Route path="/profil" element={<Profil />} />
<Route path="/benutzer" element={<BenutzerVerwaltung />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+2
View File
@@ -10,6 +10,7 @@ import {
Building2,
Gauge,
Calculator,
UserRound,
} from 'lucide-react'
import { useStore } from '../store/store'
import ThemeToggle from './ThemeToggle'
@@ -23,6 +24,7 @@ const navItems = [
{ to: '/kosten', label: 'Steuer & Kosten', icon: Calculator },
{ to: '/dokumente', label: 'Dokumente', icon: FileText },
{ to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare },
{ to: '/profil', label: 'Mein Profil', icon: UserRound },
]
export default function AppLayout() {
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react'
import { Check, IdCard, Phone, Landmark, Mail } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useStore } from '../store/store'
export default function Profil() {
const { me, role, meineEinheit, units, aktualisiereProfil } = useStore()
const [email, setEmail] = useState('')
const [telefon, setTelefon] = useState('')
const [kontoinhaber, setKontoinhaber] = useState('')
const [iban, setIban] = useState('')
const [bankname, setBankname] = useState('')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [gespeichert, setGespeichert] = useState(false)
useEffect(() => {
if (!me) return
setEmail(me.email ?? '')
setTelefon(me.telefon ?? '')
setKontoinhaber(me.kontoinhaber ?? '')
setIban(me.iban ?? '')
setBankname(me.bankname ?? '')
}, [me])
if (!me) return null
const einheit = units.find((u) => u.id === me.einheitId) ?? meineEinheit
const speichern = async () => {
if (sende) return
setSende(true)
setFehler(null)
setGespeichert(false)
const err = await aktualisiereProfil({
email: email.trim(),
telefon: telefon.trim(),
kontoinhaber: kontoinhaber.trim(),
iban: iban.trim(),
bankname: bankname.trim(),
})
setSende(false)
if (err) {
setFehler(err)
return
}
setGespeichert(true)
window.setTimeout(() => setGespeichert(false), 4000)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1>
<p className="text-sm text-slate-500">
Ihre Stammdaten – hier gepflegt, überall im Portal aktuell.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Feste Daten (nur Ansicht) */}
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2 font-semibold">
<IdCard className="h-4 w-4 text-teal-600" /> Konto & Wohnung
</div>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-xs uppercase tracking-wide text-slate-400">Name</dt>
<dd className="font-medium">{me.name}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-400">Benutzername</dt>
<dd className="font-medium">{me.benutzername}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-400">
{role === 'admin' ? 'Gruppe' : 'Wohneinheit'}
</dt>
<dd className="font-medium">
{role === 'admin'
? 'Verwaltung'
: einheit
? `${einheit.building}, Whg. ${einheit.number}`
: '–'}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-slate-400">Mitglied seit</dt>
<dd className="font-medium">{me.erstelltAm}</dd>
</div>
</dl>
</div>
{/* Kontaktdaten (editierbar) */}
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2 font-semibold">
<Phone className="h-4 w-4 text-teal-600" /> Kontaktdaten
</div>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="p-email">E-Mail-Adresse</Label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
id="p-email"
type="email"
className="pl-9"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<p className="text-xs text-slate-400">
Wird für Benachrichtigungen des Portals verwendet.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="p-tel">Telefon</Label>
<div className="relative">
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
id="p-tel"
className="pl-9"
placeholder="z. B. 0171 2345678"
value={telefon}
onChange={(e) => setTelefon(e.target.value)}
/>
</div>
</div>
</div>
</div>
{/* Bankverbindung (editierbar; Mieter) */}
{role === 'tenant' && (
<div className="rounded-2xl border bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center gap-2 font-semibold">
<Landmark className="h-4 w-4 text-teal-600" /> Bankverbindung
<span className="text-xs font-normal text-slate-400">
(für Rückzahlungen, z. B. Kaution)
</span>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="p-konto">Kontoinhaber</Label>
<Input
id="p-konto"
placeholder="Vor- und Nachname"
value={kontoinhaber}
onChange={(e) => setKontoinhaber(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="p-iban">IBAN</Label>
<Input
id="p-iban"
placeholder="DE00 0000 0000 0000 0000 00"
value={iban}
onChange={(e) => setIban(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="p-bank">Bank</Label>
<Input
id="p-bank"
placeholder="z. B. Sparkasse"
value={bankname}
onChange={(e) => setBankname(e.target.value)}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={speichern} disabled={sende} className="gap-2">
{sende ? 'Wird gespeichert …' : 'Änderungen speichern'}
</Button>
{gespeichert && (
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-600">
<Check className="h-4 w-4" /> Gespeichert.
</span>
)}
{fehler && <span className="text-sm font-medium text-red-600">{fehler}</span>}
</div>
</div>
)
}
+17
View File
@@ -80,6 +80,14 @@ interface Store extends Bootstrap {
meineEinheit: Unit
login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => Promise<void>
/** Stammdaten-Selbstpflege (v1.20): Telefon, E-Mail, Bankverbindung */
aktualisiereProfil: (daten: {
email: string
telefon: string
kontoinhaber: string
iban: string
bankname: string
}) => Promise<string | null>
registrieren: (
code: string,
benutzername: string,
@@ -237,6 +245,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
setMe(null)
setDaten(LEER)
},
async aktualisiereProfil(daten) {
try {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>('/api/auth/profil', daten)
setMe(benutzer)
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async registrieren(code, benutzername, name, email, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
+5
View File
@@ -13,6 +13,11 @@ export interface Benutzer {
aktiv: boolean
erstelltAm: string
letzterLogin?: string
/** Selbst gepflegte Stammdaten (v1.20) */
telefon?: string | null
kontoinhaber?: string | null
iban?: string | null
bankname?: string | null
}
export interface RegistrierungsCode {