v1.3: Serverseitige Datenbank (PostgreSQL + Node-API) ersetzt localStorage

- 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
This commit is contained in:
2026-10-02 12:54:44 +02:00
parent 5503ce5fc5
commit 311153b3c3
26 changed files with 8873 additions and 894 deletions
+15 -2
View File
@@ -9,14 +9,27 @@ import Documents from './pages/Documents'
import Messages from './pages/Messages'
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
function Ladebildschirm() {
return (
<div className="flex min-h-screen items-center justify-center bg-[#22333b]">
<div className="text-center text-slate-300">
<div className="text-lg font-semibold text-white">Hinterstein Immobilien</div>
<div className="mt-1 text-sm">Mieterportal wird geladen …</div>
</div>
</div>
)
}
function RequireAuth({ children }: { children: React.ReactElement }) {
const { me } = useStore()
const { me, bereit } = useStore()
if (!bereit) return <Ladebildschirm />
if (!me) return <Navigate to="/login" replace />
return children
}
function LoginGate() {
const { me } = useStore()
const { me, bereit } = useStore()
if (!bereit) return <Ladebildschirm />
if (me) return <Navigate to="/" replace />
return <Login />
}
-251
View File
@@ -1,251 +0,0 @@
import type {
Unit,
Transaction,
Ticket,
DocumentItem,
Thread,
Announcement,
} from '../types'
import generated from './generated.json'
// Demo-Mieter: Whg. Hauptstraße 12 Nr. 7
export const DEMO_TENANT = 'Anna Berger'
export const DEMO_UNIT_ID = 'hs-7'
// Maschinell erzeugte Bestandsdaten (beispieldaten.py -> generated.json)
export const units = generated.units as Unit[]
export const transactions = generated.transactions as Transaction[]
export const tickets: Ticket[] = [
{
id: 't-1',
unitId: DEMO_UNIT_ID,
tenant: DEMO_TENANT,
title: 'Wasserhahn im Bad tropft',
description:
'Der Wasserhahn am Waschbecken tropft seit einigen Tagen permanent, auch bei ganz zugedrehtem Ventil.',
category: 'Sanitär',
priority: 'mittel',
status: 'in Bearbeitung',
createdAt: '28.09.2026',
},
{
id: 't-2',
unitId: DEMO_UNIT_ID,
tenant: DEMO_TENANT,
title: 'Briefkastenschlüssel defekt',
description: 'Der Schlüssel für den Briefkasten lässt sich nicht mehr drehen, vermutlich Schloss verklebt.',
category: 'Schlüssel',
priority: 'niedrig',
status: 'offen',
createdAt: '30.09.2026',
},
{
id: 't-3',
unitId: 'rw-4',
tenant: units.find((u) => u.id === 'rw-4')?.tenant ?? '',
title: 'Heizkörper wird nicht warm',
description: 'Der Heizkörper im Wohnzimmer bleibt kalt, obwohl das Thermostat voll aufgedreht ist.',
category: 'Heizung',
priority: 'hoch',
status: 'offen',
createdAt: '01.10.2026',
},
{
id: 't-4',
unitId: 'ap-2',
tenant: units.find((u) => u.id === 'ap-2')?.tenant ?? '',
title: 'Rollladen im Schlafzimmer klemmt',
description: 'Der elektrische Rollladen fährt nur noch zur Hälfte hoch.',
category: 'Fenster / Rollladen',
priority: 'mittel',
status: 'in Bearbeitung',
createdAt: '25.09.2026',
},
{
id: 't-5',
unitId: 'hs-14',
tenant: units.find((u) => u.id === 'hs-14')?.tenant ?? '',
title: 'Flurbeleuchtung im Treppenhaus defekt',
description: 'Im 3. OG ist die Bewegungsmelder-Leuchte ausgefallen.',
category: 'Elektrik',
priority: 'hoch',
status: 'erledigt',
createdAt: '18.09.2026',
},
{
id: 't-6',
unitId: 'rw-9',
tenant: units.find((u) => u.id === 'rw-9')?.tenant ?? '',
title: 'Schimmel im Bad – Lüftungsproblem?',
description: 'In der Fensterfalz des Bades sammelt sich Kondenswasser, bitte um Prüfung der Lüftung.',
category: 'Sanitär',
priority: 'hoch',
status: 'in Bearbeitung',
createdAt: '22.09.2026',
},
{
id: 't-7',
unitId: 'ap-11',
tenant: units.find((u) => u.id === 'ap-11')?.tenant ?? '',
title: 'Markise auf dem Balkon lose',
description: 'Die Befestigung der Markise wackelt bei Wind deutlich.',
category: 'Balkon / Außenanlage',
priority: 'hoch',
status: 'offen',
createdAt: '29.09.2026',
},
{
id: 't-8',
unitId: 'hs-3',
tenant: units.find((u) => u.id === 'hs-3')?.tenant ?? '',
title: 'Spülmaschinen-Anschluss undicht',
description: 'Unter der Spüle steht Wasser, vermutlich kommt es vom Geräteanschluss.',
category: 'Sanitär',
priority: 'mittel',
status: 'erledigt',
createdAt: '10.09.2026',
},
]
export const documents: DocumentItem[] = [
{
id: 'd-1',
unitId: DEMO_UNIT_ID,
title: 'Mietvertrag Anna Berger – Hauptstraße 12, Whg. 7',
category: 'Mietvertrag',
size: '1,8 MB',
date: '01.03.2024',
},
{
id: 'd-2',
unitId: DEMO_UNIT_ID,
title: 'Nebenkostenabrechnung 2025',
category: 'Nebenkostenabrechnung',
size: '640 KB',
date: '31.05.2026',
},
{
id: 'd-3',
unitId: DEMO_UNIT_ID,
title: 'Heizkostenabrechnung 2025',
category: 'Nebenkostenabrechnung',
size: '410 KB',
date: '31.05.2026',
},
{
id: 'd-4',
unitId: null,
title: 'Hausordnung – gültige Fassung 2026',
category: 'Hausordnung',
size: '220 KB',
date: '01.01.2026',
},
{
id: 'd-5',
unitId: null,
title: 'Protokoll Hausversammlung 14.06.2026',
category: 'Protokoll',
size: '350 KB',
date: '14.06.2026',
},
{
id: 'd-6',
unitId: null,
title: 'Mülltonnen-Belegungsplan & Abfuhrtermine 2026',
category: 'Sonstiges',
size: '180 KB',
date: '02.01.2026',
},
{
id: 'd-7',
unitId: 'rw-4',
title: 'Mietvertrag – Ringweg 3, Whg. 4',
category: 'Mietvertrag',
size: '1,6 MB',
date: '15.08.2025',
},
{
id: 'd-8',
unitId: 'ap-2',
title: 'Mietvertrag – Am Park 5, Whg. 2',
category: 'Mietvertrag',
size: '1,7 MB',
date: '01.06.2024',
},
]
export const threads: Thread[] = [
{
id: 'm-1',
unitId: DEMO_UNIT_ID,
tenant: DEMO_TENANT,
subject: 'Mietkaution – Rückzahlungsnachweis',
unreadByAdmin: false,
unreadByTenant: true,
messages: [
{
id: 'm-1-a',
from: 'tenant',
author: DEMO_TENANT,
text: 'Guten Tag, könnten Sie mir bitte den aktuellen Stand meiner Mietkaution mit Nachweis der Verzinsung zusenden?',
at: '27.09.2026, 10:12',
},
{
id: 'm-1-b',
from: 'admin',
author: 'Joseph Hinterstein',
text: 'Guten Tag Frau Berger, die Kaution wurde mit 1.500,00 € auf dem Treuhandkonto verbucht. Die Verzinsung für 2025 beträgt 18,40 € – den Nachweis finden Sie ab sofort auch unter Dokumente.',
at: '29.09.2026, 09:03',
},
],
},
{
id: 'm-2',
unitId: 'rw-9',
tenant: units.find((u) => u.id === 'rw-9')?.tenant ?? '',
subject: 'Terminabstimmung Wohnungsbegehung (Schimmel)',
unreadByAdmin: true,
unreadByTenant: false,
messages: [
{
id: 'm-2-a',
from: 'tenant',
author: units.find((u) => u.id === 'rw-9')?.tenant ?? '',
text: 'Aufgrund der Schadensmeldung schlage ich einen Begehungstermin nächste Woche vor. Passt Mittwoch oder Freitag Nachmittag?',
at: '01.10.2026, 08:45',
},
],
},
]
export const announcements: Announcement[] = [
{
id: 'a-1',
title: 'Wasserabschaltung Hauptstraße 12',
text: 'Am Donnerstag, 08.10.2026, 09:00–13:00 Uhr findet eine Druckerhöhungs-Pumpen-Wartung statt. In dieser Zeit steht kein Wasser zur Verfügung.',
date: '02.10.2026',
tag: 'Technik',
},
{
id: 'a-2',
title: 'Herbst-Hausbegehung mit Hausmeister',
text: 'Am Samstag, 17.10.2026, 10:00 Uhr geht es gemeinsam durch alle Häuser. Anschließend gibt es Kaffee im Hof von Am Park 5.',
date: '28.09.2026',
tag: 'Termin',
},
{
id: 'a-3',
title: 'Tiefgarage Ringweg 3: Reinigung',
text: 'Die Tiefgarage wird am 21.10.2026 gereinigt. Bitte Fahrzeuge bis 07:30 Uhr entfernen, sonst erfolgt ein kostenpflichtiges Versetzen.',
date: '20.09.2026',
tag: 'Hinweis',
},
{
id: 'a-4',
title: 'Nebenkostenabrechnungen 2025 versandt',
text: 'Alle Abrechnungen liegen digital im Dokumentenbereich vor. Nachzahlungen sind bis 31.10.2026 überwiesen.',
date: '31.05.2026',
tag: 'Abrechnung',
},
]
+54
View File
@@ -0,0 +1,54 @@
/** Dünner Fetch-Client für die Portal-API (gleicher Ursprung, Session-Cookie). */
export class ApiFehler extends Error {
status: number
constructor(status: number, nachricht: string) {
super(nachricht)
this.status = status
}
}
interface Antwort<T> {
json: T | null
status: number
}
async function anfrage<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<Antwort<T>> {
let res: Response
try {
res = await fetch(pfad, {
credentials: 'same-origin',
...init,
headers: {
...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(init?.headers ?? {}),
},
...(json !== undefined ? { body: JSON.stringify(json) } : {}),
})
} catch {
throw new ApiFehler(0, 'Keine Verbindung zum Server.')
}
let daten: T | null = null
try {
daten = (await res.json()) as T
} catch {
/* leerer Body */
}
return { json: daten, status: res.status }
}
/** Führt eine API-Anfrage aus und liefert Daten oder eine deutschsprachige Fehlermeldung. */
export async function api<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<T> {
const { json: daten, status } = await anfrage<T>(pfad, init, json)
if (status >= 400 || daten === null) {
const fehler =
(daten as { fehler?: string } | null)?.fehler ??
(status === 401 ? 'Sitzung abgelaufen – bitte erneut anmelden.' : `Fehler (${status}).`)
throw new ApiFehler(status, fehler)
}
return daten
}
export const apiGet = <T>(pfad: string) => api<T>(pfad)
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
+5 -46
View File
@@ -1,10 +1,8 @@
/** Authentifizierung + Session, Muster aus dem Schulportal. */
import type { Benutzer, BenutzerRolle } from '../types'
export const SESSION_KEY = 'mp_benutzer_session'
export const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 // 8 h
export const BENUTZER_CHANGED_EVENT = 'mp-benutzer-changed'
/** Passwort-Hashing (wie bisher: SHA-256 im Browser, Vergleich serverseitig).
*
* Die Sitzung selbst liegt seit v1.3 serverseitig (HttpOnly-Cookie,
* Tabelle sessions) – es gibt nichts mehr im localStorage zu verwalten.
*/
/** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */
export const DEMO_PASSWORT = '0815'
@@ -15,42 +13,3 @@ export async function sha256(text: string): Promise<string> {
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
export interface Session {
benutzerId: string
rolle: BenutzerRolle
bis: number
}
export function ladeSession(): Session | null {
try {
const raw = localStorage.getItem(SESSION_KEY)
if (!raw) return null
const s = JSON.parse(raw) as Session
if (s.bis < Date.now()) {
localStorage.removeItem(SESSION_KEY)
return null
}
return s
} catch {
return null
}
}
export function speichereSession(benutzerId: string, rolle: BenutzerRolle) {
const s: Session = { benutzerId, rolle, bis: Date.now() + SESSION_DAUER_MS }
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT))
}
export function beendeSession() {
localStorage.removeItem(SESSION_KEY)
window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT))
}
export function aktuellerBenutzer(alle: Benutzer[]): Benutzer | null {
const s = ladeSession()
if (!s) return null
const b = alle.find((x) => x.id === s.benutzerId && x.aktiv)
return b ?? null
}
-64
View File
@@ -1,64 +0,0 @@
/** E-Mail-Benachrichtigungen über die Portal-Mail-API.
*
* Der Browser ruft nur denselben Ursprung an: POST /api/mail/send wird vom
* Portal-nginx (Container mp-portal) an die Mail-API (mp-mail-api:8080)
* weitergereicht und dabei serverseitig mit dem API-Token authentifiziert.
* Das Token liegt also weder im Repo noch im JavaScript.
*
* Versand ist bewusst „fire-and-forget": Klappt der Versand nicht (Offline,
* API down), bleibt die Portal-Aktion trotzdem erhalten – der Fehler landet
* nur in der Browser-Konsole.
*/
import type { Benutzer } from '../types'
const ENDPUNKT = '/api/mail/send'
export async function sendeMail(an: string, betreff: string, text: string): Promise<boolean> {
try {
const res = await fetch(ENDPUNKT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: an, subject: betreff, text }),
})
return res.ok
} catch {
return false
}
}
/** Fire-and-forget an eine oder mehrere Adressen (null/leer = nichts senden). */
export function sendeBenachrichtigung(an: string | string[] | null | undefined, betreff: string, text: string) {
const adressen = (Array.isArray(an) ? an : an ? [an] : []).filter(Boolean)
for (const adresse of adressen) {
sendeMail(adresse, betreff, text).then((ok) => {
if (!ok) console.warn(`[mail] Versand an ${adresse} fehlgeschlagen`)
})
}
}
const FUSSZEILE =
'\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
export function nachrichtenText(koerper: string): string {
return `${koerper}${FUSSZEILE}`
}
/** E-Mail-Adressen aller aktiven Verwaltungsbenutzer. */
export function verwaltungsAdressen(benutzer: Benutzer[]): string[] {
return benutzer
.filter((b) => b.rolle === 'verwaltung' && b.aktiv && b.email)
.map((b) => b.email)
}
/** E-Mail-Adressen der Mieter einer bestimmten Wohnung. */
export function mieterAdressenFuerEinheit(benutzer: Benutzer[], einheitId: string): string[] {
return benutzer
.filter((b) => b.rolle === 'mieter' && b.aktiv && b.einheitId === einheitId && b.email)
.map((b) => b.email)
}
/** Anzeigename einer Wohnung, z. B. „Hauptstraße 12, Whg. 7". */
export function einheitLabel(einheit: { building: string; number: string }): string {
return `${einheit.building}, Whg. ${einheit.number}`
}
+4 -1
View File
@@ -215,7 +215,10 @@ export default function BenutzerVerwaltung() {
))}
</select>
<Button
onClick={() => generiereCodes(anzahl, codeEinheit || null)}
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
+13 -86
View File
@@ -13,71 +13,21 @@ import {
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { useStore } from '../store/store'
import {
sendeBenachrichtigung,
verwaltungsAdressen,
mieterAdressenFuerEinheit,
nachrichtenText,
} from '../lib/mail'
/** Gegenstelle benachrichtigen: Mieter → Verwaltung, Verwaltung → Mieter der Wohnung. */
function benachrichtigeGegenstelle(opts: {
benutzer: ReturnType<typeof useStore>['benutzer']
vonRolle: 'tenant' | 'admin'
einheitId: string
betreff: string
text: string
}) {
const adressen =
opts.vonRolle === 'tenant'
? verwaltungsAdressen(opts.benutzer)
: mieterAdressenFuerEinheit(opts.benutzer, opts.einheitId)
sendeBenachrichtigung(adressen, opts.betreff, nachrichtenText(opts.text))
}
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
const { dispatch, role, me, meineEinheit, benutzer } = useStore()
const { newThread, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState('')
const [text, setText] = useState('')
const verwaltungsName = 'Joseph Hinterstein'
const submit = () => {
const submit = async () => {
if (!subject.trim() || !text.trim()) return
const id = `m-${Date.now()}`
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName
dispatch({
type: 'newThread',
thread: {
id,
unitId,
tenant: autor,
subject: subject.trim(),
unreadByAdmin: true,
unreadByTenant: false,
messages: [
{
id: `${id}-1`,
from: role,
author: autor,
text: text.trim(),
at: new Date().toLocaleString('de-DE'),
},
],
},
})
benachrichtigeGegenstelle({
benutzer,
vonRolle: role,
einheitId: unitId,
betreff:
role === 'tenant'
? `Neue Nachricht im Mieterportal: ${subject.trim()}`
: `Nachricht der Verwaltung: ${subject.trim()}`,
text: `Neue Unterhaltung von ${autor}:\n\n„${text.trim()}“`,
})
const id = await newThread(
subject.trim(),
text.trim(),
role === 'tenant' ? meineEinheit.id : 'hs-1',
)
if (!id) return
setSubject('')
setText('')
setOpen(false)
@@ -117,9 +67,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
}
export default function Messages() {
const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore()
const meinName = me?.name ?? 'Mieter'
const verwaltungsName = 'Joseph Hinterstein'
const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
const visible = role === 'tenant'
? threads.filter((t) => t.unitId === meineEinheit.id)
: threads
@@ -130,35 +78,14 @@ export default function Messages() {
useEffect(() => {
if (!active) return
dispatch({ type: 'markThreadRead', threadId: active.id, role })
markThreadRead(active.id)
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [active?.id, active?.messages.length, dispatch, role])
}, [active?.id, active?.messages.length, markThreadRead, role])
const send = () => {
const send = async () => {
if (!active || !draft.trim()) return
const text = draft.trim()
const autor = role === 'tenant' ? meinName : verwaltungsName
dispatch({
type: 'addMessage',
threadId: active.id,
message: {
id: `${active.id}-${Date.now()}`,
from: role,
author: autor,
text,
at: new Date().toLocaleString('de-DE'),
},
})
benachrichtigeGegenstelle({
benutzer,
vonRolle: role,
einheitId: active.unitId,
betreff:
role === 'tenant'
? `Neue Antwort im Mieterportal: ${active.subject}`
: `Antwort der Verwaltung: ${active.subject}`,
text: `Neue Nachricht von ${autor} in der Unterhaltung „${active.subject}“:\n\n„${text}“`,
})
await addMessage(active.id, text)
setDraft('')
}
+16 -43
View File
@@ -13,12 +13,6 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
sendeBenachrichtigung,
verwaltungsAdressen,
nachrichtenText,
einheitLabel,
} from '../lib/mail'
import {
Select,
SelectContent,
@@ -52,45 +46,26 @@ const categories = [
]
function NewTicketDialog() {
const { dispatch, role, me, meineEinheit, benutzer, units } = useStore()
const { addTicket, role, meineEinheit } = useStore()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [category, setCategory] = useState('Sanitär')
const [priority, setPriority] = useState<TicketPriority>('mittel')
const [description, setDescription] = useState('')
const [sende, setSende] = useState(false)
const submit = () => {
if (!title.trim()) return
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
const einheit = units.find((u) => u.id === unitId) ?? meineEinheit
const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)'
dispatch({
type: 'addTicket',
ticket: {
id: `t-${Date.now()}`,
unitId,
tenant: mieterName,
title: title.trim(),
description: description.trim() || '–',
category,
priority,
status: 'offen',
createdAt: new Date().toLocaleDateString('de-DE'),
},
const submit = async () => {
if (!title.trim() || sende) return
setSende(true)
const err = await addTicket({
title: title.trim(),
description: description.trim() || '–',
category,
priority,
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
})
// Verwaltung per E-Mail benachrichtigen (fire-and-forget)
sendeBenachrichtigung(
verwaltungsAdressen(benutzer),
`Neue Schadensmeldung: ${title.trim()} (${einheitLabel(einheit)})`,
nachrichtenText(
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
`Wohnung: ${einheitLabel(einheit)}\n` +
`Meldung von: ${mieterName}\n` +
`Kategorie: ${category}\n` +
`Dringlichkeit: ${priority}\n\n` +
`${title.trim()}\n${description.trim() || '–'}`,
),
)
setSende(false)
if (err) return
setTitle('')
setDescription('')
setOpen(false)
@@ -158,7 +133,7 @@ function NewTicketDialog() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!title.trim()}>Meldung absenden</Button>
<Button onClick={submit} disabled={!title.trim() || sende}>{sende ? 'Wird gesendet …' : 'Meldung absenden'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -166,7 +141,7 @@ function NewTicketDialog() {
}
export default function Tickets() {
const { tickets, units, role, dispatch, meineEinheit } = useStore()
const { tickets, units, role, setTicketStatus, meineEinheit } = useStore()
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
const visible = useMemo(
@@ -247,9 +222,7 @@ export default function Tickets() {
{role === 'admin' ? (
<Select
value={t.status}
onValueChange={(v) =>
dispatch({ type: 'setTicketStatus', id: t.id, status: v as TicketStatus })
}
onValueChange={(v) => setTicketStatus(t.id, v as TicketStatus)}
>
<SelectTrigger className="h-8 w-40">
<SelectValue />
+204 -324
View File
@@ -1,240 +1,72 @@
/** Zentraler Store des Mieterportals – seit v1.3 komplett serverseitig
* (Node-API + PostgreSQL). Der Browser hält keine Mietdaten mehr im
* localStorage; die Sitzung läuft über ein HttpOnly-Cookie (8 h).
*
* Das Store-Interface bleibt absichtlich nahe an der bisherigen Form, damit
* die Seiten nur minimal angepasst werden mussten: Lesezugriff synchron über
* den State, Schreibzugriff über asynchrone Methoden (rufen die API auf und
* aktualisieren danach den lokalen State aus der Antwort/Neuladung).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useState,
type ReactNode,
} from 'react'
import type {
Role,
Ticket,
TicketStatus,
Transaction,
DocumentItem,
Thread,
Message,
Benutzer,
BenutzerRolle,
RegistrierungsCode,
Unit,
Announcement,
} from '../types'
import {
units,
transactions,
tickets,
documents,
threads,
announcements,
DEMO_TENANT,
DEMO_UNIT_ID,
} from '../data/seed'
import {
sha256,
DEMO_PASSWORT,
ladeSession,
speichereSession,
beendeSession,
type Session,
} from '../lib/auth'
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
export const DEMO_TENANT = 'Anna Berger'
export const DEMO_UNIT_ID = 'hs-7'
/** Demo-Benutzer (Passwort aller: 0815). */
export const demoBenutzer: Benutzer[] = [
{
id: 'b-joseph',
benutzername: 'j.hinterstein',
name: 'Joseph Hinterstein',
passwortHash: DEMO_HASH,
rolle: 'verwaltung',
email: 'joseph@hinterstein-immo.de',
einheitId: null,
aktiv: true,
erstelltAm: '01.03.2024',
},
{
id: 'b-anna',
benutzername: 'a.berger',
name: DEMO_TENANT,
passwortHash: DEMO_HASH,
rolle: 'mieter',
email: 'anna.berger@example.de',
einheitId: DEMO_UNIT_ID,
aktiv: true,
erstelltAm: '01.03.2024',
letzterLogin: '02.10.2026, 09:12',
},
{
id: 'b-jonas',
benutzername: 'm.wolf',
name: 'Jonas Klein',
passwortHash: DEMO_HASH,
rolle: 'mieter',
email: 'jonas.klein@example.de',
einheitId: 'rw-4',
aktiv: true,
erstelltAm: '15.08.2025',
},
]
/** Demo-Registrierungscode (Verwaltung kann weitere erzeugen). */
const demoCodes: RegistrierungsCode[] = [
{
code: 'MP-2026-0815',
einheitId: 'ap-2',
erstelltAm: '02.10.2026',
},
]
interface State {
tickets: Ticket[]
interface Bootstrap {
units: Unit[]
transactions: Transaction[]
documents: DocumentItem[]
tickets: Ticket[]
threads: Thread[]
announcements: Announcement[]
benutzer: Benutzer[]
codes: RegistrierungsCode[]
session: Session | null
tick: number
}
type Action =
| { type: 'addTicket'; ticket: Ticket }
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
| { type: 'addMessage'; threadId: string; message: Message }
| { type: 'newThread'; thread: Thread }
| { type: 'markThreadRead'; threadId: string; role: Role }
| { type: 'setSession'; session: Session | null }
| { type: 'registriert'; benutzer: Benutzer; code: string }
| { type: 'benutzerAngelegt'; benutzer: Benutzer }
| { type: 'benutzerAktiv'; id: string; aktiv: boolean }
| { type: 'passwortGesetzt'; id: string; hash: string }
| { type: 'einheitZugewiesen'; id: string; einheitId: string | null }
| { type: 'codesHinzugefuegt'; codes: RegistrierungsCode[] }
const initialState: State = {
tickets,
transactions,
documents,
threads,
benutzer: demoBenutzer,
codes: demoCodes,
session: ladeSession(),
tick: 0,
const LEER: Bootstrap = {
units: [],
transactions: [],
documents: [],
tickets: [],
threads: [],
announcements: [],
benutzer: [],
codes: [],
}
const STORAGE_KEY = 'hinterstein-mieterportal-v2'
function loadState(): State {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
return { ...initialState, ...parsed, session: ladeSession() }
}
} catch {
/* ignore */
}
return initialState
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'addTicket':
return { ...state, tickets: [action.ticket, ...state.tickets] }
case 'setTicketStatus':
return {
...state,
tickets: state.tickets.map((t) =>
t.id === action.id ? { ...t, status: action.status } : t,
),
}
case 'addMessage':
return {
...state,
threads: state.threads.map((th) =>
th.id === action.threadId
? {
...th,
unreadByAdmin: action.message.from === 'tenant',
unreadByTenant: action.message.from === 'admin',
messages: [...th.messages, action.message],
}
: th,
),
}
case 'newThread':
return { ...state, threads: [action.thread, ...state.threads] }
case 'markThreadRead':
return {
...state,
threads: state.threads.map((th) =>
th.id === action.threadId
? action.role === 'tenant'
? { ...th, unreadByTenant: false }
: { ...th, unreadByAdmin: false }
: th,
),
}
case 'setSession':
return { ...state, session: action.session, tick: state.tick + 1 }
case 'registriert':
return {
...state,
benutzer: [...state.benutzer, action.benutzer],
codes: state.codes.map((c) =>
c.code === action.code ? { ...c, verwendetAm: new Date().toLocaleDateString('de-DE') } : c,
),
}
case 'benutzerAngelegt':
return { ...state, benutzer: [...state.benutzer, action.benutzer] }
case 'benutzerAktiv':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, aktiv: action.aktiv } : b,
),
}
case 'passwortGesetzt':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, passwortHash: action.hash } : b,
),
}
case 'einheitZugewiesen':
return {
...state,
benutzer: state.benutzer.map((b) =>
b.id === action.id ? { ...b, einheitId: action.einheitId } : b,
),
}
case 'codesHinzugefuegt':
return { ...state, codes: [...action.codes, ...state.codes] }
default:
return state
}
}
function neuerCode(): string {
const teile = () =>
Array.from(crypto.getRandomValues(new Uint8Array(2)))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
return `MP-${teile()}-${teile()}`
}
interface Store extends State {
dispatch: React.Dispatch<Action>
units: typeof units
announcements: typeof announcements
/** Aktuell angemeldeter Benutzer (null = nicht eingeloggt). */
interface Store extends Bootstrap {
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
bereit: boolean
me: Benutzer | null
/** Abgeleitete Portal-Rolle für die Modul-Ansichten. */
role: Role
/** Einheit des angemeldeten Mieters (Demo-Fallback hs-7). */
meineEinheit: (typeof units)[number]
meineEinheit: Unit
login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => void
logout: () => Promise<void>
registrieren: (
code: string,
benutzername: string,
@@ -242,6 +74,11 @@ interface Store extends State {
email: string,
passwort: string,
) => Promise<string | null>
addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise<string | null>
setTicketStatus: (id: string, status: TicketStatus) => Promise<void>
newThread: (subject: string, text: string, unitId?: string) => Promise<string | null>
addMessage: (threadId: string, text: string) => Promise<void>
markThreadRead: (threadId: string) => void
benutzerAnlegen: (
benutzername: string,
name: string,
@@ -250,136 +87,179 @@ interface Store extends State {
passwort: string,
einheitId: string | null,
) => Promise<string | null>
setAktiv: (id: string, aktiv: boolean) => void
setAktiv: (id: string, aktiv: boolean) => Promise<void>
setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => void
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[]
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
}
const StoreContext = createContext<Store | null>(null)
export function StoreProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, undefined, loadState)
const [bereit, setBereit] = useState(false)
const [me, setMe] = useState<Benutzer | null>(null)
const [daten, setDaten] = useState<Bootstrap>(LEER)
/** Lädt alle Stammdaten neu (nach Login/Registrierung und nach Schreibaktionen). */
const ladeAlles = useCallback(async () => {
const b = await apiGet<Bootstrap>('/api/bootstrap')
setDaten(b)
}, [])
// Start: bestehende Session prüfen, dann Daten laden
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
const value = useMemo<Store>(() => {
const me =
(state.session &&
state.benutzer.find((b) => b.id === state.session!.benutzerId && b.aktiv)) ||
null
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit =
units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
units.find((u) => u.id === DEMO_UNIT_ID)!
const login = async (benutzername: string, passwort: string) => {
const b = state.benutzer.find(
(x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase(),
)
if (!b) return 'Benutzername oder Passwort falsch.'
if (!b.aktiv) return 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.'
if ((await sha256(passwort)) !== b.passwortHash)
return 'Benutzername oder Passwort falsch.'
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
}
const logout = () => {
beendeSession()
dispatch({ type: 'setSession', session: null })
}
const registrieren = async (
code: string,
benutzername: string,
name: string,
email: string,
passwort: string,
) => {
const c = state.codes.find(
(x) => x.code.toUpperCase() === code.trim().toUpperCase() && !x.verwendetAm,
)
if (!c) return 'Der Registrierungscode ist ungültig oder bereits verwendet.'
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
return 'Dieser Benutzername ist bereits vergeben.'
const b: Benutzer = {
id: `b-${Date.now()}`,
benutzername: benutzername.trim(),
name: name.trim(),
passwortHash: await sha256(passwort),
rolle: 'mieter',
email: email.trim(),
einheitId: c.einheitId,
aktiv: true,
erstelltAm: new Date().toLocaleDateString('de-DE'),
let aktiv = true
;(async () => {
try {
const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me')
if (!aktiv) return
setMe(benutzer)
await ladeAlles()
} catch {
/* nicht angemeldet – Login-Seite zeigen */
} finally {
if (aktiv) setBereit(true)
}
dispatch({ type: 'registriert', benutzer: b, code: c.code })
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
})()
return () => {
aktiv = false
}
}, [ladeAlles])
const benutzerAnlegen = async (
benutzername: string,
name: string,
rolle: BenutzerRolle,
email: string,
passwort: string,
einheitId: string | null,
) => {
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
return 'Dieser Benutzername ist bereits vergeben.'
const b: Benutzer = {
id: `b-${Date.now()}`,
benutzername: benutzername.trim(),
name: name.trim(),
passwortHash: await sha256(passwort),
rolle,
email: email.trim(),
einheitId: rolle === 'mieter' ? einheitId : null,
aktiv: true,
erstelltAm: new Date().toLocaleDateString('de-DE'),
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
const meineEinheit =
daten.units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
daten.units.find((u) => u.id === DEMO_UNIT_ID) ??
daten.units[0]
const fehlerNachricht = (e: unknown): string =>
e instanceof ApiFehler ? e.message : 'Ein unerwarteter Fehler ist aufgetreten.'
const value = useMemo<Store>(() => ({
...daten,
bereit,
me,
role,
meineEinheit,
// ── Auth ────────────────────────────────────────────────
async login(benutzername, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/login', {
benutzername,
passwortHash: await sha256(passwort),
})
setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
dispatch({ type: 'benutzerAngelegt', benutzer: b })
return null
}
const setPasswort = async (id: string, passwort: string) => {
dispatch({ type: 'passwortGesetzt', id, hash: await sha256(passwort) })
}
const generiereCodes = (anzahl: number, einheitId: string | null) => {
const neu: RegistrierungsCode[] = Array.from({ length: anzahl }, () => ({
code: neuerCode(),
einheitId,
erstelltAm: new Date().toLocaleDateString('de-DE'),
},
async logout() {
try {
await apiPost('/api/auth/logout')
} catch {
/* Session ggf. bereits ungültig – trotzdem lokal abmelden */
}
setMe(null)
setDaten(LEER)
},
async registrieren(code, benutzername, name, email, passwort) {
try {
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
code,
benutzername,
name,
email,
passwortHash: await sha256(passwort),
})
setMe(benutzer)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
// ── Schadensmeldungen ───────────────────────────────────
async addTicket(t) {
try {
await apiPost('/api/tickets', t)
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setTicketStatus(id, status) {
const { ticket } = await apiPatch<{ ticket: Ticket }>(`/api/tickets/${id}`, { status })
setDaten((d) => ({ ...d, tickets: d.tickets.map((t) => (t.id === id ? ticket : t)) }))
},
// ── Nachrichten ─────────────────────────────────────────
async newThread(subject, text, unitId) {
try {
const { thread } = await apiPost<{ thread: Thread }>('/api/threads', { subject, text, unitId })
setDaten((d) => ({ ...d, threads: [thread, ...d.threads.filter((t) => t.id !== thread.id)] }))
return thread.id
} catch (e) {
console.error(fehlerNachricht(e))
return null
}
},
async addMessage(threadId, text) {
const { thread } = await apiPost<{ thread: Thread }>(`/api/threads/${threadId}/messages`, { text })
setDaten((d) => ({ ...d, threads: d.threads.map((t) => (t.id === threadId ? thread : t)) }))
},
markThreadRead(threadId) {
// Optimistisch lokal markieren, serverseitig nachmelden
setDaten((d) => ({
...d,
threads: d.threads.map((t) =>
t.id === threadId
? role === 'tenant'
? { ...t, unreadByTenant: false }
: { ...t, unreadByAdmin: false }
: t,
),
}))
dispatch({ type: 'codesHinzugefuegt', codes: neu })
return neu
}
return {
...state,
dispatch,
units,
announcements,
me,
role,
meineEinheit,
login,
logout,
registrieren,
benutzerAnlegen,
setAktiv: (id, aktiv) => dispatch({ type: 'benutzerAktiv', id, aktiv }),
setPasswort,
weiseEinheitZu: (id, einheitId) => dispatch({ type: 'einheitZugewiesen', id, einheitId }),
generiereCodes,
}
}, [state])
apiPost(`/api/threads/${threadId}/read`).catch(() => {})
},
// ── Benutzerverwaltung (Verwaltung) ─────────────────────
async benutzerAnlegen(benutzername, name, rolle, email, passwort, einheitId) {
try {
await apiPost('/api/users', {
benutzername,
name,
rolle,
email,
einheitId,
passwortHash: await sha256(passwort),
})
await ladeAlles()
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setAktiv(id, aktiv) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { aktiv })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async setPasswort(id, passwort) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, {
passwortHash: await sha256(passwort),
})
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async weiseEinheitZu(id, einheitId) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async generiereCodes(anzahl, einheitId) {
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
await ladeAlles()
return codes
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
}
@@ -395,4 +275,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
currency: 'EUR',
})
export { DEMO_TENANT, DEMO_UNIT_ID, DEMO_PASSWORT }
export { DEMO_PASSWORT }