v1.1: Rebrand Hinterstein Immobilien + echtes Auth-System

- Rebrand: Kunden-Logo, Joseph-Hinterstein-Portrait, Kontakt-Banner, Texte
- Login-Pflicht auf /login (Route-Guard), Gruppen Verwaltung/Mieter
- Mieter-Registrierung per einmaligem Registrierungscode
- Benutzerverwaltung als 6. Modul (/benutzer, nur Verwaltung)
- Demo-Zugänge mit Passwort 0815 (j.hinterstein, a.berger, m.wolf)
- Mail-Server-API: Token-Auth (/send, X-Api-Token), Mail-Stack live auf Server
- Store-Key hinterstein-mieterportal-v2, Doku (kimi.md, README) nachgeführt
This commit is contained in:
2026-10-02 12:11:43 +02:00
parent ccfe08ad24
commit fbe033ebd6
22 changed files with 1067 additions and 179 deletions
+368
View File
@@ -0,0 +1,368 @@
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={() => generiereCodes(anzahl, codeEinheit || null)}
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>
)
}