Paket A — Plattform-Admin: Benutzerverwaltung je Mandant
In der Mandanten-Detailansicht (/admin/[id]) kann der Plattform-Admin je Kunde
Benutzer anlegen und verwalten — ohne E-Mail-Flow, über Initial-/Einmal-Passwort.
- actions/platform-users.ts (Platform-Session-Guard, dbForTenant(zielTenant),
Audit scope=platform):
- createTenantUser: Name, E-Mail (eindeutig je Mandant), ≥1 Rolle; Initialpasswort
selbst setzen ODER policy-konformes Einmal-Passwort erzeugen (einmalig angezeigt);
Nutzer mit mustChangePassword=true.
- resetTenantUserPassword: neues Einmal-Passwort + erzwungener Wechsel.
- setTenantUserStatus: deaktivieren/reaktivieren.
- setTenantUserRoles: Mehrfach-Rollenzuweisung.
- Lockout-Schutz: letzter aktiver Mandanten-Admin kann nicht deaktiviert werden
und ihm kann die Admin-Rolle nicht entzogen werden.
- components/platform-tenant-users.tsx: Anlege-Formular (useActionState, zeigt das
Einmal-Passwort), Benutzerkarten mit Rollen-Checkboxen, Status-Toggle und Reset.
- /admin/[id] lädt Nutzer inkl. Rollen + Mandanten-Rollen und rendert die Verwaltung.
- Passwort-Generierung/-Prüfung gegen die Mandanten-Passwort-Policy.
- Zukunftssicher gekapselt: Einladungs-Flow (Paket 4) als alternative Aktivierung
(Token statt Initialpasswort) später einhängbar.
tsc + lint + build + Guard-Check grün. Browser-E2E folgt zusammen mit Paket C.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ const ACTION_MODULE: Record<string, string> = {
|
||||
// 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",
|
||||
};
|
||||
|
||||
@@ -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<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { 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
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">Löschung/Datenexport (DSGVO) und Retention folgen in Phase 2.</p>
|
||||
</div>
|
||||
|
||||
{/* Nutzer */}
|
||||
{/* Nutzer-Kurzinfo */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Nutzer ({tenant.users.length})</p>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{tenant.users.map((u) => (
|
||||
<li key={u.id} className="flex items-center justify-between gap-2">
|
||||
<span>
|
||||
<span className="font-medium">{u.name}</span>
|
||||
<span className="block text-[11px] text-muted-foreground">{u.email}</span>
|
||||
</span>
|
||||
<Pill tone="mut">{u.status}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Benutzer</p>
|
||||
<p className="text-[12px] text-muted-foreground">{tenant.users.length} Nutzer · Verwaltung unten (anlegen, Rollen, deaktivieren, Passwort zurücksetzen).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benutzerverwaltung (Paket A) */}
|
||||
<div className="mt-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Benutzer & Rollen — {tenant.name}</p>
|
||||
<PlatformTenantUsers tenantId={tenant.id} users={managedUsers} roles={tenant.roles} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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({ tenantId, roles }: { tenantId: string; roles: RoleOption[] }) {
|
||||
const [state, action, pending] = useActionState(createTenantUser.bind(null, tenantId), createInit);
|
||||
return (
|
||||
<form action={action} className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="cu-name">Name *</Label>
|
||||
<Input id="cu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cu-email">E-Mail *</Label>
|
||||
<Input id="cu-email" name="email" type="email" required className="mt-1" placeholder="person@kunde.example" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Rollen *</Label>
|
||||
<div className="mt-1"><RoleChecks roles={roles} selected={new Set()} idPrefix="cu" /></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({ tenantId, userId }: { tenantId: string; userId: string }) {
|
||||
const [state, action, pending] = useActionState(resetTenantUserPassword.bind(null, tenantId, 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({ tenantId, user, roles }: { tenantId: string; 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}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<Pill tone={active ? "ok" : "mut"}>{active ? "Aktiv" : "Deaktiviert"}</Pill>
|
||||
</div>
|
||||
|
||||
<form action={setTenantUserRoles.bind(null, tenantId, 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={`u-${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={setTenantUserStatus.bind(null, tenantId, user.id, active ? "DEACTIVATED" : "ACTIVE")} className="inline">
|
||||
<Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button>
|
||||
</form>
|
||||
<ResetPasswordButton tenantId={tenantId} userId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformTenantUsers({ tenantId, users, roles }: { tenantId: string; 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 tenantId={tenantId} roles={roles} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{users.map((u) => (
|
||||
<UserRow key={u.id} tenantId={tenantId} user={u} roles={roles} />
|
||||
))}
|
||||
{users.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Benutzer in diesem Mandanten.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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 anlegen (Initial-/Einmal-Passwort, erzwungener Wechsel). */
|
||||
export async function createTenantUser(tenantId: string, _prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
|
||||
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<ResetPwState> {
|
||||
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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user