Files
Mieterportal/src/store/store.tsx
T
ah-admin 07d58ecb58 v1.6: Login-Race behoben (schwarzer Bildschirm nach Anmeldung)
- Store setzt 'me' erst nach dem Laden der Bootstrap-Daten (Start, Login,
  Registrierung) - vorher rendereten Seiten gegen leere Daten und brachen ab;
  Seite lief erst nach manuellem Reload (Race zwischen setMe und ladeAlles)
- FehlerGrenze (ErrorBoundary) in App.tsx: Render-Fehler zeigen Hinweis +
  Reload-Button statt schwarzem Bildschirm
2026-10-02 13:56:03 +02:00

279 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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,
useState,
type ReactNode,
} from 'react'
import type {
Role,
Ticket,
TicketStatus,
Transaction,
DocumentItem,
Thread,
Benutzer,
BenutzerRolle,
RegistrierungsCode,
Unit,
Announcement,
} from '../types'
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth'
/** 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'
interface Bootstrap {
units: Unit[]
transactions: Transaction[]
documents: DocumentItem[]
tickets: Ticket[]
threads: Thread[]
announcements: Announcement[]
benutzer: Benutzer[]
codes: RegistrierungsCode[]
}
const LEER: Bootstrap = {
units: [],
transactions: [],
documents: [],
tickets: [],
threads: [],
announcements: [],
benutzer: [],
codes: [],
}
interface Store extends Bootstrap {
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
bereit: boolean
me: Benutzer | null
role: Role
meineEinheit: Unit
login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => Promise<void>
registrieren: (
code: string,
benutzername: string,
name: string,
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,
rolle: BenutzerRolle,
email: string,
passwort: string,
einheitId: string | null,
) => Promise<string | null>
setAktiv: (id: string, aktiv: boolean) => Promise<void>
setPasswort: (id: string, passwort: string) => Promise<void>
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 [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(() => {
let aktiv = true
;(async () => {
try {
const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me')
await ladeAlles() // erst Daten, dann me – sonst rendern Seiten gegen leere Daten
if (!aktiv) return
setMe(benutzer)
} catch {
/* nicht angemeldet – Login-Seite zeigen */
} finally {
if (aktiv) setBereit(true)
}
})()
return () => {
aktiv = false
}
}, [ladeAlles])
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),
})
await ladeAlles() // erst Daten, dann me (s. useEffect)
setMe(benutzer)
return null
} catch (e) {
return fehlerNachricht(e)
}
},
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),
})
await ladeAlles() // erst Daten, dann me (s. useEffect)
setMe(benutzer)
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,
),
}))
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>
}
export function useStore(): Store {
const ctx = useContext(StoreContext)
if (!ctx) throw new Error('useStore außerhalb von StoreProvider')
return ctx
}
export const fmtEUR = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
})
export { DEMO_PASSWORT }