From d0821e2e10a10642c13727aa356f0fc4879ae102 Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 22 Jul 2026 09:44:28 +0200 Subject: [PATCH] =?UTF-8?q?Paket=20B=20=E2=80=94=20Mandanten-Admin:=20Benu?= =?UTF-8?q?tzer-=20&=20Rollenverwaltung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/check-module-guards.ts | 1 + src/app/(app)/settings/page.tsx | 11 ++ src/app/(app)/settings/users/page.tsx | 68 ++++++++ src/components/role-manager.tsx | 114 ++++++++++++ src/components/tenant-users-manager.tsx | 129 ++++++++++++++ src/server/actions/tenant-users.ts | 219 ++++++++++++++++++++++++ 6 files changed, 542 insertions(+) create mode 100644 src/app/(app)/settings/users/page.tsx create mode 100644 src/components/role-manager.tsx create mode 100644 src/components/tenant-users-manager.tsx create mode 100644 src/server/actions/tenant-users.ts diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 10874a6..9aa6f5a 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -34,6 +34,7 @@ const ACTION_MODULE: Record = { "admin.ts": "EXEMPT", "platform.ts": "EXEMPT", "platform-users.ts": "EXEMPT", + "tenant-users.ts": "EXEMPT", "account.ts": "EXEMPT", "tenant-settings.ts": "EXEMPT", }; diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 5191b35..a3ff4fc 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -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() {
+ {(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && ( +
+
+

Benutzer & Rollen

+

Benutzer anlegen/verwalten, Rollen und Berechtigungen pflegen (mandantenintern).

+
+ +
+ )} +
{/* Unternehmensdaten */} diff --git a/src/app/(app)/settings/users/page.tsx b/src/app/(app)/settings/users/page.tsx new file mode 100644 index 0000000..e6b332f --- /dev/null +++ b/src/app/(app)/settings/users/page.tsx @@ -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 ( +
+ + Zurück zu Einstellungen + +
+ +
+ + {canUsers && ( +
+

Benutzer

+ +
+ )} + + {canRoles && ( +
+

Rollen & Berechtigungen

+ +
+ )} +
+ ); +} diff --git a/src/components/role-manager.tsx b/src/components/role-manager.tsx new file mode 100644 index 0000000..eeaf707 --- /dev/null +++ b/src/components/role-manager.tsx @@ -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; idPrefix: string }) { + const groups = new Map(); + for (const p of allPermissions) { + const g = p.split(":")[0]; + if (!groups.has(g)) groups.set(g, []); + groups.get(g)!.push(p); + } + return ( +
+ {[...groups.entries()].map(([g, perms]) => ( +
+

{g}

+
+ {perms.map((p) => ( + + ))} +
+
+ ))} +
+ ); +} + +function CreateRoleForm({ allPermissions }: { allPermissions: string[] }) { + const [state, action, pending] = useActionState(createRole, createInit); + return ( + +
+ + +
+
+ +
+
+ {state.status === "error" &&

{state.message}

} + {state.status === "done" &&

Rolle angelegt.

} + + + ); +} + +function RoleCard({ role, allPermissions }: { role: ManagedRole; allPermissions: string[] }) { + return ( +
+
+
+

{role.name}

+ {role.key} + {role.isStandard ? Standard : Eigen} +
+ {role.userCount} Nutzer · {role.permKeys.length} Rechte +
+ + {role.isStandard ? ( +
+

Standardrollen sind schreibgeschützt. Zum Anpassen klonen und die Kopie bearbeiten.

+
+ {role.permKeys.map((p) => {p})} +
+
+
+
+ +
+
+ ) : ( +
+
+ + + +
+
+
+
+
+ )} +
+ ); +} + +export function RoleManager({ roles, allPermissions }: { roles: ManagedRole[]; allPermissions: string[] }) { + return ( +
+
+

Eigene Rolle anlegen

+ +
+
+ {roles.map((r) => )} +
+
+ ); +} diff --git a/src/components/tenant-users-manager.tsx b/src/components/tenant-users-manager.tsx new file mode 100644 index 0000000..a29e9fb --- /dev/null +++ b/src/components/tenant-users-manager.tsx @@ -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 ( +
+ {label}: + {value} +

Einmalig sichtbar — dem Nutzer sicher mitteilen. Wechsel beim ersten Login erforderlich.

+
+ ); +} + +function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set; idPrefix: string }) { + return ( +
+ {roles.map((r) => ( + + ))} +
+ ); +} + +function CreateUserForm({ roles }: { roles: RoleOption[] }) { + const [state, action, pending] = useActionState(createUser, createInit); + return ( +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + +
+ {state.status === "error" && ( +

{state.message}

+ )} + {state.status === "done" && ( +
+

Nutzer {state.email} angelegt.

+ {state.generatedPassword && } +
+ )} +
+
+ ); +} + +function ResetPasswordButton({ userId }: { userId: string }) { + const [state, action, pending] = useActionState(resetUserPassword.bind(null, userId), resetInit); + return ( +
+ + {state.status === "done" && } + {state.status === "error" &&

{state.message}

} + + ); +} + +function UserRow({ user, roles }: { user: ManagedUser; roles: RoleOption[] }) { + const active = user.status === "ACTIVE"; + return ( +
+
+
+

{user.name} {user.isSelf && (Sie)}

+

{user.email}

+
+ {active ? "Aktiv" : "Deaktiviert"} +
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+ ); +} + +export function TenantUsersManager({ users, roles }: { users: ManagedUser[]; roles: RoleOption[] }) { + return ( +
+
+

Benutzer anlegen

+ +
+
+ {users.map((u) => )} +
+
+ ); +} diff --git a/src/server/actions/tenant-users.ts b/src/server/actions/tenant-users.ts new file mode 100644 index 0000000..4443581 --- /dev/null +++ b/src/server/actions/tenant-users.ts @@ -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(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 { + 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 { + 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 { + 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 { + 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"); +}