Paket B — Mandanten-Admin: Benutzer- & Rollenverwaltung

Eigener Bereich /settings/users (verlinkt aus /settings), sichtbar nur mit
user:manage bzw. role:manage. Strikt mandantenintern (dbForTenant über die eigene
Session) — kein Cross-Tenant-Zugriff. Audit-Scope: tenant.

- actions/tenant-users.ts:
  - Benutzer (user:manage): createUser (Initial-/Einmal-Passwort, mustChangePassword),
    resetUserPassword, setUserStatus (deaktivieren/reaktivieren), setUserRoles.
  - Rollen (role:manage): createRole (+ Permissions), updateRolePermissions
    (Standardrollen schreibgeschützt), cloneRole (Standard klonbar), deleteRole
    (nur eigene, nur ohne zugewiesene Nutzer — sonst Umzug-Hinweis).
  - Lockout-Schutz: letzter aktiver Mandanten-Admin kann nicht deaktiviert werden
    und ihm kann die Admin-Rolle nicht entzogen werden.
- Komponenten tenant-users-manager.tsx (Benutzer) und role-manager.tsx (Rollen +
  gruppierter Permission-Katalog); Bestands-UI/Dark-Theme wiederverwendet.

Browser-verifiziert: Mandanten-Admin sieht Benutzer + Rollen des eigenen Mandanten,
legt eine eigene Rolle „Externer Prüfer" mit ausgewählten Rechten an (asset:read,
audit:read, policy:read → korrekt gespeichert); Standardrollen als schreibgeschützt
markiert. tsc + lint + build + Guard-Check grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:44:28 +02:00
co-authored by Claude Opus 4.8
parent 3db820ea90
commit d0821e2e10
6 changed files with 542 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, type TenantDb } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { writeAuditLog } from "@/server/audit";
/**
* Benutzer- & Rollenverwaltung durch den Mandanten-Admin (Paket B). EXEMPT vom
* Modul-Gating; Autorisierung über RBAC (user:manage / role:manage). Alle Zugriffe
* laufen strikt über die eigene Session (dbForTenant(session.tenantId)) — Cross-Tenant
* ist ausgeschlossen. Audit-Scope: tenant.
*/
const STANDARD_ROLE_KEYS = new Set(Object.keys(ROLE_DEFS));
const PERMISSION_SET = new Set<string>(PERMISSIONS);
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 };
async function ctx(permission: "user:manage" | "role:manage") {
const session = await requireSession();
requirePermission(session, permission);
return { session, tenantId: session.user.tenantId, db: dbForTenant(session.user.tenantId) };
}
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 (user:manage) ── */
export async function createUser(_prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
const { session, tenantId, db } = await ctx("user:manage");
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." };
if (await db.user.findFirst({ where: { email: email.data } })) return { status: "error", message: "Es existiert bereits ein Nutzer mit dieser E-Mail." };
if ((await db.role.count({ where: { id: { in: roleIds } } })) !== 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 writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "user", entityId: user.id, after: { email: email.data, roleIds } });
revalidatePath("/settings/users");
return { status: "done", email: email.data, generatedPassword: generated };
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Signatur von useActionState erzwungen
export async function resetUserPassword(userId: string, _prev: ResetPwState, _formData: FormData): Promise<ResetPwState> {
const { session, tenantId, db } = await ctx("user:manage");
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 writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user_password", entityId: userId, after: { reset: true } });
revalidatePath("/settings/users");
return { status: "done", generatedPassword: password };
}
export async function setUserStatus(userId: string, status: "ACTIVE" | "DEACTIVATED") {
const { session, tenantId, db } = await ctx("user:manage");
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" && user.userRoles.some((ur) => ur.role.key === "tenant-admin") && 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 writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { status } });
revalidatePath("/settings/users");
}
export async function setUserRoles(userId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("user:manage");
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");
if (roleIds.length && (await db.role.count({ where: { id: { in: roleIds } } })) !== roleIds.length) throw new Error("Ungültige Rollenauswahl");
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 writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user_roles", entityId: userId, after: { roleIds } });
revalidatePath("/settings/users");
}
/* ── Rollen (role:manage) ── */
const slugify = (s: string) =>
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "rolle";
function validPermissionKeys(formData: FormData): string[] {
return formData.getAll("perms").map(String).filter((k) => PERMISSION_SET.has(k));
}
export type RoleFormState = { status: "idle" } | { status: "error"; message: string } | { status: "done" };
/** Eigene Rolle anlegen (Standardrollen-Keys sind reserviert). */
export async function createRole(_prev: RoleFormState, formData: FormData): Promise<RoleFormState> {
const { session, tenantId, db } = await ctx("role:manage");
const name = String(formData.get("name") ?? "").trim();
if (name.length < 2) return { status: "error", message: "Bitte einen Rollennamen (min. 2 Zeichen) angeben." };
const perms = validPermissionKeys(formData);
let key = slugify(name);
if (STANDARD_ROLE_KEYS.has(key)) key = `${key}-eigen`;
// Eindeutigkeit je Mandant sicherstellen.
let suffix = 1;
const base = key;
while (await db.role.findFirst({ where: { key } })) key = `${base}-${suffix++}`;
const role = await db.role.create({ data: { tenantId, key, name } });
if (perms.length) {
const permRows = await db.permission.findMany({ where: { key: { in: perms } } });
await db.rolePermission.createMany({ data: permRows.map((p) => ({ roleId: role.id, permissionId: p.id })) });
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "role", entityId: role.id, after: { key, name, perms } });
revalidatePath("/settings/users");
return { status: "done" };
}
/** Permissions einer eigenen Rolle setzen (Standardrollen sind schreibgeschützt). */
export async function updateRolePermissions(roleId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("role:manage");
const role = await db.role.findUnique({ where: { id: roleId } });
if (!role) throw new Error("Rolle nicht gefunden");
if (STANDARD_ROLE_KEYS.has(role.key)) throw new Error("Standardrollen sind schreibgeschützt. Bitte klonen und die Kopie anpassen.");
const perms = validPermissionKeys(formData);
const permRows = await db.permission.findMany({ where: { key: { in: perms } } });
await db.$transaction([
db.rolePermission.deleteMany({ where: { roleId } }),
db.rolePermission.createMany({ data: permRows.map((p) => ({ roleId, permissionId: p.id })) }),
]);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "role", entityId: roleId, after: { perms } });
revalidatePath("/settings/users");
}
/** Rolle klonen (auch Standardrollen) → neue eigene, editierbare Rolle. */
export async function cloneRole(roleId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("role:manage");
const source = await db.role.findUnique({ where: { id: roleId }, include: { rolePermissions: true } });
if (!source) throw new Error("Rolle nicht gefunden");
const name = String(formData.get("name") ?? "").trim() || `${source.name} (Kopie)`;
let key = slugify(name);
if (STANDARD_ROLE_KEYS.has(key)) key = `${key}-eigen`;
let suffix = 1;
const base = key;
while (await db.role.findFirst({ where: { key } })) key = `${base}-${suffix++}`;
const clone = await db.role.create({ data: { tenantId, key, name } });
if (source.rolePermissions.length) {
await db.rolePermission.createMany({ data: source.rolePermissions.map((rp) => ({ roleId: clone.id, permissionId: rp.permissionId })) });
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "role", entityId: clone.id, after: { clonedFrom: source.key, key, name } });
revalidatePath("/settings/users");
}
/** Rolle löschen: nur eigene Rollen, nur ohne zugewiesene Nutzer (Umzug-Hinweis). */
export async function deleteRole(roleId: string) {
const { session, tenantId, db } = await ctx("role:manage");
const role = await db.role.findUnique({ where: { id: roleId }, include: { _count: { select: { userRoles: true } } } });
if (!role) throw new Error("Rolle nicht gefunden");
if (STANDARD_ROLE_KEYS.has(role.key)) throw new Error("Standardrollen können nicht gelöscht werden.");
if (role._count.userRoles > 0) throw new Error(`Rolle ist ${role._count.userRoles} Nutzer(n) zugewiesen. Bitte zuerst umziehen/entfernen.`);
await db.role.delete({ where: { id: roleId } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "delete", entity: "role", entityId: roleId, after: { key: role.key } });
revalidatePath("/settings/users");
}