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:
@@ -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