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:
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 & 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 */}
|
||||
|
||||
@@ -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 & Berechtigungen</h2>
|
||||
<RoleManager roles={managedRoles} allPermissions={[...PERMISSIONS]} />
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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