MFA ist nicht mehr erzwungen, sondern freiwillig; die Erzwingung bleibt über ein Policy-Flag als Option erhalten (Anpassung von Phase-1-Paket 2). Plattform-Admins: - (platform)/layout erzwingt Enrollment nur noch, wenn PlatformSetting.mfaRequired aktiv ist (Default: aus) → Login ohne MFA möglich. - Profil & Sicherheit (/platform/profile): MFA freiwillig aktivieren (Enroll-Flow) bzw. deaktivieren (nur solange keine Pflicht gilt); Recovery-Code-Anzeige. - Policy-Flag „MFA-Pflicht" umschaltbar (setPlatformMfaRequired) — stellt die Erzwingung ohne Codeumbau wieder her. Rate-Limit/Lockout/Audit bleiben unverändert. Mandanten-Nutzer (analog): - auth.ts verlangt den TOTP-/Recovery-Code beim Login nur, wenn der Nutzer MFA eingerichtet hat; Login-Formular um optionales MFA-Feld ergänzt (i18n de/en). - Persönliches Konto (/account): MFA selbst aktivieren (QR + Bestätigung, Recovery- Codes) bzw. deaktivieren; disableOwnMfa respektiert eine tenant-weite MFA-Pflicht (securityPolicy.mfaRequired) als fortbestehende Option. Browser-verifiziert: Plattform-Login ohne MFA → /admin; Tenant-Login zeigt das MFA-Feld; /account bietet QR-Enrollment. tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import {
|
|
LayoutDashboard,
|
|
Boxes,
|
|
GitBranch,
|
|
ShieldAlert,
|
|
ClipboardCheck,
|
|
KanbanSquare,
|
|
Siren,
|
|
BookOpenText,
|
|
MessagesSquare,
|
|
Network,
|
|
FolderCheck,
|
|
Truck,
|
|
LineChart,
|
|
Search,
|
|
Settings,
|
|
} from "lucide-react";
|
|
import { auth, signOut } from "@/server/auth";
|
|
import { dbForTenant } from "@/server/db";
|
|
import { hasPermission } from "@/server/rbac";
|
|
import { HREF_TO_MODULE } from "@/lib/modules";
|
|
import { Button } from "@/components/ui/button";
|
|
import { NavLink } from "@/components/nav-link";
|
|
|
|
export default async function AppLayout({
|
|
children,
|
|
}: Readonly<{ children: React.ReactNode }>) {
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
|
|
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
|
|
// abgemeldet, ein offener Passwortwechsel wird erzwungen (Force-Change).
|
|
const account = await dbForTenant(session.user.tenantId).user.findUnique({
|
|
where: { id: session.user.id },
|
|
select: { status: true, mustChangePassword: true },
|
|
});
|
|
if (!account || account.status !== "ACTIVE") {
|
|
return (
|
|
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
|
|
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
|
<p className="font-heading text-lg font-semibold">Konto deaktiviert</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
|
|
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
|
|
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
if (account.mustChangePassword) redirect("/change-password");
|
|
|
|
const t = await getTranslations("nav");
|
|
const tc = await getTranslations("common");
|
|
|
|
const nav = [
|
|
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: 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: false },
|
|
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
|
|
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
|
{ 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: false },
|
|
];
|
|
|
|
// Modul-Gating: deaktivierte Module werden ausgeblendet (§3.4)
|
|
const moduleRows = await dbForTenant(session.user.tenantId).tenantModule.findMany();
|
|
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);
|
|
};
|
|
const visibleNav = nav.filter((item) => !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 (
|
|
<div className="flex min-h-screen">
|
|
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
|
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
|
|
<Link href="/dashboard">
|
|
<Image
|
|
src="/gefim-logo.png"
|
|
alt="GEFIM — Informationssicherheit im Mittelstand"
|
|
width={150}
|
|
height={53}
|
|
priority
|
|
// Negativ-/Weiß-Darstellung auf dunklem Grund
|
|
className="brightness-0 invert"
|
|
/>
|
|
</Link>
|
|
</div>
|
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
|
{visibleNav.map((item) =>
|
|
item.enabled ? (
|
|
<NavLink key={item.href} href={item.href}>
|
|
<item.icon className="size-[18px] opacity-85" />
|
|
{item.label}
|
|
</NavLink>
|
|
) : (
|
|
<span
|
|
key={item.href}
|
|
title={tc("comingSoon")}
|
|
className="flex cursor-not-allowed items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] font-semibold text-muted-foreground/50"
|
|
>
|
|
<item.icon className="size-[18px]" />
|
|
{item.label}
|
|
</span>
|
|
)
|
|
)}
|
|
|
|
{canManageTenant && (
|
|
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
|
|
<NavLink href="/settings">
|
|
<Settings className="size-[18px] opacity-85" /> {t("settings")}
|
|
</NavLink>
|
|
</div>
|
|
)}
|
|
</nav>
|
|
</aside>
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
|
<form
|
|
method="GET"
|
|
action="/assets"
|
|
className="flex w-full max-w-105 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-muted-foreground"
|
|
>
|
|
<Search className="size-4 shrink-0" />
|
|
<input
|
|
name="q"
|
|
placeholder={tc("search")}
|
|
className="w-full border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
</form>
|
|
<div className="flex-1" />
|
|
<Link href="/account" className="flex items-center gap-3" title="Mein Konto (MFA)">
|
|
<div className="text-right leading-tight">
|
|
<p className="text-[13px] font-semibold">{session.user.name}</p>
|
|
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
|
</div>
|
|
<div className="bg-grad-soft grid size-9.5 shrink-0 place-items-center rounded-full font-heading text-sm font-bold text-white">
|
|
{initials}
|
|
</div>
|
|
</Link>
|
|
<form action={logout}>
|
|
<Button type="submit" variant="ghost" size="sm">
|
|
{tc("logout")}
|
|
</Button>
|
|
</form>
|
|
</header>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|