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
+272 -21
View File
@@ -6,7 +6,17 @@ import {
useReducer,
type ReactNode,
} from 'react'
import type { Role, Ticket, Transaction, DocumentItem, Thread, Message } from '../types'
import type {
Role,
Ticket,
Transaction,
DocumentItem,
Thread,
Message,
Benutzer,
BenutzerRolle,
RegistrierungsCode,
} from '../types'
import {
units,
transactions,
@@ -17,41 +27,109 @@ import {
DEMO_TENANT,
DEMO_UNIT_ID,
} from '../data/seed'
import {
sha256,
DEMO_PASSWORT,
ladeSession,
speichereSession,
beendeSession,
type Session,
} from '../lib/auth'
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
/** 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 {
role: Role
tickets: Ticket[]
transactions: Transaction[]
documents: DocumentItem[]
threads: Thread[]
benutzer: Benutzer[]
codes: RegistrierungsCode[]
session: Session | null
tick: number
}
type Action =
| { type: 'setRole'; role: Role }
| { type: 'addTicket'; ticket: Ticket }
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
| {
type: 'addMessage'
threadId: string
message: Message
}
| { 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 = {
role: 'tenant',
tickets,
transactions,
documents,
threads,
benutzer: demoBenutzer,
codes: demoCodes,
session: ladeSession(),
tick: 0,
}
const STORAGE_KEY = 'hocks-mieterportal-v1'
const STORAGE_KEY = 'hinterstein-mieterportal-v2'
function loadState(): State {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return { ...initialState, ...JSON.parse(raw) }
if (raw) {
const parsed = JSON.parse(raw)
return { ...initialState, ...parsed, session: ladeSession() }
}
} catch {
/* ignore */
}
@@ -60,8 +138,6 @@ function loadState(): State {
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'setRole':
return { ...state, role: action.role }
case 'addTicket':
return { ...state, tickets: [action.ticket, ...state.tickets] }
case 'setTicketStatus':
@@ -98,16 +174,86 @@ function reducer(state: State, action: Action): State {
: 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>
demoUnit: typeof units[number]
units: typeof units
announcements: typeof announcements
/** Aktuell angemeldeter Benutzer (null = nicht eingeloggt). */
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]
login: (benutzername: string, passwort: string) => Promise<string | null>
logout: () => void
registrieren: (
code: string,
benutzername: string,
name: string,
email: string,
passwort: string,
) => Promise<string | null>
benutzerAnlegen: (
benutzername: string,
name: string,
rolle: BenutzerRolle,
email: string,
passwort: string,
einheitId: string | null,
) => Promise<string | null>
setAktiv: (id: string, aktiv: boolean) => void
setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => void
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[]
}
const StoreContext = createContext<Store | null>(null)
@@ -119,16 +265,121 @@ export function StoreProvider({ children }: { children: ReactNode }) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
const value = useMemo<Store>(
() => ({
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'),
}
dispatch({ type: 'registriert', benutzer: b, code: c.code })
speichereSession(b.id, b.rolle)
dispatch({ type: 'setSession', session: ladeSession() })
return null
}
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'),
}
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'),
}))
dispatch({ type: 'codesHinzugefuegt', codes: neu })
return neu
}
return {
...state,
dispatch,
demoUnit: units.find((u) => u.id === DEMO_UNIT_ID)!,
units,
announcements,
}),
[state],
)
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])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
}
@@ -144,4 +395,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
currency: 'EUR',
})
export { DEMO_TENANT, DEMO_UNIT_ID }
export { DEMO_TENANT, DEMO_UNIT_ID, DEMO_PASSWORT }