Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
@@ -0,0 +1,117 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PageHead, Pill } from "@/components/mockup-ui";
import { updateIncidentIntake } from "@/server/actions/incident-intake";
import { intakeAddress } from "@/server/incident-inbound/parse";
import { Siren, ArrowLeft } from "lucide-react";
/**
* IM-D — Mandantenseitige Ansicht/Pflege des E-Mail-Eingangs für Vorfälle.
* Zeigt die (read-only) Intake-Adresse und lässt den Kunden-Admin Allowlist-Domänen,
* Quelladresse und Benachrichtigungsempfänger pflegen. Sichtbar nur bei aktivem Modul
* „Vorfälle" und `tenant:manage`.
*/
export default async function IncidentIntakePage() {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const db = dbForTenant(session.user.tenantId);
const [moduleRow, config] = await Promise.all([
db.tenantModule.findFirst({ where: { moduleKey: "incidents" }, select: { enabled: true } }),
db.incidentIntakeConfig.findUnique({ where: { tenantId: session.user.tenantId } }),
]);
// Modul deaktiviert (oder nie provisioniert + aus) → zurück zu den Einstellungen.
if (moduleRow && !moduleRow.enabled) redirect("/settings");
const address = config ? intakeAddress(config.token) : null;
const verified = config?.status === "verifiziert";
return (
<main className="flex-1 p-6">
<Link href="/settings" className="mb-3 inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-3.5" /> Einstellungen
</Link>
<PageHead
crumb="Vorfälle"
title="E-Mail-Eingang (Vorfälle)"
sub="Vorfälle können per E-Mail gemeldet werden — richten Sie dazu eine Weiterleitung an Ihre Intake-Adresse ein."
/>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="shadow-card space-y-5 rounded-xl border bg-card p-5">
<div className="space-y-1.5">
<Label>Ihre Intake-Adresse</Label>
{address ? (
<div className="flex items-center gap-2">
<code className="rounded-md border bg-muted px-2.5 py-1.5 text-[13px] font-mono select-all">{address}</code>
{verified ? <Pill tone="ok">verifiziert</Pill> : <Pill tone="warn">Weiterleitung ausstehend</Pill>}
</div>
) : (
<p className="text-[13px] text-muted-foreground">
Wird beim ersten Speichern erzeugt (oder vom Betreiber beim Onboarding bereitgestellt).
</p>
)}
<p className="text-[12px] text-muted-foreground">
Leiten Sie Meldungen von einer der unten hinterlegten Absender-Domänen an diese Adresse weiter.
Aus jeder eingehenden Mail wird automatisch ein Vorfall (Status „neu“) erstellt.
</p>
</div>
<form action={updateIncidentIntake} className="space-y-4 border-t pt-4">
<div className="space-y-1.5">
<Label htmlFor="allowlistDomains">Erlaubte Absender-Domänen</Label>
<Textarea
id="allowlistDomains"
name="allowlistDomains"
rows={3}
placeholder={"kunde.de\nit.kunde.de"}
defaultValue={(config?.allowlistDomains ?? []).join("\n")}
/>
<p className="text-[11.5px] text-muted-foreground">
Eine Domäne je Zeile. Nur Mails von diesen Domänen (mit gültiger DKIM-Signatur) werden
automatisch zu Vorfällen — alles andere geht in die Betreiber-Prüfung. Pflichtangabe für den Automatikbetrieb.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="sourceAddress">Quelladresse (optional)</Label>
<Input id="sourceAddress" name="sourceAddress" type="email" placeholder="vorfall@kunde.de" defaultValue={config?.sourceAddress ?? ""} />
<p className="text-[11.5px] text-muted-foreground">Die konkrete Adresse, von der weitergeleitet wird — zur Dokumentation.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="notifyEmail">Benachrichtigung an (optional)</Label>
<Input id="notifyEmail" name="notifyEmail" type="email" placeholder="isb@kunde.de" defaultValue={config?.notifyEmail ?? ""} />
</div>
<div className="flex justify-end">
<Button type="submit" size="sm">Speichern</Button>
</div>
</form>
</div>
<aside className="shadow-card h-fit space-y-3 rounded-xl border bg-card p-5 text-[12.5px] text-muted-foreground">
<div className="flex items-center gap-2 text-foreground">
<Siren className="size-4" /> <span className="font-semibold">So richten Sie es ein</span>
</div>
<ol className="list-decimal space-y-1.5 pl-4">
<li>Absender-Domäne(n) eintragen und speichern.</li>
<li>In Ihrem Mailsystem eine Weiterleitung auf die Intake-Adresse einrichten.</li>
<li>Eine Test-Mail senden — sie erscheint als Vorfall, der Status wechselt auf „verifiziert“.</li>
</ol>
<p className="border-t pt-3">
Die Weiterleitung bricht technisch SPF — das ist erwartet. Das Vertrauen entsteht aus Ihrer
Domänen-Allowlist und der DKIM-Signatur Ihres Mailsystems.
</p>
</aside>
</div>
</main>
);
}
+226
View File
@@ -0,0 +1,226 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules";
import { BRAND } from "@/lib/brand";
import { updateTenantSettings } from "@/server/actions/tenant-settings";
import { SlidersHorizontal, ShieldCheck, History, Siren } from "lucide-react";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
export default async function SettingsPage({
searchParams,
}: {
searchParams: Promise<{ audit?: string }>;
}) {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const db = dbForTenant(session.user.tenantId);
const sp = await searchParams;
const [s, moduleRows] = await Promise.all([
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
db.tenantModule.findMany(),
]);
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
// Client filtert AuditLog automatisch auf den eigenen Mandanten (TENANT_MODELS/RLS).
let auditRows: AuditRow[] = [];
let auditActors: Record<string, string> = {};
if (sp.audit) {
auditRows = await db.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 200,
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
});
const ids = [...new Set(auditRows.map((r) => r.actorId).filter(Boolean))] as string[];
if (ids.length) {
const users = await db.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } });
auditActors = Object.fromEntries(users.map((u) => [u.id, u.name]));
}
}
const enabled = new Set(moduleRows.filter((m) => m.enabled).map((m) => m.moduleKey));
const v = (x?: string | null) => x ?? "";
return (
<main className="flex-1 p-6">
<PageHead crumb="Organisation" title="Einstellungen" sub="Unternehmensdaten, Branding, Sicherheits-Policy — Stammdaten speisen die ISMS-Variablen" />
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && (
<Link href="/settings/users" className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
<div>
<p className="font-heading text-sm font-semibold">Benutzer &amp; Rollen</p>
<p className="text-[12px] text-muted-foreground">Benutzer anlegen/verwalten, Rollen und Berechtigungen pflegen (mandantenintern).</p>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen →</span>
</Link>
)}
<Link href="/settings/risk-criteria" className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-3">
<SlidersHorizontal className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" />
<div>
<p className="font-heading text-sm font-semibold">Risikoakzeptanz &amp; Bewertungsskala</p>
<p className="text-[12px] text-muted-foreground">Risikoklassen, Freigabeinstanzen, Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) pflegen.</p>
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen →</span>
</Link>
{/* IM-D: E-Mail-Eingang für Vorfälle — nur bei aktivem Modul „Vorfälle". */}
{enabled.has("incidents") && (
<Link href="/settings/incident-intake" className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-3">
<Siren className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" />
<div>
<p className="font-heading text-sm font-semibold">E-Mail-Eingang (Vorfälle)</p>
<p className="text-[12px] text-muted-foreground">Intake-Adresse, erlaubte Absender-Domänen und Weiterleitung für gemeldete Vorfälle.</p>
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen →</span>
</Link>
)}
{/* Audit-Trail (Aktivitätsprotokoll) — Popup wie gehabt (?audit=1) */}
<Link href="/settings?audit=1" scroll={false} className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-3">
<History className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" />
<div>
<p className="font-heading text-sm font-semibold">Audit-Trail</p>
<p className="text-[12px] text-muted-foreground">Nachvollziehbare Aktivitäten dieses Mandanten (wer hat wann was geändert).</p>
</div>
</div>
<span className="text-[13px] font-semibold text-[var(--primary)]">Einsehen →</span>
</Link>
</div>
{/* Plattform-Administration (getrennte Anmeldung — nur für Betreiber-Accounts) */}
<Link href="/platform/login" className="shadow-card mt-3 flex items-center justify-between rounded-xl border border-dashed bg-card p-5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-3">
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-heading text-sm font-semibold">Plattform-Administration</p>
<p className="text-[12px] text-muted-foreground">Übergreifendes Betreiber-Portal (Mandanten, Modul-Freischaltung, Plattform-Admins). Eigene Anmeldung erforderlich.</p>
</div>
</div>
<span className="text-[13px] font-semibold text-muted-foreground">Zum Portal →</span>
</Link>
<form action={updateTenantSettings} className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
<div className="space-y-5">
{/* Unternehmensdaten */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">Unternehmensdaten</p>
<p className="mb-3 text-[12px] text-muted-foreground">Diese Werte füttern automatisch die Template-Variablen des Richtlinienmoduls (eine Pflegestelle).</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="orgName">Unternehmensname *</Label>
<Input id="orgName" name="orgName" required defaultValue={v(s?.orgName)} className="mt-1" />
</div>
<div>
<Label htmlFor="orgShort">Kurzname</Label>
<Input id="orgShort" name="orgShort" defaultValue={v(s?.orgShort)} className="mt-1" />
</div>
<div>
<Label htmlFor="sector">Sektor</Label>
<Input id="sector" name="sector" defaultValue={v(s?.sector)} className="mt-1" />
</div>
<div>
<Label htmlFor="address">Adresse</Label>
<Input id="address" name="address" defaultValue={v(s?.address)} className="mt-1" />
</div>
<div>
<Label htmlFor="duns">D-U-N-S</Label>
<Input id="duns" name="duns" defaultValue={v(s?.duns)} className="mt-1" />
</div>
<div>
<Label htmlFor="ismsScope">ISMS-Geltungsbereich (Kurz)</Label>
<Input id="ismsScope" name="ismsScope" defaultValue={v(s?.ismsScope)} className="mt-1" />
</div>
<div>
<Label htmlFor="ismsScopeDescription">Geltungsbereich (Beschreibung)</Label>
<Textarea id="ismsScopeDescription" name="ismsScopeDescription" rows={2} defaultValue={v(s?.ismsScopeDescription)} className="mt-1" />
</div>
<div>
<Label htmlFor="nis2Category">NIS2-Betroffenheit</Label>
<select id="nis2Category" name="nis2Category" defaultValue={s?.nis2Category ?? "keine"} className={`${inputCls} mt-1`}>
<option value="keine">Keine</option>
<option value="wichtig">Wichtige Einrichtung</option>
<option value="wesentlich">Wesentliche Einrichtung</option>
</select>
<p className="mt-1 text-[11px] text-muted-foreground">Steuert die NIS2-Meldefristen im Vorfall-Modul (Timer folgen).</p>
</div>
</div>
</div>
{/* Verantwortliche Rollen */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">Verantwortliche Rollen (→ ISMS-Variablen)</p>
<div className="grid gap-4 md:grid-cols-2">
<div><Label htmlFor="roleManagement">Oberste Leitung (ROLE_MANAGEMENT)</Label><Input id="roleManagement" name="roleManagement" defaultValue={v(s?.roleManagement)} className="mt-1" /></div>
<div><Label htmlFor="roleIsb">ISB / CISO (ROLE_ISB)</Label><Input id="roleIsb" name="roleIsb" defaultValue={v(s?.roleIsb)} className="mt-1" /></div>
<div><Label htmlFor="roleItLead">IT-Leitung (ROLE_IT_LEAD)</Label><Input id="roleItLead" name="roleItLead" defaultValue={v(s?.roleItLead)} className="mt-1" /></div>
<div><Label htmlFor="roleDpo">Datenschutz (ROLE_DPO)</Label><Input id="roleDpo" name="roleDpo" defaultValue={v(s?.roleDpo)} className="mt-1" /></div>
</div>
</div>
</div>
<div className="space-y-5">
{/* Branding & Regionales */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">Branding & Regionales</p>
<p className="mb-3 text-[12px] text-muted-foreground">
Standard-Branding ist <span className="font-semibold">{BRAND.name}</span>. Eigene Werte
überschreiben nur, was hier gesetzt ist.
</p>
<div className="space-y-3">
<div><Label htmlFor="accent">Akzentfarbe (Hex)</Label><Input id="accent" name="accent" defaultValue={v(s?.accent)} placeholder="#7d6fd6" className="mt-1" /></div>
<div>
<Label htmlFor="locale">Sprache</Label>
<select id="locale" name="locale" defaultValue={s?.locale ?? "de"} className={`${inputCls} mt-1`}><option value="de">Deutsch</option><option value="en">English</option></select>
</div>
<div><Label htmlFor="timezone">Zeitzone</Label><Input id="timezone" name="timezone" defaultValue={s?.timezone ?? "Europe/Berlin"} className="mt-1" /></div>
<p className="text-[11px] text-muted-foreground">
Logo-Upload (PNG/SVG) folgt in Phase 2 (Objektspeicher). Bis dahin — und ohne
eigenes Logo auch danach — zeigt die Anwendung das {BRAND.name}-Logo.
</p>
</div>
</div>
{/* Assessment-Level / Schutzbedarf (Kern-Einstellung: nur Betreiber) */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-1 font-heading text-sm font-semibold">Assessment-Level (Schutzbedarf)</p>
<p className="text-sm">Aktuell: <span className="font-semibold">{s?.tisaxLevel ?? "AL2"}</span> — {(s?.tisaxLevel ?? "AL2") === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"}</p>
<p className="mt-2 text-[11px] text-muted-foreground">Einzige Quelle des Schutzbedarfs. Diese Kern-Einstellung wird ausschließlich vom Plattform-Betreiber im Admin-Portal gesteuert und ist hier (sowie im Onboarding-Wizard) nicht änderbar.</p>
</div>
<Button type="submit" className="w-full justify-center">Speichern</Button>
</div>
</form>
{/* Module (Übersicht, Freischaltung durch Plattform-Admin) */}
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">Freigeschaltete Module</p>
<div className="flex flex-wrap gap-2">
{MODULES.map((m) => (
<Pill key={m.key} tone={enabled.has(m.key) ? "ok" : "mut"}>{m.name}</Pill>
))}
</div>
<p className="mt-3 text-[11px] text-muted-foreground">Die Modul-Freischaltung erfolgt durch den Plattform-Betreiber.</p>
</div>
{sp.audit && (
<AuditTrailModal rows={auditRows} actorNames={auditActors} closeHref="/settings" sub="Aktivitäten dieses Mandanten (neueste zuerst, letzte 200)" />
)}
</main>
);
}
@@ -0,0 +1,72 @@
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 } from "@/server/rbac";
import { PageHead } from "@/components/mockup-ui";
import { CriteriaEditor, type Tone } from "@/app/(app)/onboarding/steps/criteria/criteria-editor";
/**
* Risikoakzeptanzkriterien & Bewertungsskala (aus den Einstellungen ausgelagerte
* Unterseite). Zentrale Pflegestelle der Risikoklassen (Schwellen & Freigabeinstanzen),
* der Eintrittswahrscheinlichkeits-Skala und der Schadensdimensionen (C/I/A) — dieselben
* Werte werden im Onboarding-Wizard (Schritt „Kriterien“) gespiegelt und bearbeitet.
*/
export default async function RiskCriteriaPage() {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const db = dbForTenant(session.user.tenantId);
const [matrixClasses, ewLevels, damageDims] = await Promise.all([
db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
]);
// Serialisierbare Editor-Daten (identisch zum Wizard-Popup — eine Pflegestelle).
const dimLvl = (val: unknown, k: string) => {
const rec = (val ?? {}) as Record<string, unknown>;
return typeof rec[k] === "string" ? (rec[k] as string) : "";
};
const editorMatrix = matrixClasses.map((c) => ({
name: c.name,
maxScore: c.maxScore,
acceptance: c.acceptance,
tone: (["ok", "warn", "orange", "risk"].includes(c.tone) ? c.tone : "warn") as Tone,
}));
const editorEw = ewLevels.map((e) => ({ level: e.level, label: e.label, definition: e.definition }));
const editorDims = damageDims.map((d) => ({
name: d.name,
levels: {
"1": dimLvl(d.levels, "1"),
"2": dimLvl(d.levels, "2"),
"3": dimLvl(d.levels, "3"),
"4": dimLvl(d.levels, "4"),
"5": dimLvl(d.levels, "5"),
},
}));
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="Risikoakzeptanz & Bewertungsskala"
sub="Risikoklassen (Schwellen & Freigabeinstanzen), Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) — gespiegelt im Onboarding-Wizard."
/>
</div>
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
<CriteriaEditor
initialMatrixClasses={editorMatrix}
initialEwLevels={editorEw}
initialDamageDims={editorDims}
/>
</div>
</main>
);
}
+110
View File
@@ -0,0 +1,110 @@
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 { createUser, updateUser, setUserRoles, setUserStatus } from "@/server/actions/tenant-users";
import { PageHead } from "@/components/mockup-ui";
import { Modal } from "@/components/modal";
import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { RoleManager, CreateRoleForm } 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).
* Benutzer werden über Popups (?new/?edit) angelegt/bearbeitet, die Tabelle zeigt nur.
*/
export default async function UsersRolesPage({
searchParams,
}: {
searchParams: Promise<{ new?: string; edit?: string; newRole?: string }>;
}) {
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 sp = await searchParams;
const [users, roles] = await Promise.all([
db.user.findMany({
select: { id: true, name: true, email: true, status: true, userRoles: { select: { role: { select: { id: true, name: 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, name: r.name }));
const tableUsers = users.map((u) => ({
id: u.id, name: u.name, email: u.email, status: u.status,
roleNames: u.userRoles.map((ur) => ur.role.name), 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,
}));
const editUser = sp.edit ? users.find((u) => u.id === sp.edit) : null;
const base = "/settings/users";
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>
<UserTable users={tableUsers} newHref={`${base}?new=1`} editHref={(id) => `${base}?edit=${id}`} />
</section>
)}
{canRoles && (
<section className="mt-8">
<h2 className="mb-3 font-heading text-sm font-semibold">Rollen &amp; Berechtigungen</h2>
<p className="mb-3 text-[12.5px] text-muted-foreground">Rolle anklicken, um ihre Berechtigungen zu sehen und (bei eigenen Rollen) zu bearbeiten.</p>
<RoleManager roles={managedRoles} allPermissions={[...PERMISSIONS]} newRoleHref={`${base}?newRole=1`} />
</section>
)}
{canUsers && sp.new && (
<Modal title="Benutzer anlegen" sub="Neuen Nutzer mit Rollen und Initialpasswort anlegen" closeHref={base} closeLabel="Schließen">
<UserCreateForm action={createUser} roles={roleOptions} closeHref={base} />
</Modal>
)}
{canRoles && sp.newRole && (
<Modal title="Eigene Rolle anlegen" sub="Rollenname und Berechtigungen festlegen (mandantenintern)" closeHref={base} closeLabel="Schließen">
<div className="p-5">
<CreateRoleForm allPermissions={[...PERMISSIONS]} closeHref={base} />
</div>
</Modal>
)}
{canUsers && editUser && (
<Modal title={`Benutzer bearbeiten — ${editUser.name}`} sub={editUser.email} closeHref={base} closeLabel="Schließen">
<UserEditForm
user={{
id: editUser.id, name: editUser.name, email: editUser.email, status: editUser.status,
roleIds: editUser.userRoles.map((ur) => ur.role.id), isSelf: editUser.id === session.user.id,
}}
roles={roleOptions}
updateAction={updateUser.bind(null, editUser.id)}
rolesAction={setUserRoles.bind(null, editUser.id)}
statusAction={setUserStatus.bind(null, editUser.id, editUser.status === "ACTIVE" ? "DEACTIVATED" : "ACTIVE")}
/>
</Modal>
)}
</main>
);
}