Benutzer bearbeiten (Name/E-Mail) + Kern-Einstellungen nur Superadmin
Nachschärfung der Benutzer-/Rollenverwaltung auf Basis des Feedbacks. - Nutzer editierbar: Name & E-Mail (eindeutig je Mandant) je Nutzer in beiden Konsolen — updateTenantUser (Plattform) / updateUser (Tenant), inline-Formular in platform-tenant-users.tsx und tenant-users-manager.tsx. Rollen/Status/Reset bestanden bereits (browser-verifiziert: Name/E-Mail, Rollen, Deaktivieren, Reset). - Kern-Einstellungen ausschließlich Superadmin: TISAX-/Schutzbedarf-Tiefe wandert in die Admin-Konsole (setTenantTisaxLevel, AL2/AL3 in /admin/[id]); aus den Tenant-Einstellungen entfernt (nur noch Read-only-Anzeige). updateTenantSettings fasst tisaxLevel/Schutzbedarf-Flags nicht mehr an. Module waren bereits Superadmin. - Tenant-Einstellungen: „Benutzer & Rollen"-Bereich verlinkt und sichtbar (user:manage/role:manage) — Verwaltung unter /settings/users. Browser-verifiziert: Admin-Konsole editiert Name/E-Mail/Rollen/Status/Passwort und schaltet TISAX AL3; /settings zeigt Benutzer-Bereich + TISAX read-only; /settings/users listet Nutzer mit Editierfeldern. tsc + lint + build + Guard grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
- 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`.
|
- **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).
|
- **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.
|
- **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)
|
## 11. Nächste sinnvolle Aufgaben (Einstiegspunkte)
|
||||||
|
|||||||
@@ -104,14 +104,11 @@ export default async function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schutzbedarf / TISAX */}
|
{/* Schutzbedarf / TISAX (Kern-Einstellung: nur Betreiber) */}
|
||||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
<p className="mb-3 font-heading text-sm font-semibold">Schutzbedarf / TISAX-Level</p>
|
<p className="mb-1 font-heading text-sm font-semibold">Schutzbedarf / TISAX-Tiefe</p>
|
||||||
<select name="tisaxLevel" defaultValue={s?.tisaxLevel ?? "AL2"} className={inputCls}>
|
<p className="text-sm">Aktuell: <span className="font-semibold">{s?.tisaxLevel ?? "AL2"}</span></p>
|
||||||
<option value="AL2">AL2 — MUSS · SOLL · HOCH</option>
|
<p className="mt-2 text-[11px] text-muted-foreground">Diese Kern-Einstellung wird vom Plattform-Betreiber gesteuert und ist hier nicht änderbar.</p>
|
||||||
<option value="AL3">AL3 — zusätzlich SEHR HOCH</option>
|
|
||||||
</select>
|
|
||||||
<p className="mt-2 text-[11px] text-muted-foreground">Steuert die Schutzbedarf-Flags des Richtlinienmoduls (global; Override je Richtlinie im Editor).</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full justify-center">Speichern</Button>
|
<Button type="submit" className="w-full justify-center">Speichern</Button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { prisma } from "@/server/db";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||||
import { MODULES } from "@/lib/modules";
|
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";
|
import { PlatformTenantUsers } from "@/components/platform-tenant-users";
|
||||||
|
|
||||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||||
@@ -93,6 +93,20 @@ 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>
|
<p className="mt-3 text-[11px] text-muted-foreground">Löschung/Datenexport (DSGVO) und Retention folgen in Phase 2.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kern-Einstellung: Schutzbedarf / TISAX-Tiefe (nur Superadmin) */}
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
|
<p className="mb-1 font-heading text-sm font-semibold">Schutzbedarf / TISAX-Tiefe</p>
|
||||||
|
<p className="mb-3 text-[12px] text-muted-foreground">Steuert die Schutzbedarf-Flags des Richtlinienmoduls. Aktuell: {tenant.settings?.tisaxLevel ?? "AL2"}.</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL2")}>
|
||||||
|
<Button type="submit" variant={(tenant.settings?.tisaxLevel ?? "AL2") === "AL2" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(tenant.settings?.tisaxLevel ?? "AL2") === "AL2"}>AL2 — MUSS · SOLL · HOCH</Button>
|
||||||
|
</form>
|
||||||
|
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL3")}>
|
||||||
|
<Button type="submit" variant={tenant.settings?.tisaxLevel === "AL3" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={tenant.settings?.tisaxLevel === "AL3"}>AL3 — zusätzlich SEHR HOCH</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nutzer-Kurzinfo */}
|
{/* Nutzer-Kurzinfo */}
|
||||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||||
<p className="mb-1 font-heading text-sm font-semibold">Benutzer</p>
|
<p className="mb-1 font-heading text-sm font-semibold">Benutzer</p>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import {
|
import {
|
||||||
createTenantUser, resetTenantUserPassword, setTenantUserStatus, setTenantUserRoles,
|
createTenantUser, updateTenantUser, resetTenantUserPassword, setTenantUserStatus, setTenantUserRoles,
|
||||||
type CreateUserState, type ResetPwState,
|
type CreateUserState, type ResetPwState, type EditUserState,
|
||||||
} from "@/server/actions/platform-users";
|
} from "@/server/actions/platform-users";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 createInit: CreateUserState = { status: "idle" };
|
||||||
const resetInit: ResetPwState = { status: "idle" };
|
const resetInit: ResetPwState = { status: "idle" };
|
||||||
|
const editInit: EditUserState = { status: "idle" };
|
||||||
|
|
||||||
function OneTimeSecret({ label, value }: { label: string; value: string }) {
|
function OneTimeSecret({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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 (
|
||||||
|
<form action={action} className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-40 flex-1">
|
||||||
|
<Label htmlFor={`en-${user.id}`} className="text-[11px] text-muted-foreground">Name</Label>
|
||||||
|
<Input id={`en-${user.id}`} name="name" defaultValue={user.name} className="mt-1 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-52 flex-1">
|
||||||
|
<Label htmlFor={`ee-${user.id}`} className="text-[11px] text-muted-foreground">E-Mail</Label>
|
||||||
|
<Input id={`ee-${user.id}`} name="email" type="email" defaultValue={user.email} className="mt-1 h-8" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Speichern"}</Button>
|
||||||
|
{state.status === "ok" && <span className="text-[11px] text-[var(--ok)]">Gespeichert</span>}
|
||||||
|
{state.status === "error" && <span className="text-[11px] text-[var(--risk)]">{state.message}</span>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserRow({ tenantId, user, roles }: { tenantId: string; user: ManagedUser; roles: RoleOption[] }) {
|
function UserRow({ tenantId, user, roles }: { tenantId: string; user: ManagedUser; roles: RoleOption[] }) {
|
||||||
const active = user.status === "ACTIVE";
|
const active = user.status === "ACTIVE";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4">
|
<div className="rounded-xl border bg-card p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="flex-1"><EditUserForm tenantId={tenantId} user={user} /></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>
|
<Pill tone={active ? "ok" : "mut"}>{active ? "Aktiv" : "Deaktiviert"}</Pill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import {
|
import {
|
||||||
createUser, resetUserPassword, setUserStatus, setUserRoles,
|
createUser, updateUser, resetUserPassword, setUserStatus, setUserRoles,
|
||||||
type CreateUserState, type ResetPwState,
|
type CreateUserState, type ResetPwState, type EditUserState,
|
||||||
} from "@/server/actions/tenant-users";
|
} from "@/server/actions/tenant-users";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 createInit: CreateUserState = { status: "idle" };
|
||||||
const resetInit: ResetPwState = { status: "idle" };
|
const resetInit: ResetPwState = { status: "idle" };
|
||||||
|
const editInit: EditUserState = { status: "idle" };
|
||||||
|
|
||||||
function OneTimeSecret({ label, value }: { label: string; value: string }) {
|
function OneTimeSecret({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
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 (
|
||||||
|
<form action={action} className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-40 flex-1">
|
||||||
|
<Label htmlFor={`ten-${user.id}`} className="text-[11px] text-muted-foreground">Name {user.isSelf && <span>(Sie)</span>}</Label>
|
||||||
|
<Input id={`ten-${user.id}`} name="name" defaultValue={user.name} className="mt-1 h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-52 flex-1">
|
||||||
|
<Label htmlFor={`tee-${user.id}`} className="text-[11px] text-muted-foreground">E-Mail</Label>
|
||||||
|
<Input id={`tee-${user.id}`} name="email" type="email" defaultValue={user.email} className="mt-1 h-8" />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "…" : "Speichern"}</Button>
|
||||||
|
{state.status === "ok" && <span className="text-[11px] text-[var(--ok)]">Gespeichert</span>}
|
||||||
|
{state.status === "error" && <span className="text-[11px] text-[var(--risk)]">{state.message}</span>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserRow({ user, roles }: { user: ManagedUser; roles: RoleOption[] }) {
|
function UserRow({ user, roles }: { user: ManagedUser; roles: RoleOption[] }) {
|
||||||
const active = user.status === "ACTIVE";
|
const active = user.status === "ACTIVE";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card p-4">
|
<div className="rounded-xl border bg-card p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="flex-1"><EditUserForm user={user} /></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>
|
<Pill tone={active ? "ok" : "mut"}>{active ? "Aktiv" : "Deaktiviert"}</Pill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { platformAuth } from "@/server/platform-auth";
|
import { platformAuth } from "@/server/platform-auth";
|
||||||
import { prisma } from "@/server/db";
|
import { prisma, dbForTenant } from "@/server/db";
|
||||||
import { provisionTenant } from "@/server/provision";
|
import { provisionTenant } from "@/server/provision";
|
||||||
|
|
||||||
/** Nur Plattform-Admins über die getrennte Plattform-Session (kein Mandantenkontext). */
|
/** 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}`);
|
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) {
|
export async function toggleTenantModule(tenantId: string, moduleKey: string, enabled: boolean) {
|
||||||
const session = await requirePlatformAdmin();
|
const session = await requirePlatformAdmin();
|
||||||
await prisma.tenantModule.upsert({
|
await prisma.tenantModule.upsert({
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export type ResetPwState =
|
|||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
| { status: "done"; generatedPassword: 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). */
|
/** Zählt aktive Nutzer mit Mandanten-Admin-Rolle (Lockout-Schutz). */
|
||||||
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
|
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
|
||||||
return db.user.count({
|
return db.user.count({
|
||||||
@@ -90,6 +95,27 @@ export async function createTenantUser(tenantId: string, _prev: CreateUserState,
|
|||||||
return { status: "done", email: email.data, generatedPassword: generated };
|
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<EditUserState> {
|
||||||
|
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. */
|
/** Passwort zurücksetzen: neues Einmal-Passwort + erzwungener Wechsel. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Signatur von useActionState erzwungen
|
// 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> {
|
export async function resetTenantUserPassword(tenantId: string, userId: string, _prev: ResetPwState, _formData: FormData): Promise<ResetPwState> {
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export async function updateTenantSettings(formData: FormData) {
|
|||||||
const db = dbForTenant(tenantId);
|
const db = dbForTenant(tenantId);
|
||||||
|
|
||||||
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
|
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 = {
|
const data = {
|
||||||
orgName,
|
orgName,
|
||||||
orgShort: str(formData.get("orgShort")) || null,
|
orgShort: str(formData.get("orgShort")) || null,
|
||||||
@@ -35,7 +36,6 @@ export async function updateTenantSettings(formData: FormData) {
|
|||||||
accent: str(formData.get("accent")) || null,
|
accent: str(formData.get("accent")) || null,
|
||||||
locale: str(formData.get("locale")) || "de",
|
locale: str(formData.get("locale")) || "de",
|
||||||
timezone: str(formData.get("timezone")) || "Europe/Berlin",
|
timezone: str(formData.get("timezone")) || "Europe/Berlin",
|
||||||
tisaxLevel,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.tenantSettings.upsert({
|
await db.tenantSettings.upsert({
|
||||||
@@ -46,11 +46,8 @@ export async function updateTenantSettings(formData: FormData) {
|
|||||||
|
|
||||||
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
|
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
|
||||||
await syncPolicyVariablesFromSettings(prisma, tenantId, data);
|
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("/settings");
|
||||||
revalidatePath("/policies");
|
revalidatePath("/policies");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export type ResetPwState =
|
|||||||
| { status: "error"; message: string }
|
| { status: "error"; message: string }
|
||||||
| { status: "done"; generatedPassword: string };
|
| { status: "done"; generatedPassword: string };
|
||||||
|
|
||||||
|
export type EditUserState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "error"; message: string }
|
||||||
|
| { status: "ok" };
|
||||||
|
|
||||||
async function ctx(permission: "user:manage" | "role:manage") {
|
async function ctx(permission: "user:manage" | "role:manage") {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, permission);
|
requirePermission(session, permission);
|
||||||
@@ -88,6 +93,26 @@ export async function createUser(_prev: CreateUserState, formData: FormData): Pr
|
|||||||
return { status: "done", email: email.data, generatedPassword: generated };
|
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<EditUserState> {
|
||||||
|
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
|
// 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> {
|
export async function resetUserPassword(userId: string, _prev: ResetPwState, _formData: FormData): Promise<ResetPwState> {
|
||||||
const { session, tenantId, db } = await ctx("user:manage");
|
const { session, tenantId, db } = await ctx("user:manage");
|
||||||
|
|||||||
Reference in New Issue
Block a user