Paket A — Plattform-Admin: Benutzerverwaltung je Mandant

In der Mandanten-Detailansicht (/admin/[id]) kann der Plattform-Admin je Kunde
Benutzer anlegen und verwalten — ohne E-Mail-Flow, über Initial-/Einmal-Passwort.

- actions/platform-users.ts (Platform-Session-Guard, dbForTenant(zielTenant),
  Audit scope=platform):
  - createTenantUser: Name, E-Mail (eindeutig je Mandant), ≥1 Rolle; Initialpasswort
    selbst setzen ODER policy-konformes Einmal-Passwort erzeugen (einmalig angezeigt);
    Nutzer mit mustChangePassword=true.
  - resetTenantUserPassword: neues Einmal-Passwort + erzwungener Wechsel.
  - setTenantUserStatus: deaktivieren/reaktivieren.
  - setTenantUserRoles: Mehrfach-Rollenzuweisung.
  - Lockout-Schutz: letzter aktiver Mandanten-Admin kann nicht deaktiviert werden
    und ihm kann die Admin-Rolle nicht entzogen werden.
- components/platform-tenant-users.tsx: Anlege-Formular (useActionState, zeigt das
  Einmal-Passwort), Benutzerkarten mit Rollen-Checkboxen, Status-Toggle und Reset.
- /admin/[id] lädt Nutzer inkl. Rollen + Mandanten-Rollen und rendert die Verwaltung.
- Passwort-Generierung/-Prüfung gegen die Mandanten-Passwort-Policy.
- Zukunftssicher gekapselt: Einladungs-Flow (Paket 4) als alternative Aktivierung
  (Token statt Initialpasswort) später einhängbar.

tsc + lint + build + Guard-Check grün. Browser-E2E folgt zusammen mit Paket C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:24:33 +02:00
co-authored by Claude Opus 4.8
parent db36c795bd
commit 908b098400
4 changed files with 306 additions and 14 deletions
+152
View File
@@ -0,0 +1,152 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requirePlatformSession } from "@/server/platform-auth";
import { dbForTenant, type TenantDb } from "@/server/db";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { writePlatformAudit } from "@/server/audit";
/**
* Benutzerverwaltung durch den Plattform-Admin je Mandant (Paket A).
* EXEMPT vom Modul-Gating; Autorisierung über die Plattform-Session. Alle
* Schreibzugriffe laufen über dbForTenant(zielTenant) → strikt mandantengebunden.
* Audit-Scope: platform (Betreiber handelt mandantenübergreifend).
*
* Zukunftssicher: `createTenantUser` kapselt die Aktivierung über ein Initial-/
* Einmal-Passwort (mustChangePassword). Der spätere Einladungs-Flow (Paket 4) kann
* hier als alternative Aktivierung (Token statt Passwort) eingehängt werden.
*/
export type CreateUserState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; email: string; generatedPassword: string | null };
export type ResetPwState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; generatedPassword: string };
/** Zählt aktive Nutzer mit Mandanten-Admin-Rolle (Lockout-Schutz). */
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
return db.user.count({
where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
});
}
async function tenantPolicy(db: TenantDb, tenantId: string) {
const s = await db.tenantSettings.findUnique({ where: { tenantId } });
return resolvePasswordPolicy(s?.securityPolicy);
}
const emailSchema = z.string().trim().email();
/** Benutzer anlegen (Initial-/Einmal-Passwort, erzwungener Wechsel). */
export async function createTenantUser(tenantId: string, _prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const name = String(formData.get("name") ?? "").trim();
const emailRaw = String(formData.get("email") ?? "").trim().toLowerCase();
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
const mode = String(formData.get("pwMode") ?? "generate");
const typedPw = String(formData.get("password") ?? "");
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
const email = emailSchema.safeParse(emailRaw);
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (roleIds.length === 0) return { status: "error", message: "Bitte mindestens eine Rolle zuweisen." };
const existing = await db.user.findFirst({ where: { email: email.data } });
if (existing) return { status: "error", message: "In diesem Mandanten existiert bereits ein Nutzer mit dieser E-Mail." };
// Rollen müssen zum Mandanten gehören (dbForTenant filtert; count verifiziert).
const validRoles = await db.role.count({ where: { id: { in: roleIds } } });
if (validRoles !== roleIds.length) return { status: "error", message: "Ungültige Rollenauswahl." };
const policy = await tenantPolicy(db, tenantId);
let generated: string | null = null;
let password: string;
if (mode === "set") {
const missing = validatePassword(typedPw, policy);
if (missing.length) return { status: "error", message: `Das Initialpasswort benötigt ${missing.join(", ")}.` };
password = typedPw;
} else {
password = generateCompliantPassword(policy);
generated = password;
}
const user = await db.user.create({
data: {
tenantId, email: email.data, name, status: "ACTIVE",
passwordHash: await hashPassword(password), mustChangePassword: true,
userRoles: { create: roleIds.map((roleId) => ({ roleId })) },
},
});
await writePlatformAudit({ actorId: session.user.id, action: "create", entity: "user", entityId: user.id, after: { tenantId, email: email.data, roleIds } });
revalidatePath(`/admin/${tenantId}`);
return { status: "done", email: email.data, generatedPassword: generated };
}
/** Passwort zurücksetzen: neues Einmal-Passwort + erzwungener Wechsel. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Signatur von useActionState erzwungen
export async function resetTenantUserPassword(tenantId: string, userId: string, _prev: ResetPwState, _formData: FormData): Promise<ResetPwState> {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { status: "error", message: "Nutzer nicht gefunden." };
const password = generateCompliantPassword(await tenantPolicy(db, tenantId));
await db.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(password), mustChangePassword: true } });
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user_password", entityId: userId, after: { tenantId, reset: true } });
revalidatePath(`/admin/${tenantId}`);
return { status: "done", generatedPassword: password };
}
/** Deaktivieren/Reaktivieren mit Lockout-Schutz (letzter aktiver Mandanten-Admin). */
export async function setTenantUserStatus(tenantId: string, userId: string, status: "ACTIVE" | "DEACTIVATED") {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (status === "DEACTIVATED") {
const isAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
if (isAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Der letzte aktive Mandanten-Admin kann nicht deaktiviert werden.");
}
}
await db.user.update({ where: { id: userId }, data: { status } });
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { tenantId, status } });
revalidatePath(`/admin/${tenantId}`);
}
/** Rollen eines Nutzers setzen (Mehrfachauswahl), mit Lockout-Schutz. */
export async function setTenantUserRoles(tenantId: string, userId: string, formData: FormData) {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
const validRoles = roleIds.length ? await db.role.count({ where: { id: { in: roleIds } } }) : 0;
if (validRoles !== roleIds.length) throw new Error("Ungültige Rollenauswahl");
// Lockout-Schutz: dem letzten aktiven Mandanten-Admin nicht die Admin-Rolle entziehen.
const adminRole = await db.role.findFirst({ where: { key: "tenant-admin" } });
const hadAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
const keepsAdmin = adminRole ? roleIds.includes(adminRole.id) : false;
if (hadAdmin && !keepsAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Dem letzten aktiven Mandanten-Admin kann die Admin-Rolle nicht entzogen werden.");
}
await db.$transaction([
db.userRole.deleteMany({ where: { userId } }),
db.userRole.createMany({ data: roleIds.map((roleId) => ({ userId, roleId })) }),
]);
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user_roles", entityId: userId, after: { tenantId, roleIds } });
revalidatePath(`/admin/${tenantId}`);
}