Phase B1: Anmeldung, Benutzer, Gruppen und Rollen
Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.
Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().
- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
gedruckter Code mehr scannen.
Zwei Fehler dabei gefunden und behoben:
- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
Erkennung laeuft jetzt ueber eine Eigenschaft.
Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
|
||||
function useSubmit() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (json: unknown) => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiJson("/api/auth", { method: "POST", json });
|
||||
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
|
||||
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
|
||||
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof ApiError
|
||||
? caught.message
|
||||
: "Die Anmeldung ist fehlgeschlagen.",
|
||||
);
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { submit, pending, error, router };
|
||||
}
|
||||
|
||||
export function LoginForm() {
|
||||
const { submit, pending, error } = useSubmit();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit({ action: "login", email, password });
|
||||
}}
|
||||
>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
|
||||
<Field label="E-Mail-Adresse" htmlFor="login-email">
|
||||
<TextInput
|
||||
id="login-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
value={email}
|
||||
disabled={pending}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Passwort" htmlFor="login-password">
|
||||
<TextInput
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
disabled={pending}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Wird angemeldet …" : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetupForm({ minLength }: { minLength: number }) {
|
||||
const { submit, pending, error } = useSubmit();
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
|
||||
const mismatch = repeat.length > 0 && password !== repeat;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (mismatch) return;
|
||||
void submit({ action: "setup", email, name, password });
|
||||
}}
|
||||
>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
|
||||
<Field label="Name (optional)" htmlFor="setup-name">
|
||||
<TextInput
|
||||
id="setup-name"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
disabled={pending}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="E-Mail-Adresse" htmlFor="setup-email">
|
||||
<TextInput
|
||||
id="setup-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={email}
|
||||
disabled={pending}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Passwort"
|
||||
htmlFor="setup-password"
|
||||
hint={`Mindestens ${minLength} Zeichen.`}
|
||||
>
|
||||
<TextInput
|
||||
id="setup-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={minLength}
|
||||
value={password}
|
||||
disabled={pending}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Passwort wiederholen"
|
||||
htmlFor="setup-repeat"
|
||||
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
|
||||
>
|
||||
<TextInput
|
||||
id="setup-repeat"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
invalid={mismatch}
|
||||
value={repeat}
|
||||
disabled={pending}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" disabled={pending || mismatch}>
|
||||
{pending ? "Wird angelegt …" : "Administrator anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await apiJson("/api/auth", {
|
||||
method: "POST",
|
||||
json: { action: "logout" },
|
||||
});
|
||||
} finally {
|
||||
router.replace("/anmelden");
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "…" : "Abmelden"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user