diff --git a/docs/HANDOVER-DEV.md b/docs/HANDOVER-DEV.md index b3f57da..7c8d43a 100644 --- a/docs/HANDOVER-DEV.md +++ b/docs/HANDOVER-DEV.md @@ -177,6 +177,8 @@ Aufbauend auf dem Fundament wurden mehrere Härtungspakete umgesetzt (feingranul - Mandanten-Admin intern: `src/server/actions/tenant-users.ts` + `components/tenant-users-manager.tsx`/`role-manager.tsx`, Seite `/settings/users` (nur `user:manage`/`role:manage`). Benutzer-CRUD **und** Rollen-CRUD (eigene Rollen + Permissions; Standardrollen schreibgeschützt/klonbar). Strikt `dbForTenant(session)` → kein Cross-Tenant. **Lockout-Schutz** für den letzten aktiven Mandanten-Admin. - **Force-Change:** neue Nutzer starten mit `mustChangePassword=true`; das `(app)`-Layout leitet autoritativ (DB) auf `/change-password` und sperrt deaktivierte Konten. Passwort-Policy: `src/lib/password-policy.ts` (Validierung, client-safe) + `src/server/password.ts` (Argon2id-Hash + Generator); Quelle `TenantSettings.securityPolicy.password`. - **Nutzer-MFA optional:** Tenant-Login (`auth.ts`) verlangt TOTP nur bei eingerichteter MFA; Self-Service unter `/account`. `role:manage` ist neu im RBAC-Katalog (Mandanten-Admin). + - **Bearbeiten:** je Nutzer sind **Name & E-Mail** (E-Mail eindeutig je Mandant), Rollen, Aktiv/Deaktiviert und Passwort-Reset editierbar — in beiden Konsolen. +- **Zuständigkeit Einstellungen (Superadmin vs. Tenant-Admin):** **Kern-Einstellungen** (aktive Module, TISAX-/Schutzbedarf-**Tiefe**, Richtlinienpaket) steuert ausschließlich der **Superadmin** in der Admin-Konsole (`/admin/[id]`, u. a. `setTenantTisaxLevel`). Der **Tenant-Admin** pflegt in `/settings` nur seinen Bereich (Stammdaten/Branding → ISMS-Variablen) + Benutzer/Rollen; die TISAX-Tiefe ist dort nur noch als Read-only-Anzeige. - **Zukunftssicher:** `createTenantUser`/`createUser` kapseln die Aktivierung über Initial-/Einmal-Passwort — der spätere **E-Mail-Einladungs-Flow (Paket 4)** lässt sich als alternative Aktivierung (Token statt Passwort) einhängen, ohne die UI umzubauen. ## 11. Nächste sinnvolle Aufgaben (Einstiegspunkte) diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index a3ff4fc..2523170 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -104,14 +104,11 @@ export default async function SettingsPage() { - {/* Schutzbedarf / TISAX */} + {/* Schutzbedarf / TISAX (Kern-Einstellung: nur Betreiber) */}
-

Schutzbedarf / TISAX-Level

- -

Steuert die Schutzbedarf-Flags des Richtlinienmoduls (global; Override je Richtlinie im Editor).

+

Schutzbedarf / TISAX-Tiefe

+

Aktuell: {s?.tisaxLevel ?? "AL2"}

+

Diese Kern-Einstellung wird vom Plattform-Betreiber gesteuert und ist hier nicht änderbar.

diff --git a/src/app/(platform)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx index 7802f76..161c708 100644 --- a/src/app/(platform)/admin/[id]/page.tsx +++ b/src/app/(platform)/admin/[id]/page.tsx @@ -5,7 +5,7 @@ import { prisma } from "@/server/db"; 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 { setTenantStatus, toggleTenantModule, setTenantTisaxLevel } from "@/server/actions/admin"; import { PlatformTenantUsers } from "@/components/platform-tenant-users"; const STATUS_TONE: Record = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; @@ -93,6 +93,20 @@ export default async function AdminTenantPage({ params }: { params: Promise<{ id

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

+ {/* Kern-Einstellung: Schutzbedarf / TISAX-Tiefe (nur Superadmin) */} +
+

Schutzbedarf / TISAX-Tiefe

+

Steuert die Schutzbedarf-Flags des Richtlinienmoduls. Aktuell: {tenant.settings?.tisaxLevel ?? "AL2"}.

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

Benutzer

diff --git a/src/components/platform-tenant-users.tsx b/src/components/platform-tenant-users.tsx index 005b74b..2d8f0cf 100644 --- a/src/components/platform-tenant-users.tsx +++ b/src/components/platform-tenant-users.tsx @@ -2,8 +2,8 @@ import { useActionState } from "react"; import { - createTenantUser, resetTenantUserPassword, setTenantUserStatus, setTenantUserRoles, - type CreateUserState, type ResetPwState, + createTenantUser, updateTenantUser, resetTenantUserPassword, setTenantUserStatus, setTenantUserRoles, + type CreateUserState, type ResetPwState, type EditUserState, } from "@/server/actions/platform-users"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,6 +15,7 @@ export interface ManagedUser { id: string; name: string; email: string; status: const createInit: CreateUserState = { status: "idle" }; const resetInit: ResetPwState = { status: "idle" }; +const editInit: EditUserState = { status: "idle" }; function OneTimeSecret({ label, value }: { label: string; value: string }) { return ( @@ -88,15 +89,31 @@ function ResetPasswordButton({ tenantId, userId }: { tenantId: string; userId: s ); } +function EditUserForm({ tenantId, user }: { tenantId: string; user: ManagedUser }) { + const [state, action, pending] = useActionState(updateTenantUser.bind(null, tenantId, user.id), editInit); + return ( +
+
+ + +
+
+ + +
+ + {state.status === "ok" && Gespeichert} + {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"}
diff --git a/src/components/tenant-users-manager.tsx b/src/components/tenant-users-manager.tsx index a29e9fb..8de41cb 100644 --- a/src/components/tenant-users-manager.tsx +++ b/src/components/tenant-users-manager.tsx @@ -2,8 +2,8 @@ import { useActionState } from "react"; import { - createUser, resetUserPassword, setUserStatus, setUserRoles, - type CreateUserState, type ResetPwState, + createUser, updateUser, resetUserPassword, setUserStatus, setUserRoles, + type CreateUserState, type ResetPwState, type EditUserState, } from "@/server/actions/tenant-users"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,6 +15,7 @@ export interface ManagedUser { id: string; name: string; email: string; status: const createInit: CreateUserState = { status: "idle" }; const resetInit: ResetPwState = { status: "idle" }; +const editInit: EditUserState = { status: "idle" }; function OneTimeSecret({ label, value }: { label: string; value: string }) { return ( @@ -86,15 +87,31 @@ function ResetPasswordButton({ userId }: { userId: string }) { ); } +function EditUserForm({ user }: { user: ManagedUser }) { + const [state, action, pending] = useActionState(updateUser.bind(null, user.id), editInit); + return ( +
+
+ + +
+
+ + +
+ + {state.status === "ok" && Gespeichert} + {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"}
diff --git a/src/server/actions/admin.ts b/src/server/actions/admin.ts index 58f5ebe..dfb0946 100644 --- a/src/server/actions/admin.ts +++ b/src/server/actions/admin.ts @@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; import { platformAuth } from "@/server/platform-auth"; -import { prisma } from "@/server/db"; +import { prisma, dbForTenant } from "@/server/db"; import { provisionTenant } from "@/server/provision"; /** Nur Plattform-Admins über die getrennte Plattform-Session (kein Mandantenkontext). */ @@ -57,6 +57,20 @@ export async function setTenantStatus(tenantId: string, status: "ACTIVE" | "SUSP revalidatePath(`/admin/${tenantId}`); } +/** Kern-Einstellung (nur Superadmin): TISAX-/Schutzbedarf-Tiefe des Mandanten. */ +export async function setTenantTisaxLevel(tenantId: string, level: "AL2" | "AL3") { + const session = await requirePlatformAdmin(); + const db = dbForTenant(tenantId); + await db.tenantSettings.update({ where: { tenantId }, data: { tisaxLevel: level } }); + // Schutzbedarf-Flags des Richtlinienmoduls: HIGH stets an, VERY_HIGH nur bei AL3. + await db.policyVariable.updateMany({ where: { key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } }); + await db.policyVariable.updateMany({ where: { key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: level === "AL3" ? "true" : "false" } }); + await prisma.auditLog.create({ + data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tisax_level", entityId: tenantId, after: { level } }, + }); + revalidatePath(`/admin/${tenantId}`); +} + export async function toggleTenantModule(tenantId: string, moduleKey: string, enabled: boolean) { const session = await requirePlatformAdmin(); await prisma.tenantModule.upsert({ diff --git a/src/server/actions/platform-users.ts b/src/server/actions/platform-users.ts index d778718..f60516c 100644 --- a/src/server/actions/platform-users.ts +++ b/src/server/actions/platform-users.ts @@ -29,6 +29,11 @@ export type ResetPwState = | { status: "error"; message: string } | { status: "done"; generatedPassword: string }; +export type EditUserState = + | { status: "idle" } + | { status: "error"; message: string } + | { status: "ok" }; + /** Zählt aktive Nutzer mit Mandanten-Admin-Rolle (Lockout-Schutz). */ async function countActiveTenantAdmins(db: TenantDb): Promise { return db.user.count({ @@ -90,6 +95,27 @@ export async function createTenantUser(tenantId: string, _prev: CreateUserState, return { status: "done", email: email.data, generatedPassword: generated }; } +/** Stammdaten (Name, E-Mail) eines Nutzers ändern; E-Mail bleibt je Mandant eindeutig. */ +export async function updateTenantUser(tenantId: string, userId: string, _prev: EditUserState, 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 name = String(formData.get("name") ?? "").trim(); + const email = emailSchema.safeParse(String(formData.get("email") ?? "").trim().toLowerCase()); + if (!name) return { status: "error", message: "Bitte einen Namen angeben." }; + if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." }; + if (email.data !== user.email && (await db.user.findFirst({ where: { email: email.data, id: { not: userId } } }))) { + return { status: "error", message: "Diese E-Mail ist in diesem Mandanten bereits vergeben." }; + } + + await db.user.update({ where: { id: userId }, data: { name, email: email.data } }); + await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { tenantId, name, email: email.data } }); + revalidatePath(`/admin/${tenantId}`); + return { status: "ok" }; +} + /** 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 { diff --git a/src/server/actions/tenant-settings.ts b/src/server/actions/tenant-settings.ts index d625e06..c1f0441 100644 --- a/src/server/actions/tenant-settings.ts +++ b/src/server/actions/tenant-settings.ts @@ -18,8 +18,9 @@ export async function updateTenantSettings(formData: FormData) { const db = dbForTenant(tenantId); const orgName = z.string().trim().min(1).parse(formData.get("orgName")); - const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2"; + // TISAX-/Schutzbedarf-Tiefe ist eine Kern-Einstellung und wird ausschließlich vom + // Superadmin (Admin-Konsole) gesteuert — hier bewusst nicht anfassen. const data = { orgName, orgShort: str(formData.get("orgShort")) || null, @@ -35,7 +36,6 @@ export async function updateTenantSettings(formData: FormData) { accent: str(formData.get("accent")) || null, locale: str(formData.get("locale")) || "de", timezone: str(formData.get("timezone")) || "Europe/Berlin", - tisaxLevel, }; await db.tenantSettings.upsert({ @@ -46,11 +46,8 @@ export async function updateTenantSettings(formData: FormData) { // Stammdaten → ISMS-Template-Variablen (eine Pflegestelle) await syncPolicyVariablesFromSettings(prisma, tenantId, data); - // TISAX-Level steuert die Schutzbedarf-Flags des Richtlinienmoduls - await prisma.policyVariable.updateMany({ where: { tenantId, key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } }); - await prisma.policyVariable.updateMany({ where: { tenantId, key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: tisaxLevel === "AL3" ? "true" : "false" } }); - await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName, tisaxLevel } }); + await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName } }); revalidatePath("/settings"); revalidatePath("/policies"); } diff --git a/src/server/actions/tenant-users.ts b/src/server/actions/tenant-users.ts index 4443581..e862d09 100644 --- a/src/server/actions/tenant-users.ts +++ b/src/server/actions/tenant-users.ts @@ -30,6 +30,11 @@ export type ResetPwState = | { status: "error"; message: string } | { status: "done"; generatedPassword: string }; +export type EditUserState = + | { status: "idle" } + | { status: "error"; message: string } + | { status: "ok" }; + async function ctx(permission: "user:manage" | "role:manage") { const session = await requireSession(); requirePermission(session, permission); @@ -88,6 +93,26 @@ export async function createUser(_prev: CreateUserState, formData: FormData): Pr return { status: "done", email: email.data, generatedPassword: generated }; } +/** Stammdaten (Name, E-Mail) eines Nutzers ändern; E-Mail bleibt je Mandant eindeutig. */ +export async function updateUser(userId: string, _prev: EditUserState, 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 name = String(formData.get("name") ?? "").trim(); + const email = emailSchema.safeParse(String(formData.get("email") ?? "").trim().toLowerCase()); + if (!name) return { status: "error", message: "Bitte einen Namen angeben." }; + if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." }; + if (email.data !== user.email && (await db.user.findFirst({ where: { email: email.data, id: { not: userId } } }))) { + return { status: "error", message: "Diese E-Mail ist bereits vergeben." }; + } + + await db.user.update({ where: { id: userId }, data: { name, email: email.data } }); + await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { name, email: email.data } }); + revalidatePath("/settings/users"); + return { status: "ok" }; +} + // 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");