- server/: Express-API (Auth, Bootstrap, Tickets, Threads, Benutzerverwaltung, Codes) mit pg-Schema (11 Tabellen), idempotem Seed (50 Wohnungen, 552 Buchungen) - mp-db (PostgreSQL 16, Volume mp_pgdata) + mp-api im Compose-Stack, nginx proxiet /api/ an mp-api; deploy.sh baut API-Image auf dem Server - Frontend: Store laedt /api/bootstrap, Schreibzugriff async, kein localStorage mehr (ausser Theme), Ladebildschirm beim Session-Check, vite-Proxy fuer Dev - Rollenrechte serverseitig: Mieter nur eigene Wohnung (getestet, 403-Verweis) - Mail-Benachrichtigungen komplett serverseitig (server/src/mail.js) - beispieldaten.py schreibt server/seed/generated.json; Verwaltungs-Mail = axel@hocks.eu - E2E live verifiziert: Login, Scoping, Ticket + Zustellung (status=sent), Registrierung, Codes; Testdaten bereinigt; Doku nachgefuehrt
372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { useStore } from '../store/store'
|
||
import type { BenutzerRolle } from '../types'
|
||
|
||
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
|
||
const tones = {
|
||
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',
|
||
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||
teal: 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300',
|
||
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
||
}
|
||
return (
|
||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${tones[tone]}`}>
|
||
{label}: {value}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function NeuerBenutzerDialog() {
|
||
const { benutzerAnlegen, units } = useStore()
|
||
const [open, setOpen] = useState(false)
|
||
const [name, setName] = useState('')
|
||
const [bn, setBn] = useState('')
|
||
const [email, setEmail] = useState('')
|
||
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
|
||
const [pw, setPw] = useState('0815')
|
||
const [einheitId, setEinheitId] = useState('hs-7')
|
||
const [fehler, setFehler] = useState<string | null>(null)
|
||
|
||
const submit = async () => {
|
||
setFehler(null)
|
||
const err = await benutzerAnlegen(bn, name, rolle, email, pw, rolle === 'mieter' ? einheitId : null)
|
||
if (err) return setFehler(err)
|
||
setName(''); setBn(''); setEmail(''); setPw('0815')
|
||
setOpen(false)
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400">
|
||
<Plus className="h-4 w-4" /> Benutzer anlegen
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="nb-name">Name</Label>
|
||
<Input id="nb-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Vor- und Nachname" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="nb-bn">Benutzername</Label>
|
||
<Input id="nb-bn" value={bn} onChange={(e) => setBn(e.target.value)} placeholder="z. B. m.mueller" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="nb-email">E-Mail</Label>
|
||
<Input id="nb-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>Gruppe</Label>
|
||
<div className="flex gap-2">
|
||
{(['mieter', 'verwaltung'] as const).map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
onClick={() => setRolle(r)}
|
||
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||
rolle === r ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||
}`}
|
||
>
|
||
{r === 'mieter' ? 'Mieter' : 'Verwaltung'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{rolle === 'mieter' && (
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="nb-einheit">Wohnung zuweisen</Label>
|
||
<select
|
||
id="nb-einheit"
|
||
value={einheitId}
|
||
onChange={(e) => setEinheitId(e.target.value)}
|
||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
>
|
||
{units.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.building}, Whg. {u.number} ({u.size} m²)
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="nb-pw">Passwort (Initial)</Label>
|
||
<Input id="nb-pw" value={pw} onChange={(e) => setPw(e.target.value)} />
|
||
<p className="text-xs text-slate-500">Der Benutzer sollte das Passwort nach dem ersten Login ändern.</p>
|
||
</div>
|
||
{fehler && (
|
||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300">{fehler}</div>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||
<Button onClick={submit} disabled={!name.trim() || !bn.trim() || !email.trim() || !pw.trim()} className="bg-amber-500 text-slate-900 hover:bg-amber-400">
|
||
Anlegen
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
export default function BenutzerVerwaltung() {
|
||
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, generiereCodes } = useStore()
|
||
const [suche, setSuche] = useState('')
|
||
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
|
||
const [anzahl, setAnzahl] = useState(5)
|
||
const [codeEinheit, setCodeEinheit] = useState('')
|
||
const [kopiert, setKopiert] = useState<string | null>(null)
|
||
|
||
const gefiltert = useMemo(() => {
|
||
const q = suche.trim().toLowerCase()
|
||
return benutzer
|
||
.filter((b) => gruppe === 'alle' || b.rolle === gruppe)
|
||
.filter(
|
||
(b) =>
|
||
!q ||
|
||
b.name.toLowerCase().includes(q) ||
|
||
b.benutzername.toLowerCase().includes(q) ||
|
||
b.email.toLowerCase().includes(q),
|
||
)
|
||
}, [benutzer, suche, gruppe])
|
||
|
||
const freieCodes = codes.filter((c) => !c.verwendetAm)
|
||
|
||
const passwortZuruecksetzen = async (id: string, name: string) => {
|
||
if (!window.confirm(`Passwort von ${name} auf „0815“ zurücksetzen?`)) return
|
||
await setPasswort(id, '0815')
|
||
}
|
||
|
||
const kopieren = (text: string) => {
|
||
navigator.clipboard?.writeText(text).catch(() => {})
|
||
setKopiert(text)
|
||
setTimeout(() => setKopiert(null), 1500)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">Benutzerverwaltung</h1>
|
||
<p className="text-sm text-slate-500">
|
||
Konten der Gruppen Verwaltung und Mieter – Registrierungscodes für Neumieter
|
||
</p>
|
||
</div>
|
||
<NeuerBenutzerDialog />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Chip label="Benutzer gesamt" value={benutzer.length} />
|
||
<Chip label="Verwaltung" value={benutzer.filter((b) => b.rolle === 'verwaltung').length} tone="amber" />
|
||
<Chip label="Mieter" value={benutzer.filter((b) => b.rolle === 'mieter').length} tone="teal" />
|
||
<Chip label="Aktive Codes" value={freieCodes.length} tone="blue" />
|
||
</div>
|
||
|
||
{/* Registrierungscodes */}
|
||
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||
<h2 className="flex items-center gap-2 font-semibold">
|
||
<KeyRound className="h-4.5 w-4.5 text-amber-600" /> Registrierungscodes für Mieter
|
||
</h2>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<select
|
||
value={codeEinheit}
|
||
onChange={(e) => setCodeEinheit(e.target.value)}
|
||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||
aria-label="Wohnung für Code"
|
||
>
|
||
<option value="">Wohnung wählen …</option>
|
||
{units.filter((u) => u.status === 'frei').map((u) => (
|
||
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
|
||
))}
|
||
{units.filter((u) => u.status === 'vermietet').map((u) => (
|
||
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={anzahl}
|
||
onChange={(e) => setAnzahl(Number(e.target.value))}
|
||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||
aria-label="Anzahl Codes"
|
||
>
|
||
{[1, 5, 10, 20].map((n) => (
|
||
<option key={n} value={n}>{n}</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
onClick={async () => {
|
||
const neu = await generiereCodes(anzahl, codeEinheit || null)
|
||
if (neu.length > 0) kopieren(neu[0].code)
|
||
}}
|
||
className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
|
||
>
|
||
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{codes.length === 0 ? (
|
||
<p className="text-sm text-slate-500">Noch keine Codes erzeugt.</p>
|
||
) : (
|
||
<div className="flex flex-wrap gap-2">
|
||
{codes.slice(0, 20).map((c) => {
|
||
const einheit = units.find((u) => u.id === c.einheitId)
|
||
return (
|
||
<button
|
||
key={c.code}
|
||
onClick={() => kopieren(c.code)}
|
||
title="Klicken zum Kopieren"
|
||
className={`rounded-lg px-3 py-1.5 font-mono text-xs transition ${
|
||
c.verwendetAm
|
||
? 'bg-slate-100 text-slate-400 line-through dark:bg-slate-800'
|
||
: kopiert === c.code
|
||
? 'bg-emerald-100 text-emerald-800 ring-2 ring-emerald-400 dark:bg-emerald-950 dark:text-emerald-300'
|
||
: 'bg-amber-50 text-amber-900 ring-1 ring-amber-200 hover:ring-amber-400 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-800'
|
||
}`}
|
||
>
|
||
{c.code}
|
||
{einheit && <span className="ml-1.5 font-sans text-[10px] text-slate-500">({einheit.building.split(' ')[0]} {einheit.number})</span>}
|
||
{c.verwendetAm && <span className="ml-1 font-sans text-[10px]">verwendet</span>}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
<p className="mt-2 text-xs text-slate-400">
|
||
Mieter registrieren sich auf der Startseite mit einem Code – der Code wird dabei verbraucht
|
||
und die Wohnung automatisch zugeordnet.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Benutzerliste */}
|
||
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||
<h2 className="flex items-center gap-2 font-semibold">
|
||
<UsersRound className="h-4.5 w-4.5 text-amber-600" /> Benutzerkonten
|
||
</h2>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||
<Input
|
||
value={suche}
|
||
onChange={(e) => setSuche(e.target.value)}
|
||
placeholder="Suchen …"
|
||
className="w-56 pl-8"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
{(['alle', 'mieter', 'verwaltung'] as const).map((g) => (
|
||
<button
|
||
key={g}
|
||
onClick={() => setGruppe(g)}
|
||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
|
||
gruppe === g
|
||
? 'bg-amber-500 text-slate-900'
|
||
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||
}`}
|
||
>
|
||
{g === 'alle' ? 'Alle' : g === 'mieter' ? 'Mieter' : 'Verwaltung'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{gefiltert.map((b) => {
|
||
const einheit = units.find((u) => u.id === b.einheitId)
|
||
return (
|
||
<div
|
||
key={b.id}
|
||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 transition ${
|
||
b.aktiv ? 'bg-white dark:bg-slate-900' : 'bg-slate-50 opacity-60 dark:bg-slate-950'
|
||
}`}
|
||
>
|
||
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold ${
|
||
b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'
|
||
}`}>
|
||
{b.name.split(' ').map((t) => t[0]).slice(0, 2).join('')}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="truncate text-sm font-semibold">{b.name}</span>
|
||
<Badge variant="secondary" className={b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'}>
|
||
{b.rolle === 'verwaltung' ? (
|
||
<><ShieldCheck className="mr-1 h-3 w-3" /> Verwaltung</>
|
||
) : (
|
||
<><UserCheck className="mr-1 h-3 w-3" /> Mieter</>
|
||
)}
|
||
</Badge>
|
||
{!b.aktiv && <Badge variant="secondary" className="bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">deaktiviert</Badge>}
|
||
</div>
|
||
<div className="truncate text-xs text-slate-500">
|
||
@{b.benutzername} · {b.email}
|
||
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
|
||
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
|
||
</div>
|
||
</div>
|
||
{b.rolle === 'mieter' && (
|
||
<select
|
||
value={b.einheitId ?? ''}
|
||
onChange={(e) => weiseEinheitZu(b.id, e.target.value || null)}
|
||
className="rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||
aria-label={`Wohnung für ${b.name}`}
|
||
>
|
||
<option value="">Keine Wohnung</option>
|
||
{units.map((u) => (
|
||
<option key={u.id} value={u.id}>
|
||
{u.building}, Whg. {u.number}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<div className="flex gap-1.5">
|
||
<button
|
||
onClick={() => passwortZuruecksetzen(b.id, b.name)}
|
||
className="rounded-lg bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||
>
|
||
Passwort → 0815
|
||
</button>
|
||
<button
|
||
onClick={() => setAktiv(b.id, !b.aktiv)}
|
||
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition ${
|
||
b.aktiv
|
||
? 'bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-950 dark:text-red-300'
|
||
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300'
|
||
}`}
|
||
>
|
||
{b.aktiv ? 'Deaktivieren' : 'Aktivieren'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{gefiltert.length === 0 && (
|
||
<p className="py-6 text-center text-sm text-slate-500">Keine Benutzer für diesen Filter.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|