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
+1
View File
@@ -34,6 +34,7 @@ const ACTION_MODULE: Record<string, string> = {
"admin.ts": "EXEMPT",
"platform.ts": "EXEMPT",
"platform-users.ts": "EXEMPT",
"tenant-users.ts": "EXEMPT",
"account.ts": "EXEMPT",
"tenant-settings.ts": "EXEMPT",
};
+11
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
@@ -28,6 +29,16 @@ export default async function SettingsPage() {
<main className="flex-1 p-6">
<PageHead crumb="Organisation" title="Einstellungen" sub="Unternehmensdaten, Branding, Sicherheits-Policy — Stammdaten speisen die ISMS-Variablen" />
{(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && (
<div className="shadow-card mt-4 flex items-center justify-between rounded-xl border bg-card p-5">
<div>
<p className="font-heading text-sm font-semibold">Benutzer &amp; Rollen</p>
<p className="text-[12px] text-muted-foreground">Benutzer anlegen/verwalten, Rollen und Berechtigungen pflegen (mandantenintern).</p>
</div>
<Button nativeButton={false} render={<Link href="/settings/users" />}>Öffnen</Button>
</div>
)}
<form action={updateTenantSettings} className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
<div className="space-y-5">
{/* Unternehmensdaten */}
+68
View File
@@ -0,0 +1,68 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
import { PageHead } from "@/components/mockup-ui";
import { TenantUsersManager } from "@/components/tenant-users-manager";
import { RoleManager } from "@/components/role-manager";
/**
* Benutzer- & Rollenverwaltung im Mandanten (Paket B). Sichtbar nur mit user:manage
* bzw. role:manage; strikt auf den eigenen Mandanten begrenzt (dbForTenant).
*/
export default async function UsersRolesPage() {
const session = await requireSession();
const canUsers = hasPermission(session, "user:manage");
const canRoles = hasPermission(session, "role:manage");
if (!canUsers && !canRoles) redirect("/dashboard");
const db = dbForTenant(session.user.tenantId);
const [users, roles] = await Promise.all([
db.user.findMany({
select: { id: true, name: true, email: true, status: true, userRoles: { select: { roleId: true } } },
orderBy: { createdAt: "asc" },
}),
db.role.findMany({
include: { rolePermissions: { include: { permission: true } }, _count: { select: { userRoles: true } } },
orderBy: { name: "asc" },
}),
]);
const standardKeys = new Set(Object.keys(ROLE_DEFS));
const roleOptions = roles.map((r) => ({ id: r.id, key: r.key, name: r.name }));
const managedUsers = users.map((u) => ({
id: u.id, name: u.name, email: u.email, status: u.status,
roleIds: u.userRoles.map((ur) => ur.roleId), isSelf: u.id === session.user.id,
}));
const managedRoles = roles.map((r) => ({
id: r.id, key: r.key, name: r.name, isStandard: standardKeys.has(r.key),
permKeys: r.rolePermissions.map((rp) => rp.permission.key), userCount: r._count.userRoles,
}));
return (
<main className="flex-1 p-6">
<Link href="/settings" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> Zurück zu Einstellungen
</Link>
<div className="mt-3">
<PageHead crumb="Organisation" title="Benutzer & Rollen" sub="Benutzer anlegen und verwalten, Rollen und Berechtigungen pflegen — nur für den eigenen Mandanten." />
</div>
{canUsers && (
<section className="mt-5">
<h2 className="mb-3 font-heading text-sm font-semibold">Benutzer</h2>
<TenantUsersManager users={managedUsers} roles={roleOptions} />
</section>
)}
{canRoles && (
<section className="mt-8">
<h2 className="mb-3 font-heading text-sm font-semibold">Rollen &amp; Berechtigungen</h2>
<RoleManager roles={managedRoles} allPermissions={[...PERMISSIONS]} />
</section>
)}
</main>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useActionState } from "react";
import {
createRole, updateRolePermissions, cloneRole, deleteRole, type RoleFormState,
} from "@/server/actions/tenant-users";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Pill } from "@/components/mockup-ui";
export interface ManagedRole { id: string; key: string; name: string; isStandard: boolean; permKeys: string[]; userCount: number }
const createInit: RoleFormState = { status: "idle" };
/** Permission-Checkboxen, gruppiert nach Ressourcen-Präfix (asset:*, risk:* …). */
function PermissionChecks({ allPermissions, selected, idPrefix }: { allPermissions: string[]; selected: Set<string>; idPrefix: string }) {
const groups = new Map<string, string[]>();
for (const p of allPermissions) {
const g = p.split(":")[0];
if (!groups.has(g)) groups.set(g, []);
groups.get(g)!.push(p);
}
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[...groups.entries()].map(([g, perms]) => (
<div key={g} className="rounded-lg border bg-muted/40 p-2.5">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
<div className="space-y-1">
{perms.map((p) => (
<label key={p} htmlFor={`${idPrefix}-${p}`} className="flex items-center gap-1.5 text-[12.5px]">
<input id={`${idPrefix}-${p}`} type="checkbox" name="perms" value={p} defaultChecked={selected.has(p)} />
<code className="font-mono text-[11.5px]">{p}</code>
</label>
))}
</div>
</div>
))}
</div>
);
}
function CreateRoleForm({ allPermissions }: { allPermissions: string[] }) {
const [state, action, pending] = useActionState(createRole, createInit);
return (
<form action={action} className="space-y-3">
<div className="max-w-sm">
<Label htmlFor="nr-name">Rollenname *</Label>
<Input id="nr-name" name="name" required className="mt-1" placeholder="z. B. Auditor (extern)" />
</div>
<div>
<Label>Berechtigungen</Label>
<div className="mt-1"><PermissionChecks allPermissions={allPermissions} selected={new Set()} idPrefix="nr" /></div>
</div>
{state.status === "error" && <p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>}
{state.status === "done" && <p className="text-sm text-[var(--ok)]">Rolle angelegt.</p>}
<Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Rolle anlegen"}</Button>
</form>
);
}
function RoleCard({ role, allPermissions }: { role: ManagedRole; allPermissions: string[] }) {
return (
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="font-medium">{role.name}</p>
<code className="font-mono text-[11px] text-muted-foreground">{role.key}</code>
{role.isStandard ? <Pill tone="info">Standard</Pill> : <Pill tone="mut">Eigen</Pill>}
</div>
<span className="text-[11.5px] text-muted-foreground">{role.userCount} Nutzer · {role.permKeys.length} Rechte</span>
</div>
{role.isStandard ? (
<div className="mt-3">
<p className="text-[12px] text-muted-foreground">Standardrollen sind schreibgeschützt. Zum Anpassen klonen und die Kopie bearbeiten.</p>
<div className="mt-2 flex flex-wrap gap-2">
{role.permKeys.map((p) => <code key={p} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{p}</code>)}
</div>
<form action={cloneRole.bind(null, role.id)} className="mt-3 flex items-end gap-2">
<div><Label htmlFor={`clone-${role.id}`} className="text-[11px]">Name der Kopie</Label>
<Input id={`clone-${role.id}`} name="name" className="mt-1 h-8" placeholder={`${role.name} (Kopie)`} /></div>
<Button type="submit" variant="outline" size="sm">Klonen</Button>
</form>
</div>
) : (
<div className="mt-3 space-y-3">
<form action={updateRolePermissions.bind(null, role.id)}>
<PermissionChecks allPermissions={allPermissions} selected={new Set(role.permKeys)} idPrefix={`er-${role.id}`} />
<Button type="submit" variant="outline" size="sm" className="mt-2">Berechtigungen speichern</Button>
</form>
<div className="flex flex-wrap gap-2">
<form action={cloneRole.bind(null, role.id)}><Button type="submit" variant="outline" size="sm">Klonen</Button></form>
<form action={deleteRole.bind(null, role.id)}><Button type="submit" variant="outline" size="sm">Löschen</Button></form>
</div>
</div>
)}
</div>
);
}
export function RoleManager({ roles, allPermissions }: { roles: ManagedRole[]; allPermissions: string[] }) {
return (
<div className="space-y-4">
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">Eigene Rolle anlegen</p>
<CreateRoleForm allPermissions={allPermissions} />
</div>
<div className="space-y-3">
{roles.map((r) => <RoleCard key={r.id} role={r} allPermissions={allPermissions} />)}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useActionState } from "react";
import {
createUser, resetUserPassword, setUserStatus, setUserRoles,
type CreateUserState, type ResetPwState,
} from "@/server/actions/tenant-users";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Pill } from "@/components/mockup-ui";
export interface RoleOption { id: string; key: string; name: string }
export interface ManagedUser { id: string; name: string; email: string; status: string; roleIds: string[]; isSelf: boolean }
const createInit: CreateUserState = { status: "idle" };
const resetInit: ResetPwState = { status: "idle" };
function OneTimeSecret({ label, value }: { label: string; value: string }) {
return (
<div className="mt-2 rounded-lg bg-[rgba(46,204,113,0.12)] px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}: </span>
<code className="select-all font-mono font-semibold tracking-wider">{value}</code>
<p className="mt-0.5 text-[11px] text-muted-foreground">Einmalig sichtbar dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.</p>
</div>
);
}
function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set<string>; idPrefix: string }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{roles.map((r) => (
<label key={r.id} htmlFor={`${idPrefix}-${r.id}`} className="flex items-center gap-1.5 text-[13px]">
<input id={`${idPrefix}-${r.id}`} type="checkbox" name="roles" value={r.id} defaultChecked={selected.has(r.id)} />
{r.name}
</label>
))}
</div>
);
}
function CreateUserForm({ roles }: { roles: RoleOption[] }) {
const [state, action, pending] = useActionState(createUser, createInit);
return (
<form action={action} className="grid gap-3 md:grid-cols-2">
<div>
<Label htmlFor="nu-name">Name *</Label>
<Input id="nu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" />
</div>
<div>
<Label htmlFor="nu-email">E-Mail *</Label>
<Input id="nu-email" name="email" type="email" required className="mt-1" placeholder="person@firma.example" />
</div>
<div className="md:col-span-2">
<Label>Rollen *</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set()} idPrefix="nu" /></div>
</div>
<fieldset className="md:col-span-2 space-y-2">
<Label>Initialpasswort</Label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="generate" defaultChecked /> Einmal-Passwort automatisch erzeugen (empfohlen, einmalig angezeigt)</label>
<label className="flex items-center gap-2 text-[13px]"><input type="radio" name="pwMode" value="set" /> Passwort selbst setzen:</label>
<Input name="password" type="text" className="mt-1" placeholder={"nur bei „selbst setzen“"} autoComplete="off" />
</fieldset>
{state.status === "error" && (
<p role="alert" className="md:col-span-2 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>
)}
{state.status === "done" && (
<div className="md:col-span-2">
<p className="text-sm text-[var(--ok)]">Nutzer {state.email} angelegt.</p>
{state.generatedPassword && <OneTimeSecret label="Einmal-Passwort" value={state.generatedPassword} />}
</div>
)}
<div className="md:col-span-2"><Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Benutzer anlegen"}</Button></div>
</form>
);
}
function ResetPasswordButton({ userId }: { userId: string }) {
const [state, action, pending] = useActionState(resetUserPassword.bind(null, userId), resetInit);
return (
<form action={action} className="inline">
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Passwort zurücksetzen"}</Button>
{state.status === "done" && <OneTimeSecret label="Neues Einmal-Passwort" value={state.generatedPassword} />}
{state.status === "error" && <p className="mt-1 text-xs text-[var(--risk)]">{state.message}</p>}
</form>
);
}
function UserRow({ user, roles }: { user: ManagedUser; roles: RoleOption[] }) {
const active = user.status === "ACTIVE";
return (
<div className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium">{user.name} {user.isSelf && <span className="text-[11px] text-muted-foreground">(Sie)</span>}</p>
<p className="text-[12px] text-muted-foreground">{user.email}</p>
</div>
<Pill tone={active ? "ok" : "mut"}>{active ? "Aktiv" : "Deaktiviert"}</Pill>
</div>
<form action={setUserRoles.bind(null, user.id)} className="mt-3">
<Label className="text-[12px] text-muted-foreground">Rollen</Label>
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(user.roleIds)} idPrefix={`ru-${user.id}`} /></div>
<Button type="submit" variant="outline" size="sm" className="mt-2">Rollen speichern</Button>
</form>
<div className="mt-3 flex flex-wrap items-center gap-2">
<form action={setUserStatus.bind(null, user.id, active ? "DEACTIVATED" : "ACTIVE")} className="inline">
<Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button>
</form>
<ResetPasswordButton userId={user.id} />
</div>
</div>
);
}
export function TenantUsersManager({ users, roles }: { users: ManagedUser[]; roles: RoleOption[] }) {
return (
<div className="space-y-4">
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">Benutzer anlegen</p>
<CreateUserForm roles={roles} />
</div>
<div className="space-y-3">
{users.map((u) => <UserRow key={u.id} user={u} roles={roles} />)}
</div>
</div>
);
}
+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");
}