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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user