diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 3f79670..10874a6 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -33,6 +33,7 @@ const ACTION_MODULE: Record = { // keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard. "admin.ts": "EXEMPT", "platform.ts": "EXEMPT", + "platform-users.ts": "EXEMPT", "account.ts": "EXEMPT", "tenant-settings.ts": "EXEMPT", }; diff --git a/src/app/(platform)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx index 42e7e60..7802f76 100644 --- a/src/app/(platform)/admin/[id]/page.tsx +++ b/src/app/(platform)/admin/[id]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { PageHead, Pill } from "@/components/mockup-ui"; import { MODULES } from "@/lib/modules"; import { setTenantStatus, toggleTenantModule } from "@/server/actions/admin"; +import { PlatformTenantUsers } from "@/components/platform-tenant-users"; const STATUS_TONE: Record = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; const STATUS_LABEL: Record = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" }; @@ -19,11 +20,19 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id include: { settings: true, modules: true, - users: { select: { id: true, name: true, email: true, status: true }, orderBy: { createdAt: "asc" } }, + roles: { select: { id: true, key: true, name: true }, orderBy: { name: "asc" } }, + users: { + select: { id: true, name: true, email: true, status: true, userRoles: { select: { roleId: true } } }, + orderBy: { createdAt: "asc" }, + }, }, }); if (!tenant) notFound(); + const managedUsers = tenant.users.map((u) => ({ + id: u.id, name: u.name, email: u.email, status: u.status, roleIds: u.userRoles.map((ur) => ur.roleId), + })); + const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled])); const isOn = (key: string) => moduleState.get(key) ?? true; @@ -84,23 +93,19 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id

Löschung/Datenexport (DSGVO) und Retention folgen in Phase 2.

- {/* Nutzer */} + {/* Nutzer-Kurzinfo */}
-

Nutzer ({tenant.users.length})

-
    - {tenant.users.map((u) => ( -
  • - - {u.name} - {u.email} - - {u.status} -
  • - ))} -
+

Benutzer

+

{tenant.users.length} Nutzer · Verwaltung unten (anlegen, Rollen, deaktivieren, Passwort zurücksetzen).

+ + {/* Benutzerverwaltung (Paket A) */} +
+

Benutzer & Rollen — {tenant.name}

+ +
); } diff --git a/src/components/platform-tenant-users.tsx b/src/components/platform-tenant-users.tsx new file mode 100644 index 0000000..005b74b --- /dev/null +++ b/src/components/platform-tenant-users.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useActionState } from "react"; +import { + createTenantUser, resetTenantUserPassword, setTenantUserStatus, setTenantUserRoles, + type CreateUserState, type ResetPwState, +} from "@/server/actions/platform-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[] } + +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({ tenantId, roles }: { tenantId: string; roles: RoleOption[] }) { + const [state, action, pending] = useActionState(createTenantUser.bind(null, tenantId), createInit); + return ( +
+
+ + +
+
+ + +
+
+ +
+
+
+ + + + +
+ {state.status === "error" && ( +

{state.message}

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

Nutzer {state.email} angelegt.

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

{state.message}

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

{user.name}

+

{user.email}

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

Benutzer anlegen

+ +
+
+ {users.map((u) => ( + + ))} + {users.length === 0 &&

Noch keine Benutzer in diesem Mandanten.

} +
+
+ ); +} diff --git a/src/server/actions/platform-users.ts b/src/server/actions/platform-users.ts new file mode 100644 index 0000000..d778718 --- /dev/null +++ b/src/server/actions/platform-users.ts @@ -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 { + 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 { + 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 { + 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}`); +}