User management with login, roles and a Config area #25
36 changed files with 4928 additions and 38 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -36,6 +36,13 @@ It must support circuits, device rows, project devices, drag-and-drop restructur
|
|||
- General Circuit, CircuitDeviceRow, CircuitList and DistributionBoard
|
||||
repositories expose only active reads. Runtime writes belong in typed command
|
||||
repositories; direct integration fixtures belong under `tests/support`.
|
||||
- Authentication: session-cookie login with two fixed roles (`admin`, `user`),
|
||||
no self-signup. `src/server/auth/*` (password hashing, sessions, Express
|
||||
middleware), `src/domain/services/user.service.ts`,
|
||||
`src/db/repositories/user.repository.ts`. Every `/api/*` route requires a
|
||||
signed-in user (`src/server/index.ts`); `/api/users/*` additionally
|
||||
requires `admin`. Frontend gate: `AuthProvider` + `LoginGate`
|
||||
(`src/app/layout.tsx`). See `docs/authentication.md` for the full picture.
|
||||
|
||||
The supported runtime model is Circuit-First. The former Consumer UI, API,
|
||||
tables and upgrade tooling are removed; do not reintroduce them.
|
||||
|
|
@ -704,6 +711,10 @@ for the early-phase power balance by DIN 276 cost group (see
|
|||
- logical feed link between distribution boards (`fedFrom` stays free text
|
||||
until the project owner decides whether it references a board, a feeding
|
||||
circuit or both) and the resulting hierarchical power summation
|
||||
- configurable role/rights matrix instead of the fixed `admin`/`user` roles
|
||||
- account lockout after failed login attempts
|
||||
- email/one-time-link password provisioning, OIDC/Entra login
|
||||
- linking user accounts to a personnel/employee master table
|
||||
|
||||
Do not implement these while working on an unrelated phase.
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ services:
|
|||
PORT: "3000"
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf
|
||||
# nicht aus dem LAN erreichbar sein.
|
||||
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
||||
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
||||
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
|
||||
# Nur auf dem Loopback: die API darf nicht aus dem LAN erreichbar sein.
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
||||
|
|
|
|||
11
compose.yaml
11
compose.yaml
|
|
@ -45,8 +45,15 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung
|
||||
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht.
|
||||
# AUTH_BOOTSTRAP_* legen nur den allerersten Admin an (solange die
|
||||
# users-Tabelle leer ist) und gehoeren in eine lokale, nicht versionierte
|
||||
# .env - siehe docs/AUTH.md.
|
||||
AUTH_BOOTSTRAP_USERNAME: "${AUTH_BOOTSTRAP_USERNAME:-}"
|
||||
AUTH_BOOTSTRAP_PASSWORT: "${AUTH_BOOTSTRAP_PASSWORT:-}"
|
||||
AUTH_SESSION_HOURS: "${AUTH_SESSION_HOURS:-12}"
|
||||
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
||||
# Nicht auf dem Host veroeffentlicht: die API wird ueber den
|
||||
# Next.js-Rewrite im Web-Dienst erreicht.
|
||||
expose:
|
||||
- "3000"
|
||||
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
|||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||
- [Leitungsberechnung](cable-sizing-module.md) – optionales Rechenmodul,
|
||||
Rechenweg, Datenbasis und Prüfnachweis der Tabellen
|
||||
- [Anmeldung und Benutzerverwaltung](authentication.md) – Rollen, Ablauf beim
|
||||
Anlegen eines Zugangs, Sessions, Bootstrap-Admin
|
||||
|
||||
## Entwicklung und Betrieb
|
||||
|
||||
|
|
|
|||
61
docs/authentication.md
Normal file
61
docs/authentication.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Anmeldung und Benutzerverwaltung
|
||||
|
||||
Die Leistungsbilanz verlangt eine Anmeldung. Es gibt kein Self-Signup: jeder
|
||||
Zugang wird von einem Administrator über **Config → Benutzer** angelegt.
|
||||
|
||||
## Rollen
|
||||
|
||||
Es gibt genau zwei Rollen (`src/shared/constants/user-roles.ts`):
|
||||
|
||||
- **admin** – wie `user`, zusätzlich Zugriff auf den Config-Bereich
|
||||
(Benutzerverwaltung, eigenes Konto)
|
||||
- **user** – volle Nutzung der Leistungsbilanz (Projekte, Stromkreislisten,
|
||||
Grobbilanz, …), kein Zugriff auf Config
|
||||
|
||||
Es gibt (bewusst, siehe unten) keine konfigurierbare Rechte-Matrix und keine
|
||||
Verknüpfung mit einer Mitarbeiter-/Personen-Stammdatentabelle.
|
||||
|
||||
## Ablauf beim Anlegen eines Zugangs
|
||||
|
||||
1. Ein Administrator öffnet Config → Benutzer und legt einen neuen Zugang mit
|
||||
Anmeldename, Name, Rolle und einem **Startpasswort** an, das er selbst
|
||||
eintippt.
|
||||
2. Der Zugang startet mit `mustChangePassword = true`.
|
||||
3. Beim ersten Anmelden zeigt die Oberfläche statt der App ein Formular, das
|
||||
ein eigenes Passwort verlangt, bevor es weitergeht.
|
||||
4. "Passwort zurücksetzen" (ebenfalls in Config → Benutzer) setzt ein neues
|
||||
Startpasswort und `mustChangePassword` wieder auf `true` – z. B. wenn
|
||||
jemand sein Passwort vergessen hat.
|
||||
|
||||
## Technischer Aufbau
|
||||
|
||||
- **Datenmodell:** Tabelle `users` (`src/db/schema/users.ts`) –
|
||||
`username`, `passwordHash` (scrypt, `src/server/auth/password.ts`), `name`,
|
||||
`role`, `active`, `mustChangePassword`.
|
||||
- **Sessions:** In-Memory-Store (`src/server/auth/sessions.ts`), Cookie
|
||||
`lb_session`, sliding expiry (`AUTH_SESSION_HOURS`, Standard 12h). Ein
|
||||
Neustart der API beendet alle Sessions – für einen Single-Container-Dienst
|
||||
ein akzeptabler Kompromiss gegenüber einer persistierten Session-Tabelle.
|
||||
- **Middleware:** `src/server/auth/auth.middleware.ts` schützt `/api/*`
|
||||
(außer `/api/auth/login`, `/api/auth/status`, `/api/auth/logout`) und
|
||||
erzwingt den Passwortwechsel, solange `mustChangePassword` gesetzt ist.
|
||||
`/api/users/*` verlangt zusätzlich die Rolle `admin`.
|
||||
- **Frontend:** `AuthProvider` lädt `/api/auth/me`; `LoginGate`
|
||||
(`src/app/layout.tsx`) zeigt Login- bzw. Passwortformular, solange nicht
|
||||
angemeldet bzw. `mustChangePassword` gesetzt ist, sonst die App.
|
||||
|
||||
## Bootstrap-Admin
|
||||
|
||||
`AUTH_BOOTSTRAP_USERNAME` / `AUTH_BOOTSTRAP_PASSWORT` (Env-Variablen, siehe
|
||||
`compose.yaml`) legen beim ersten Start einen Admin an – **nur falls die
|
||||
`users`-Tabelle noch komplett leer ist**. Danach werden diese Variablen
|
||||
ignoriert; jeder weitere Zugang entsteht über Config → Benutzer. Die Werte
|
||||
gehören in eine lokale, nicht versionierte `.env` auf dem Deploy-Host, nicht
|
||||
in `compose.yaml` selbst.
|
||||
|
||||
## Bewusst nicht umgesetzt (Stand jetzt)
|
||||
|
||||
- Konfigurierbare Rechte-Matrix statt fixer `admin`/`user`-Rollen
|
||||
- Konten-Sperre nach Fehlversuchen
|
||||
- E-Mail-/Einmal-Link-Passwortvergabe, OIDC/Entra-Anmeldung
|
||||
- Verknüpfung von Zugängen mit einer Mitarbeiter-Stammdatentabelle
|
||||
325
src/app/config/benutzer/page.tsx
Normal file
325
src/app/config/benutzer/page.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { userApi, type UserDto, type UserRole } from "../../../frontend/utils/auth-api";
|
||||
import { useAuth } from "../../../frontend/components/AuthProvider";
|
||||
import { USER_ROLES, USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
||||
|
||||
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
export default function BenutzerPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||||
const [resetPasswordFor, setResetPasswordFor] = useState<UserDto | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rows = await userApi.list();
|
||||
setUsers(rows);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Benutzer konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function withBusy(userId: string, action: () => Promise<void>) {
|
||||
setBusyUserId(userId);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Aktion fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusyUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRole(item: UserDto, role: UserRole) {
|
||||
await withBusy(item.id, async () => {
|
||||
const updated = await userApi.update(item.id, { role });
|
||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleActive(item: UserDto) {
|
||||
await withBusy(item.id, async () => {
|
||||
const updated = await userApi.update(item.id, { active: !item.active });
|
||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: "1.5rem" }}>
|
||||
<div className="page-header" style={{ marginTop: 0 }}>
|
||||
<h2 className="h5" style={{ margin: 0 }}>
|
||||
Benutzer
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-body-secondary" style={{ marginBottom: "1rem" }}>
|
||||
Es gibt kein Self-Signup: Jeder Zugang wird hier bewusst von einem
|
||||
Administrator angelegt. Das Startpasswort wird direkt eingetragen; die
|
||||
Person muss es beim ersten Anmelden durch ein eigenes ersetzen.
|
||||
</p>
|
||||
|
||||
<NeuerBenutzerFormular
|
||||
onCreated={(created) => {
|
||||
setUsers((list) => [...list, created]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{loading && <p>Lädt…</p>}
|
||||
|
||||
{!loading && users.length > 0 && (
|
||||
<table className="table" style={{ marginTop: "1.5rem" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Anmeldename</th>
|
||||
<th>Rolle</th>
|
||||
<th>Aktiv</th>
|
||||
<th>Status</th>
|
||||
<th>Angelegt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((item) => {
|
||||
const isSelf = item.id === currentUser?.id;
|
||||
const busy = busyUserId === item.id;
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.username}</td>
|
||||
<td>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={item.role}
|
||||
disabled={busy || isSelf}
|
||||
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
||||
onChange={(event) => void changeRole(item, event.target.value as UserRole)}
|
||||
>
|
||||
{USER_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{USER_ROLE_LABEL[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.active}
|
||||
disabled={busy || isSelf}
|
||||
title={isSelf ? "Der eigene Zugang kann hier nicht geändert werden." : undefined}
|
||||
onChange={() => void toggleActive(item)}
|
||||
/>
|
||||
</td>
|
||||
<td>{item.mustChangePassword ? "Muss Passwort setzen" : "In Ordnung"}</td>
|
||||
<td>{zeitpunkt.format(new Date(item.createdAt))}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => setResetPasswordFor(item)}
|
||||
>
|
||||
Passwort zurücksetzen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!loading && users.length === 0 && <p>Noch keine Benutzer angelegt.</p>}
|
||||
|
||||
{resetPasswordFor && (
|
||||
<PasswortZuruecksetzenDialog
|
||||
item={resetPasswordFor}
|
||||
onClose={() => setResetPasswordFor(null)}
|
||||
onDone={(updated) => {
|
||||
setUsers((list) => list.map((row) => (row.id === updated.id ? updated : row)));
|
||||
setResetPasswordFor(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeuerBenutzerFormular({ onCreated }: { onCreated: (created: UserDto) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<UserRole>("user");
|
||||
const [initialPassword, setInitialPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await userApi.create({ username, name, role, initialPassword });
|
||||
onCreated(created);
|
||||
setUsername("");
|
||||
setName("");
|
||||
setRole("user");
|
||||
setInitialPassword("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Benutzer konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="row g-2 align-items-end"
|
||||
style={{ borderBottom: "1px solid rgba(0,0,0,0.1)", paddingBottom: "1rem" }}
|
||||
>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label" htmlFor="new-user-username">
|
||||
Anmeldename
|
||||
</label>
|
||||
<input
|
||||
id="new-user-username"
|
||||
className="form-control form-control-sm"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label" htmlFor="new-user-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="new-user-name"
|
||||
className="form-control form-control-sm"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-2">
|
||||
<label className="form-label" htmlFor="new-user-role">
|
||||
Rolle
|
||||
</label>
|
||||
<select
|
||||
id="new-user-role"
|
||||
className="form-select form-select-sm"
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as UserRole)}
|
||||
>
|
||||
{USER_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{USER_ROLE_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<label className="form-label" htmlFor="new-user-password">
|
||||
Startpasswort
|
||||
</label>
|
||||
<input
|
||||
id="new-user-password"
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={initialPassword}
|
||||
onChange={(event) => setInitialPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-1">
|
||||
<button type="submit" className="btn btn-primary btn-sm w-100" disabled={submitting}>
|
||||
{submitting ? "…" : "Anlegen"}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="col-12">
|
||||
<p className="text-danger mb-0">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswortZuruecksetzenDialog({
|
||||
item,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
item: UserDto;
|
||||
onClose: () => void;
|
||||
onDone: (updated: UserDto) => void;
|
||||
}) {
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await userApi.resetPassword(item.id, newPassword);
|
||||
onDone(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht zurückgesetzt werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop-inline">
|
||||
<form className="card" style={{ maxWidth: 360, padding: "1.5rem" }} onSubmit={handleSubmit}>
|
||||
<h3 className="h6">Passwort zurücksetzen für {item.name}</h3>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="reset-password">
|
||||
Neues Startpasswort
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
minLength={8}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<div className="d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Speichert…" : "Zurücksetzen"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/app/config/konto/page.tsx
Normal file
103
src/app/config/konto/page.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authApi } from "../../../frontend/utils/auth-api";
|
||||
import { useAuth } from "../../../frontend/components/AuthProvider";
|
||||
import { USER_ROLE_LABEL } from "../../../shared/constants/user-roles";
|
||||
|
||||
export default function KontoPage() {
|
||||
const { user } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (newPassword !== newPasswordRepeat) {
|
||||
setError("Die beiden neuen Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
await authApi.changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setNewPasswordRepeat("");
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 480, padding: "1.5rem" }}>
|
||||
<h2 className="h5">Konto</h2>
|
||||
{user && (
|
||||
<p className="text-body-secondary">
|
||||
Angemeldet als <strong>{user.name}</strong> ({user.username}) ·{" "}
|
||||
{USER_ROLE_LABEL[user.role]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h3 className="h6 mt-4">Passwort ändern</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="konto-current-password">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input
|
||||
id="konto-current-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="konto-new-password">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="konto-new-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="konto-new-password-repeat">
|
||||
Neues Passwort wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="konto-new-password-repeat"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPasswordRepeat}
|
||||
onChange={(event) => setNewPasswordRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
{success && <p className="text-success">Passwort wurde geändert.</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Speichert…" : "Passwort ändern"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/app/config/layout.tsx
Normal file
51
src/app/config/layout.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useAuth } from "../../frontend/components/AuthProvider";
|
||||
|
||||
const CONFIG_TABS = [
|
||||
{ href: "/config/benutzer", label: "Benutzer" },
|
||||
{ href: "/config/konto", label: "Konto" },
|
||||
] as const;
|
||||
|
||||
export default function ConfigLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { status, isAdmin } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// LoginGate already guarantees a signed-in user with a set password by
|
||||
// the time this layout renders; only the role still needs checking.
|
||||
if (status === "signed-in" && !isAdmin) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [status, isAdmin, router]);
|
||||
|
||||
if (status !== "signed-in" || !isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<div className="page-header">
|
||||
<h1>Config</h1>
|
||||
</div>
|
||||
|
||||
<nav className="config-tabs">
|
||||
{CONFIG_TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={pathname?.startsWith(tab.href) ? "config-tab active" : "config-tab"}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/config/page.tsx
Normal file
5
src/app/config/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ConfigPage() {
|
||||
redirect("/config/benutzer");
|
||||
}
|
||||
|
|
@ -2716,3 +2716,116 @@ button.structure-entry-main:focus-visible {
|
|||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Anmeldung (Sidebar-Config-Link, Identität, Login-Gate) ─────────── */
|
||||
|
||||
.sidebar-nav-bottom {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 8px 14px 10px;
|
||||
}
|
||||
|
||||
.sidebar-identity-label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(199, 214, 224, 0.7);
|
||||
}
|
||||
|
||||
.sidebar-identity-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
color: #c7d6e0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar-identity {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-gate-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-md);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--color-primary-deep) 0%,
|
||||
var(--color-primary-dark) 45%,
|
||||
var(--color-primary) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-gate-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 28px 26px;
|
||||
}
|
||||
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.config-tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.config-tab:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.config-tab.active {
|
||||
opacity: 1;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.modal-backdrop-inline {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import Script from "next/script";
|
|||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "../frontend/components/AppShell";
|
||||
import { AuthProvider } from "../frontend/components/AuthProvider";
|
||||
import { LoginGate } from "../frontend/components/LoginGate";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Leistungsbilanz",
|
||||
|
|
@ -36,7 +38,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||
<Script id="theme-init" strategy="beforeInteractive">
|
||||
{THEME_INIT_SCRIPT}
|
||||
</Script>
|
||||
<AuthProvider>
|
||||
<LoginGate>
|
||||
<AppShell>{children}</AppShell>
|
||||
</LoginGate>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
13
src/db/migrations/0012_lively_turbo.sql
Normal file
13
src/db/migrations/0012_lively_turbo.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`role` text DEFAULT 'user' NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`must_change_password` integer DEFAULT true NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||
3017
src/db/migrations/meta/0012_snapshot.json
Normal file
3017
src/db/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,13 @@
|
|||
"when": 1790531351492,
|
||||
"tag": "0011_glossy_norman_osborn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1790617581434,
|
||||
"tag": "0012_lively_turbo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
87
src/db/repositories/user.repository.ts
Normal file
87
src/db/repositories/user.repository.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import crypto from "node:crypto";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { users } from "../schema/users.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
|
||||
export interface CreateUserRow {
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async list() {
|
||||
const db = this.database;
|
||||
return db.select().from(users).orderBy(asc(users.name));
|
||||
}
|
||||
|
||||
async count() {
|
||||
const db = this.database;
|
||||
const rows = await db.select({ id: users.id }).from(users).limit(1);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async findById(userId: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findByUsername(username: string) {
|
||||
const db = this.database;
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username.trim().toLowerCase()))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async create(input: CreateUserRow) {
|
||||
const db = this.database;
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
username: input.username.trim().toLowerCase(),
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash: input.passwordHash,
|
||||
active: true,
|
||||
mustChangePassword: true,
|
||||
});
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async updateRoleAndActive(
|
||||
userId: string,
|
||||
patch: { role?: UserRole; active?: boolean }
|
||||
) {
|
||||
const db = this.database;
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||
...(patch.active !== undefined ? { active: patch.active } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
return this.findById(userId);
|
||||
}
|
||||
|
||||
async setPassword(
|
||||
userId: string,
|
||||
passwordHash: string,
|
||||
mustChangePassword: boolean
|
||||
) {
|
||||
const db = this.database;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, mustChangePassword, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
return this.findById(userId);
|
||||
}
|
||||
}
|
||||
27
src/db/schema/users.ts
Normal file
27
src/db/schema/users.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
name: text("name").notNull(),
|
||||
role: text("role").notNull().default("user"),
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
mustChangePassword: integer("must_change_password", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => [uniqueIndex("users_username_unique").on(table.username)]
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
104
src/domain/services/user.service.ts
Normal file
104
src/domain/services/user.service.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { UserRepository } from "../../db/repositories/user.repository.js";
|
||||
import { hashPassword, verifyPassword } from "../../server/auth/password.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
import type { AuthConfig } from "../../server/auth/auth-config.js";
|
||||
import { createLogger } from "../../shared/logging/logger.js";
|
||||
|
||||
const logger = createLogger("user-service");
|
||||
|
||||
export class InvalidCredentialsError extends Error {
|
||||
constructor() {
|
||||
super("Anmeldung fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
export class UsernameTakenError extends Error {
|
||||
constructor() {
|
||||
super("Dieser Anmeldename ist bereits vergeben.");
|
||||
}
|
||||
}
|
||||
|
||||
export class WrongPasswordError extends Error {
|
||||
constructor() {
|
||||
super("Das aktuelle Passwort ist falsch.");
|
||||
}
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly repository: UserRepository,
|
||||
private readonly authConfig: AuthConfig
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates the bootstrap admin from env vars, but only while the users
|
||||
* table is still completely empty. After the first account exists, every
|
||||
* further account is created through Config -> Benutzer, and the bootstrap
|
||||
* env vars are ignored - that's the whole point of them being a one-time
|
||||
* escape hatch instead of a standing backdoor.
|
||||
*/
|
||||
async ensureBootstrapAdmin(): Promise<void> {
|
||||
if (!this.authConfig.bootstrap) return;
|
||||
const existingCount = await this.repository.count();
|
||||
if (existingCount > 0) return;
|
||||
const passwordHash = await hashPassword(this.authConfig.bootstrap.password);
|
||||
await this.repository.create({
|
||||
username: this.authConfig.bootstrap.username,
|
||||
name: "Administrator",
|
||||
role: "admin",
|
||||
passwordHash,
|
||||
});
|
||||
logger.info("bootstrap admin created", {
|
||||
username: this.authConfig.bootstrap.username,
|
||||
});
|
||||
}
|
||||
|
||||
async authenticate(username: string, password: string) {
|
||||
const user = await this.repository.findByUsername(username);
|
||||
// Always hash something, even for an unknown user: otherwise the
|
||||
// response time would leak which usernames exist.
|
||||
const passwordHash = user?.passwordHash ?? (await hashPassword("placeholder"));
|
||||
const passwordMatches = await verifyPassword(password, passwordHash);
|
||||
if (!user || !user.active || !passwordMatches) {
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async createUser(input: {
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
initialPassword: string;
|
||||
}) {
|
||||
const existing = await this.repository.findByUsername(input.username);
|
||||
if (existing) throw new UsernameTakenError();
|
||||
const passwordHash = await hashPassword(input.initialPassword);
|
||||
return this.repository.create({
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
passwordHash,
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoleAndActive(userId: string, patch: { role?: UserRole; active?: boolean }) {
|
||||
return this.repository.updateRoleAndActive(userId, patch);
|
||||
}
|
||||
|
||||
/** Admin-triggered reset with a new start password; forces a change on next login. */
|
||||
async resetPassword(userId: string, newPassword: string) {
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
return this.repository.setPassword(userId, passwordHash, true);
|
||||
}
|
||||
|
||||
/** Self-service change by the signed-in user; clears mustChangePassword. */
|
||||
async changeOwnPassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.repository.findById(userId);
|
||||
if (!user) throw new WrongPasswordError();
|
||||
const currentMatches = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!currentMatches) throw new WrongPasswordError();
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
return this.repository.setPassword(userId, passwordHash, false);
|
||||
}
|
||||
}
|
||||
61
src/frontend/components/AuthProvider.tsx
Normal file
61
src/frontend/components/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { authApi, type AuthUserDto } from "../utils/auth-api";
|
||||
|
||||
type AuthStatus = "loading" | "signed-in" | "signed-out";
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: AuthUserDto | undefined;
|
||||
isAdmin: boolean;
|
||||
reload: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
const [user, setUser] = useState<AuthUserDto | undefined>(undefined);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const current = await authApi.me();
|
||||
setUser(current);
|
||||
setStatus("signed-in");
|
||||
} catch {
|
||||
setUser(undefined);
|
||||
setStatus("signed-out");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} finally {
|
||||
setUser(undefined);
|
||||
setStatus("signed-out");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ status, user, isAdmin: user?.role === "admin", reload, logout }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
179
src/frontend/components/LoginGate.tsx
Normal file
179
src/frontend/components/LoginGate.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authApi } from "../utils/auth-api";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
export function LoginGate({ children }: { children: React.ReactNode }) {
|
||||
const { status, user, reload } = useAuth();
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="login-gate-screen">
|
||||
<p>Lädt…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "signed-out") {
|
||||
return (
|
||||
<div className="login-gate-screen">
|
||||
<LoginForm onSignedIn={reload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.mustChangePassword) {
|
||||
return (
|
||||
<div className="login-gate-screen">
|
||||
<ForcedPasswordChangeForm onChanged={reload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function LoginForm({ onSignedIn }: { onSignedIn: () => Promise<void> }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await authApi.login(username, password);
|
||||
await onSignedIn();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Anmeldung fehlgeschlagen.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="login-gate-card card" onSubmit={handleSubmit}>
|
||||
<h1 className="h4 mb-3">Leistungsbilanz</h1>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="login-username">
|
||||
Anmeldename
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="form-control"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="login-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Meldet an…" : "Anmelden"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ForcedPasswordChangeForm({ onChanged }: { onChanged: () => Promise<void> }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (newPassword !== newPasswordRepeat) {
|
||||
setError("Die beiden neuen Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await authApi.changePassword(currentPassword, newPassword);
|
||||
await onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort konnte nicht geändert werden.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="login-gate-card card" onSubmit={handleSubmit}>
|
||||
<h1 className="h4 mb-3">Neues Passwort setzen</h1>
|
||||
<p className="text-body-secondary">
|
||||
Dieser Zugang wurde mit einem Startpasswort angelegt. Bitte vor der
|
||||
weiteren Nutzung ein eigenes Passwort vergeben.
|
||||
</p>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="force-current-password">
|
||||
Aktuelles (Start-)Passwort
|
||||
</label>
|
||||
<input
|
||||
id="force-current-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="force-new-password">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="force-new-password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="force-new-password-repeat">
|
||||
Neues Passwort wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="force-new-password-repeat"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={newPasswordRepeat}
|
||||
onChange={(event) => setNewPasswordRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-danger">{error}</p>}
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Speichert…" : "Passwort setzen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
|
|||
import { listProjects } from "../utils/api";
|
||||
import type { ProjectDto } from "../types";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { useAuth } from "./AuthProvider";
|
||||
|
||||
// Anchors on the project overview page (/projects/[id]).
|
||||
const PROJECT_SECTIONS = [
|
||||
|
|
@ -30,6 +31,7 @@ interface SidebarProps {
|
|||
|
||||
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||
const projectSubPath = projectMatch?.[2] ?? "";
|
||||
|
|
@ -179,8 +181,31 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
</>
|
||||
)}
|
||||
</nav>
|
||||
{isAdmin && (
|
||||
<nav className="sidebar-nav sidebar-nav-bottom">
|
||||
<Link
|
||||
href="/config"
|
||||
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
|
||||
title="Config"
|
||||
>
|
||||
<span className="sidebar-icon">⚙</span>
|
||||
<span className="sidebar-label">Config</span>
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
<div className="sidebar-footer">
|
||||
{user && !isCollapsed && (
|
||||
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>
|
||||
<span className="sidebar-identity-label">Angemeldet als</span>
|
||||
<span className="sidebar-identity-name">{user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
{user && (
|
||||
<button type="button" className="sidebar-logout" onClick={() => void logout()}>
|
||||
{isCollapsed ? "⏻" : "Abmelden"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
86
src/frontend/utils/auth-api.ts
Normal file
86
src/frontend/utils/auth-api.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Small, self-contained fetch helper for the auth/user endpoints - kept
|
||||
// separate from the large domain api.ts so this feature doesn't have to
|
||||
// touch that file's shared `request()` internals.
|
||||
|
||||
export type UserRole = "admin" | "user";
|
||||
|
||||
export interface AuthStatusDto {
|
||||
signedIn: boolean;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export interface AuthUserDto {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export interface UserDto {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
mustChangePassword: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class AuthApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `Anfrage fehlgeschlagen (Status ${response.status})`;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: unknown };
|
||||
if (typeof body.error === "string") message = body.error;
|
||||
} catch {
|
||||
// Keep the generic message for non-JSON error responses.
|
||||
}
|
||||
throw new AuthApiError(message, response.status);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
status: () => request<AuthStatusDto>("/api/auth/status"),
|
||||
login: (username: string, password: string) =>
|
||||
request<AuthStatusDto>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
me: () => request<AuthUserDto>("/api/auth/me"),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ changed: true }>("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
logout: () => request<{ signedOut: true }>("/api/auth/logout", { method: "POST" }),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
list: () => request<UserDto[]>("/api/users"),
|
||||
create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) =>
|
||||
request<UserDto>("/api/users", { method: "POST", body: JSON.stringify(input) }),
|
||||
update: (userId: string, patch: { role?: UserRole; active?: boolean }) =>
|
||||
request<UserDto>(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
resetPassword: (userId: string, newPassword: string) =>
|
||||
request<UserDto>(`/api/users/${userId}/reset-password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newPassword }),
|
||||
}),
|
||||
};
|
||||
44
src/server/auth/auth-config.ts
Normal file
44
src/server/auth/auth-config.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export const SESSION_COOKIE = "lb_session";
|
||||
|
||||
const DEFAULT_SESSION_HOURS = 12;
|
||||
|
||||
export interface AuthConfig {
|
||||
sessionTtlMs: number;
|
||||
cookieSecure: boolean;
|
||||
/**
|
||||
* Only used to create the very first admin when the users table is still
|
||||
* empty - after that, every account is created through Config -> Benutzer.
|
||||
* Unset means: no bootstrap account, the table must already have an admin.
|
||||
*/
|
||||
bootstrap: { username: string; password: string } | null;
|
||||
}
|
||||
|
||||
export class AuthConfigError extends Error {}
|
||||
|
||||
export function readAuthConfig(env: NodeJS.ProcessEnv = process.env): AuthConfig {
|
||||
const hours = Number(env.AUTH_SESSION_HOURS ?? DEFAULT_SESSION_HOURS);
|
||||
if (!Number.isFinite(hours) || hours <= 0) {
|
||||
throw new AuthConfigError("AUTH_SESSION_HOURS must be a positive number.");
|
||||
}
|
||||
|
||||
const bootstrapUsername = (env.AUTH_BOOTSTRAP_USERNAME ?? "").trim().toLowerCase();
|
||||
const bootstrapPassword = env.AUTH_BOOTSTRAP_PASSWORT ?? "";
|
||||
if (Boolean(bootstrapUsername) !== Boolean(bootstrapPassword)) {
|
||||
throw new AuthConfigError(
|
||||
"AUTH_BOOTSTRAP_USERNAME and AUTH_BOOTSTRAP_PASSWORT belong together - set both or neither."
|
||||
);
|
||||
}
|
||||
if (bootstrapPassword && bootstrapPassword.length < 12) {
|
||||
throw new AuthConfigError(
|
||||
"AUTH_BOOTSTRAP_PASSWORT must be at least 12 characters long."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionTtlMs: hours * 60 * 60 * 1000,
|
||||
cookieSecure: (env.COOKIE_SECURE ?? "").trim().toLowerCase() === "true",
|
||||
bootstrap: bootstrapUsername
|
||||
? { username: bootstrapUsername, password: bootstrapPassword }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
111
src/server/auth/auth.middleware.ts
Normal file
111
src/server/auth/auth.middleware.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import { SESSION_COOKIE, type AuthConfig } from "./auth-config.js";
|
||||
import { readCookie } from "./cookie.js";
|
||||
import type { SessionStore } from "./sessions.js";
|
||||
import type { UserRepository } from "../../db/repositories/user.repository.js";
|
||||
import type { UserRole } from "../../shared/constants/user-roles.js";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
authUser?: AuthUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionIdFromRequest(req: {
|
||||
headers: { cookie?: string | undefined };
|
||||
}): string | undefined {
|
||||
return readCookie(req.headers.cookie, SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function identityFromRequest(
|
||||
req: { headers: { cookie?: string | undefined } },
|
||||
sessions: SessionStore,
|
||||
users: UserRepository,
|
||||
slideExpiry = true
|
||||
): Promise<AuthUser | undefined> {
|
||||
const session = sessions.get(sessionIdFromRequest(req), slideExpiry);
|
||||
if (!session) return undefined;
|
||||
const user = await users.findById(session.userId);
|
||||
if (!user || !user.active) return undefined;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
export function cookieOptions(authConfig: AuthConfig) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: authConfig.cookieSecure,
|
||||
path: "/",
|
||||
maxAge: authConfig.sessionTtlMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Protects /api. Only the login route and the status check stay reachable without a session. */
|
||||
export function createAuthGuard(
|
||||
authConfig: AuthConfig,
|
||||
sessions: SessionStore,
|
||||
users: UserRepository,
|
||||
openPaths: readonly string[] = ["/auth/login", "/auth/status", "/auth/logout"]
|
||||
): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (openPaths.includes(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
identityFromRequest(req, sessions, users)
|
||||
.then((identity) => {
|
||||
if (!identity) {
|
||||
res.status(401).json({ error: "Nicht angemeldet." });
|
||||
return;
|
||||
}
|
||||
req.authUser = identity;
|
||||
const sessionId = sessionIdFromRequest(req);
|
||||
if (sessionId) {
|
||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
|
||||
}
|
||||
next();
|
||||
})
|
||||
.catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/** Blocks everything except logout and the password change while a start password is still in use. */
|
||||
export function requirePasswordChanged(): RequestHandler {
|
||||
const allowed = new Set(["/auth/me", "/auth/change-password", "/auth/logout"]);
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.authUser?.mustChangePassword || allowed.has(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({
|
||||
error: "Bitte zuerst ein eigenes Passwort setzen.",
|
||||
mustChangePassword: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function requireRole(role: UserRole): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.authUser?.role !== role) {
|
||||
res.status(403).json({ error: "Keine Berechtigung für diese Aktion." });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
15
src/server/auth/cookie.ts
Normal file
15
src/server/auth/cookie.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** Reads one cookie value from a raw "Cookie" request header. No cookie-parser dependency needed for a single cookie. */
|
||||
export function readCookie(
|
||||
header: string | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!header) return undefined;
|
||||
for (const part of header.split(";")) {
|
||||
const separatorIndex = part.indexOf("=");
|
||||
if (separatorIndex === -1) continue;
|
||||
const key = part.slice(0, separatorIndex).trim();
|
||||
if (key !== name) continue;
|
||||
return decodeURIComponent(part.slice(separatorIndex + 1).trim());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
63
src/server/auth/password.ts
Normal file
63
src/server/auth/password.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from "node:crypto";
|
||||
|
||||
const SCRYPT_N = 16384;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const KEY_LENGTH = 64;
|
||||
|
||||
// A hand-rolled wrapper instead of util.promisify(scrypt): promisify only
|
||||
// resolves to scrypt's 3-argument overload, so the 4-argument form used for
|
||||
// verification (with explicit N/r/p) doesn't type-check through it.
|
||||
function scryptAsync(
|
||||
password: string,
|
||||
salt: Buffer,
|
||||
keyLength: number,
|
||||
options?: ScryptOptions
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const callback = (error: Error | null, derivedKey: Buffer) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derivedKey);
|
||||
};
|
||||
if (options) {
|
||||
scrypt(password, salt, keyLength, options, callback);
|
||||
} else {
|
||||
scrypt(password, salt, keyLength, callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Format: scrypt$N$r$p$saltHex$hashHex - the parameters travel with the
|
||||
// hash so they can be tuned later without invalidating existing rows.
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
|
||||
return [
|
||||
"scrypt",
|
||||
SCRYPT_N,
|
||||
SCRYPT_R,
|
||||
SCRYPT_P,
|
||||
salt.toString("hex"),
|
||||
derivedKey.toString("hex"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
storedHash: string
|
||||
): Promise<boolean> {
|
||||
const parts = storedHash.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
const [, nRaw, rRaw, pRaw, saltHex, hashHex] = parts;
|
||||
const n = Number(nRaw);
|
||||
const r = Number(rRaw);
|
||||
const p = Number(pRaw);
|
||||
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
|
||||
return false;
|
||||
}
|
||||
const salt = Buffer.from(saltHex, "hex");
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const derivedKey = await scryptAsync(password, salt, expected.length, { N: n, r, p });
|
||||
if (derivedKey.length !== expected.length) return false;
|
||||
return timingSafeEqual(derivedKey, expected);
|
||||
}
|
||||
55
src/server/auth/sessions.ts
Normal file
55
src/server/auth/sessions.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session store. The API runs as a single container/process, so a
|
||||
* restart clearing every session is an acceptable trade-off for the
|
||||
* simplicity of not needing a persisted session table.
|
||||
*/
|
||||
export class SessionStore {
|
||||
private readonly sessions = new Map<string, Session>();
|
||||
|
||||
constructor(private readonly ttlMs: number) {}
|
||||
|
||||
create(userId: string): Session {
|
||||
const session: Session = {
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
};
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Returns the session and slides its expiry forward, or undefined if gone/expired. */
|
||||
get(sessionId: string | undefined, slideExpiry = true): Session | undefined {
|
||||
if (!sessionId) return undefined;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return undefined;
|
||||
if (session.expiresAt < Date.now()) {
|
||||
this.sessions.delete(sessionId);
|
||||
return undefined;
|
||||
}
|
||||
if (slideExpiry) session.expiresAt = Date.now() + this.ttlMs;
|
||||
return session;
|
||||
}
|
||||
|
||||
destroy(sessionId: string | undefined): void {
|
||||
if (!sessionId) return;
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Ends every other session for a user, e.g. after a password change. */
|
||||
destroyAllForUser(userId: string, exceptSessionId?: string): void {
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (session.userId === userId && id !== exceptSessionId) {
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { RoomRepository } from "../../db/repositories/room.repository.js";
|
|||
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
|
||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||
import { UserRepository } from "../../db/repositories/user.repository.js";
|
||||
|
||||
export const circuitDeviceRowRepository =
|
||||
new CircuitDeviceRowRepository(db);
|
||||
|
|
@ -39,3 +40,4 @@ export const externalCsvConfigurationRepository =
|
|||
new ExternalCsvConfigurationRepository(db);
|
||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||
export const userRepository = new UserRepository(db);
|
||||
|
|
|
|||
8
src/server/composition/auth.ts
Normal file
8
src/server/composition/auth.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { readAuthConfig } from "../auth/auth-config.js";
|
||||
import { SessionStore } from "../auth/sessions.js";
|
||||
import { UserService } from "../../domain/services/user.service.js";
|
||||
import { userRepository } from "./application-repositories.js";
|
||||
|
||||
export const authConfig = readAuthConfig();
|
||||
export const sessionStore = new SessionStore(authConfig.sessionTtlMs);
|
||||
export const userService = new UserService(userRepository, authConfig);
|
||||
88
src/server/controllers/auth.controller.ts
Normal file
88
src/server/controllers/auth.controller.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { Request, Response } from "express";
|
||||
import { changePasswordSchema, loginSchema } from "../../shared/validation/auth.schemas.js";
|
||||
import { InvalidCredentialsError, WrongPasswordError } from "../../domain/services/user.service.js";
|
||||
import { authConfig, sessionStore, userService } from "../composition/auth.js";
|
||||
import {
|
||||
cookieOptions,
|
||||
identityFromRequest,
|
||||
sessionIdFromRequest,
|
||||
} from "../auth/auth.middleware.js";
|
||||
import { SESSION_COOKIE } from "../auth/auth-config.js";
|
||||
import { userRepository } from "../composition/application-repositories.js";
|
||||
|
||||
export async function getAuthStatus(req: Request, res: Response) {
|
||||
const identity = await identityFromRequest(req, sessionStore, userRepository);
|
||||
const sessionId = sessionIdFromRequest(req);
|
||||
if (identity && sessionId) {
|
||||
res.cookie(SESSION_COOKIE, sessionId, cookieOptions(authConfig));
|
||||
}
|
||||
res.json({
|
||||
signedIn: Boolean(identity),
|
||||
mustChangePassword: identity?.mustChangePassword ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(req: Request, res: Response) {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(401).json({ error: "Anmeldung fehlgeschlagen." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const user = await userService.authenticate(parsed.data.username, parsed.data.password);
|
||||
const session = sessionStore.create(user.id);
|
||||
res.cookie(SESSION_COOKIE, session.id, cookieOptions(authConfig));
|
||||
res.json({ signedIn: true, mustChangePassword: user.mustChangePassword });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError) {
|
||||
res.status(401).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMe(req: Request, res: Response) {
|
||||
const identity = req.authUser;
|
||||
if (!identity) {
|
||||
res.status(401).json({ error: "Nicht angemeldet." });
|
||||
return;
|
||||
}
|
||||
res.json(identity);
|
||||
}
|
||||
|
||||
export async function changePassword(req: Request, res: Response) {
|
||||
const identity = req.authUser;
|
||||
if (!identity) {
|
||||
res.status(401).json({ error: "Nicht angemeldet." });
|
||||
return;
|
||||
}
|
||||
const parsed = changePasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await userService.changeOwnPassword(
|
||||
identity.id,
|
||||
parsed.data.currentPassword,
|
||||
parsed.data.newPassword
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof WrongPasswordError) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// A password change invalidates every other session of this account; the
|
||||
// current one stays so the change doesn't log the user out themselves.
|
||||
sessionStore.destroyAllForUser(identity.id, sessionIdFromRequest(req));
|
||||
res.json({ changed: true });
|
||||
}
|
||||
|
||||
export function logout(req: Request, res: Response) {
|
||||
sessionStore.destroy(sessionIdFromRequest(req));
|
||||
res.clearCookie(SESSION_COOKIE, { ...cookieOptions(authConfig), maxAge: undefined });
|
||||
res.json({ signedOut: true });
|
||||
}
|
||||
103
src/server/controllers/user.controller.ts
Normal file
103
src/server/controllers/user.controller.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { Request, Response } from "express";
|
||||
import {
|
||||
createUserSchema,
|
||||
resetPasswordSchema,
|
||||
updateUserSchema,
|
||||
} from "../../shared/validation/user.schemas.js";
|
||||
import { UsernameTakenError } from "../../domain/services/user.service.js";
|
||||
import { userService } from "../composition/auth.js";
|
||||
import { userRepository } from "../composition/application-repositories.js";
|
||||
|
||||
// The password hash never leaves this layer.
|
||||
function toDto(user: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
active: boolean;
|
||||
mustChangePassword: boolean;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUsers(_req: Request, res: Response) {
|
||||
const rows = await userRepository.list();
|
||||
res.json(rows.map(toDto));
|
||||
}
|
||||
|
||||
export async function createUser(req: Request, res: Response) {
|
||||
const parsed = createUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await userService.createUser(parsed.data);
|
||||
if (!created) {
|
||||
res.status(500).json({ error: "Benutzer konnte nicht angelegt werden." });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(toDto(created));
|
||||
} catch (error) {
|
||||
if (error instanceof UsernameTakenError) {
|
||||
res.status(409).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser(req: Request, res: Response) {
|
||||
const { userId } = req.params;
|
||||
if (typeof userId !== "string") {
|
||||
res.status(400).json({ error: "Invalid userId" });
|
||||
return;
|
||||
}
|
||||
const parsed = updateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
// The signed-in admin can't lock themselves out by deactivating or
|
||||
// demoting their own account through this endpoint.
|
||||
if (userId === req.authUser?.id) {
|
||||
res.status(400).json({ error: "Der eigene Zugang kann hier nicht geändert werden." });
|
||||
return;
|
||||
}
|
||||
const existing = await userRepository.findById(userId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||
return;
|
||||
}
|
||||
const updated = await userService.updateRoleAndActive(userId, parsed.data);
|
||||
res.json(updated ? toDto(updated) : null);
|
||||
}
|
||||
|
||||
export async function resetUserPassword(req: Request, res: Response) {
|
||||
const { userId } = req.params;
|
||||
if (typeof userId !== "string") {
|
||||
res.status(400).json({ error: "Invalid userId" });
|
||||
return;
|
||||
}
|
||||
const parsed = resetPasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await userRepository.findById(userId);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: "Benutzer nicht gefunden." });
|
||||
return;
|
||||
}
|
||||
const updated = await userService.resetPassword(userId, parsed.data.newPassword);
|
||||
res.json(updated ? toDto(updated) : null);
|
||||
}
|
||||
|
|
@ -4,7 +4,12 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
|||
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
|
||||
import { projectRouter } from "./routes/project.routes.js";
|
||||
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
|
||||
import { authRouter } from "./routes/auth.routes.js";
|
||||
import { userRouter } from "./routes/user.routes.js";
|
||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||
import { createAuthGuard, requirePasswordChanged } from "./auth/auth.middleware.js";
|
||||
import { authConfig, sessionStore, userService } from "./composition/auth.js";
|
||||
import { userRepository } from "./composition/application-repositories.js";
|
||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||
import { closeDefaultDatabase } from "../db/client.js";
|
||||
|
||||
|
|
@ -50,6 +55,15 @@ app.get("/health", (_req, res) => {
|
|||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Everything below /api requires a signed-in, active user, except
|
||||
// /auth/login, /auth/status and /auth/logout (see createAuthGuard's
|
||||
// openPaths). authRouter is mounted AFTER the guard (not before) so that
|
||||
// /auth/me and /auth/change-password still get req.authUser populated by it.
|
||||
app.use("/api", createAuthGuard(authConfig, sessionStore, userRepository));
|
||||
app.use("/api", requirePasswordChanged());
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/users", userRouter);
|
||||
app.use("/api/cable-sizing", cableSizingRouter);
|
||||
app.use("/api/projects", projectRouter);
|
||||
app.use("/api/global-devices", globalDeviceRouter);
|
||||
|
|
@ -77,13 +91,20 @@ setInterval(() => {
|
|||
});
|
||||
}, heartbeatIntervalMs).unref();
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
userService
|
||||
.ensureBootstrapAdmin()
|
||||
.catch((error) => {
|
||||
logger.error("bootstrap admin setup failed", toErrorMeta(error));
|
||||
process.exit(1);
|
||||
})
|
||||
.then(() => {
|
||||
const server = app.listen(port, () => {
|
||||
logger.info("server started", { port });
|
||||
});
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
let shuttingDown = false;
|
||||
|
||||
function shutdown(signal: NodeJS.Signals): void {
|
||||
function shutdown(signal: NodeJS.Signals): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info("shutting down", { signal });
|
||||
|
|
@ -110,7 +131,8 @@ function shutdown(signal: NodeJS.Signals): void {
|
|||
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
|
||||
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
|
||||
server.closeIdleConnections();
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
});
|
||||
|
|
|
|||
16
src/server/routes/auth.routes.ts
Normal file
16
src/server/routes/auth.routes.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Router } from "express";
|
||||
import {
|
||||
changePassword,
|
||||
getAuthStatus,
|
||||
getMe,
|
||||
login,
|
||||
logout,
|
||||
} from "../controllers/auth.controller.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.get("/status", getAuthStatus);
|
||||
authRouter.post("/login", login);
|
||||
authRouter.get("/me", getMe);
|
||||
authRouter.post("/change-password", changePassword);
|
||||
authRouter.post("/logout", logout);
|
||||
16
src/server/routes/user.routes.ts
Normal file
16
src/server/routes/user.routes.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Router } from "express";
|
||||
import { requireRole } from "../auth/auth.middleware.js";
|
||||
import {
|
||||
createUser,
|
||||
listUsers,
|
||||
resetUserPassword,
|
||||
updateUser,
|
||||
} from "../controllers/user.controller.js";
|
||||
|
||||
export const userRouter = Router();
|
||||
|
||||
userRouter.use(requireRole("admin"));
|
||||
userRouter.get("/", listUsers);
|
||||
userRouter.post("/", createUser);
|
||||
userRouter.patch("/:userId", updateUser);
|
||||
userRouter.post("/:userId/reset-password", resetUserPassword);
|
||||
7
src/shared/constants/user-roles.ts
Normal file
7
src/shared/constants/user-roles.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const USER_ROLES = ["admin", "user"] as const;
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
|
||||
export const USER_ROLE_LABEL: Record<UserRole, string> = {
|
||||
admin: "Administrator",
|
||||
user: "Benutzer",
|
||||
};
|
||||
15
src/shared/validation/auth.schemas.ts
Normal file
15
src/shared/validation/auth.schemas.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const loginSchema = z
|
||||
.object({
|
||||
username: z.string().trim().min(1),
|
||||
password: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8).max(200),
|
||||
})
|
||||
.strict();
|
||||
33
src/shared/validation/user.schemas.ts
Normal file
33
src/shared/validation/user.schemas.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { z } from "zod";
|
||||
import { USER_ROLES } from "../constants/user-roles.js";
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
export const createUserSchema = z
|
||||
.object({
|
||||
username: z.string().trim().min(1).max(200),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
role: z.enum(USER_ROLES),
|
||||
initialPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updateUserSchema = z
|
||||
.object({
|
||||
role: z.enum(USER_ROLES).optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => value.role !== undefined || value.active !== undefined, {
|
||||
message: "Nothing to update.",
|
||||
});
|
||||
|
||||
export const resetPasswordSchema = z
|
||||
.object({
|
||||
newPassword: z.string().min(MIN_PASSWORD_LENGTH).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue