feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)
Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten Anmeldung ersetzt wird, danach ist TOTP Pflicht. Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung. SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin bleibt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { anmeldenameSchema, kontoNameSchema, kontoRolleSchema } from "@/lib/lokale-konten";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
kontoAktivSetzen,
|
||||
kontoZuruecksetzen,
|
||||
lokalesKontoAnlegen,
|
||||
rolleAendern,
|
||||
} from "@/server/services/benutzer";
|
||||
|
||||
/** Benutzerverwaltung (#035). Duenn: Recht mit Re-Auth, Eingabe, Service. */
|
||||
|
||||
const PFAD = "/einstellungen/benutzer";
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
/** Das Einmal-Passwort geht genau einmal an den Browser des Admins – nie ins Protokoll. */
|
||||
export type KontoZustand = FormularZustand & {
|
||||
readonly einmalPasswort?: string;
|
||||
readonly anmeldename?: string;
|
||||
};
|
||||
|
||||
export async function kontoAnlegenAktion(_vorher: KontoZustand, formular: FormData): Promise<KontoZustand> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
const eingabe = z
|
||||
.object({ anmeldename: anmeldenameSchema, name: kontoNameSchema, rolle: kontoRolleSchema })
|
||||
.safeParse({
|
||||
anmeldename: textfeld(formular, "anmeldename") ?? "",
|
||||
name: textfeld(formular, "name") ?? "",
|
||||
rolle: textfeld(formular, "rolle") ?? "LESER",
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
const feldfehler: Record<string, string> = {};
|
||||
for (const fehler of eingabe.error.issues) feldfehler[String(fehler.path[0])] ??= fehler.message;
|
||||
return { feldfehler };
|
||||
}
|
||||
const { einmalPasswort } = await lokalesKontoAnlegen(eingabe.data, akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return { einmalPasswort, anmeldename: eingabe.data.anmeldename };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rolleAendernAktion(kontoId: string, rolle: string): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
await rolleAendern(id.parse(kontoId), kontoRolleSchema.parse(rolle), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function aktivSetzenAktion(kontoId: string, aktiv: boolean): Promise<{ fehler?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
await kontoAktivSetzen(id.parse(kontoId), z.boolean().parse(aktiv), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function zuruecksetzenAktion(
|
||||
kontoId: string,
|
||||
): Promise<{ fehler?: string; einmalPasswort?: string }> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("benutzerVerwalten");
|
||||
const { einmalPasswort } = await kontoZuruecksetzen(id.parse(kontoId), akteurVon(benutzer));
|
||||
revalidatePath(PFAD);
|
||||
return { einmalPasswort };
|
||||
} catch (fehler) {
|
||||
return fehlerZustand(fehler);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user