import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
Compass,
LayoutDashboard,
Boxes,
GitBranch,
ShieldAlert,
ClipboardCheck,
ShieldCheck,
KanbanSquare,
ListChecks,
Siren,
BookOpenText,
MessagesSquare,
Network,
FolderCheck,
Truck,
LineChart,
Search,
Settings,
} from "lucide-react";
import { auth, signOut } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { isTokenStillValid } from "@/server/sessions";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { HREF_TO_MODULE } from "@/lib/modules";
import { AUDIT_READINESS_MODULE_KEY } from "@/lib/audit-readiness/activation";
import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button";
import { NavLink } from "@/components/nav-link";
import { TenantBrand } from "@/components/brand/tenant-brand";
import { TenantSwitcher } from "@/components/tenant-switcher";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { CertviaLogo } from "@/components/brand/certvia-logo";
export default async function AppLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const session = await auth();
if (!session?.user) redirect("/login");
// WS2: angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne
// Slug) → Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
if (!session.user.tenantId) redirect("/select-tenant");
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
// abgemeldet, ein offener Passwortwechsel wird erzwungen (Force-Change).
// Option C (WS4): Membership-Status kommt vom User, die globalen Auth-Zustände
// (Passwortzwang, Kill-Switch, MFA, globaler Sperrstatus) von der Identity.
const account = await dbForTenant(session.user.tenantId).user.findUnique({
where: { id: session.user.id },
select: { status: true },
});
const identity = session.user.identityId
? await prisma.identity.findUnique({
where: { id: session.user.identityId },
select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
})
: null;
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
await signOut({ redirectTo: "/login" });
}
if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
return (
Konto deaktiviert
Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.
);
}
if (identity.mustChangePassword) redirect("/change-password");
// SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
// noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb dieses Layouts).
if (!identity.mfaEnrolledAt) {
const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
if (resolveMfaRequired(settings?.securityPolicy)) {
const passkeys = session.user.identityId
? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
: 0;
if (passkeys === 0) redirect("/enroll-mfa");
}
}
const t = await getTranslations("nav");
const tc = await getTranslations("common");
const nav = [
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
{ href: "/onboarding", label: t("onboarding"), icon: Compass, enabled: true },
// M4: nur sichtbar, wenn der Audit-Wizard scharfgeschaltet ist (Opt-in-Flag unten).
{ href: "/audit-readiness", label: t("auditReadiness"), icon: ShieldCheck, enabled: true, requiresAudit: true },
{ href: "/assets", label: t("assets"), icon: Boxes, enabled: true },
{ href: "/processes", label: t("bia"), icon: GitBranch, enabled: true },
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true },
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: true },
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
{ href: "/tasks", label: t("tasks"), icon: ListChecks, enabled: true },
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: true },
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: true },
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: true },
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: true },
{ href: "/review", label: t("review"), icon: LineChart, enabled: true },
];
// Modul-Gating: deaktivierte Module werden ausgeblendet (§3.4)
const [moduleRows, brandingSettings] = await Promise.all([
dbForTenant(session.user.tenantId).tenantModule.findMany(),
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
where: { tenantId: session.user.tenantId },
select: { accent: true },
}),
]);
// Certvia ist der Default; ein Mandanten-Logo überschreibt später nur, wenn gesetzt (S8).
const branding = resolveTenantBranding(brandingSettings);
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const moduleEnabled = (href: string) => {
const key = HREF_TO_MODULE[href];
return !key || !disabledModules.has(key);
};
// M4: Audit-Wizard ist opt-in (Default aus) — nur bei ausdrücklicher Aktivierung sichtbar.
const auditEnabled = moduleRows.some((m) => m.moduleKey === AUDIT_READINESS_MODULE_KEY && m.enabled);
const visibleNav = nav.filter(
(item) =>
(!("requiresAudit" in item) || !item.requiresAudit || auditEnabled) &&
(!item.enabled || moduleEnabled(item.href)),
);
const canManageTenant = hasPermission(session, "tenant:manage");
const initials = (session.user.name ?? "?")
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase();
async function logout() {
"use server";
await signOut({ redirectTo: "/login" });
}
return (
);
}