Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split
- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds, Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity, MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin) - Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email) - TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt - RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions - Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports, emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts - Modul-Routen mit requireModule-Layout und Platzhalterseite - Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader - check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/ - Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt - Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/ NotificationPreference-Fixtures umgestellt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+31
-100
@@ -1,54 +1,31 @@
|
||||
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 { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
||||
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";
|
||||
import { CraftviaLogo } from "@/components/brand/craftvia-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).
|
||||
// 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.
|
||||
// abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
|
||||
// User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
|
||||
const account = await dbForTenant(session.user.tenantId).user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { status: true },
|
||||
@@ -67,7 +44,7 @@ export default async function AppLayout({
|
||||
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">
|
||||
<CertviaLogo variant="lockup" theme="dark" height={34} className="mx-auto" />
|
||||
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
|
||||
<p className="mt-5 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">
|
||||
@@ -94,27 +71,8 @@ export default async function AppLayout({
|
||||
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)
|
||||
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
|
||||
// Seiten/Actions prüfen serverseitig selbst).
|
||||
const [moduleRows, brandingSettings] = await Promise.all([
|
||||
dbForTenant(session.user.tenantId).tenantModule.findMany(),
|
||||
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
||||
@@ -122,22 +80,14 @@ export default async function AppLayout({
|
||||
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 navItems = visibleNavItems(NAV_ITEMS, {
|
||||
disabledModules,
|
||||
permissions: session.user.permissions ?? [],
|
||||
});
|
||||
const mainNav = navItems.filter((i) => i.section === "main");
|
||||
const adminNav = navItems.filter((i) => i.section === "admin");
|
||||
|
||||
const initials = (session.user.name ?? "?")
|
||||
.split(/\s+/)
|
||||
@@ -156,34 +106,26 @@ export default async function AppLayout({
|
||||
<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" aria-label={branding.productName}>
|
||||
<TenantBrand branding={branding} height={36} />
|
||||
<TenantBrand branding={branding} height={34} />
|
||||
</Link>
|
||||
<TenantSwitcher memberships={session.user.memberships ?? []} activeSlug={session.user.tenantSlug} />
|
||||
</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>
|
||||
)
|
||||
)}
|
||||
{mainNav.map((item) => (
|
||||
<NavLink key={item.href} href={item.href}>
|
||||
<item.icon className="size-[18px] opacity-85" />
|
||||
{t(item.label)}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{canManageTenant && (
|
||||
{adminNav.length > 0 && (
|
||||
<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>
|
||||
{adminNav.map((item) => (
|
||||
<NavLink key={item.href} href={item.href}>
|
||||
<item.icon className="size-[18px] opacity-85" />
|
||||
{t(item.label)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
@@ -191,21 +133,10 @@ export default async function AppLayout({
|
||||
|
||||
<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>
|
||||
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
|
||||
<div className="flex-1" />
|
||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||
<Link href="/account" className="flex items-center gap-3" title="Mein Konto (MFA)">
|
||||
<Link href="/account" className="flex items-center gap-3">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user