diff --git a/messages/de.json b/messages/de.json index 7cc1c0a..52c081e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -45,7 +45,17 @@ "welcome": "Willkommen, {name}", "tenant": "Mandant", "roles": "Rollen", - "placeholder": "Die Modul-Dashboards (Risiken, Aufgaben, SoA-Erfüllungsgrad) folgen in den nächsten Iterationen." + "placeholder": "Die Modul-Dashboards (Risiken, Aufgaben, SoA-Erfüllungsgrad) folgen in den nächsten Iterationen.", + "subtitle": "Willkommen zurück, {name} — Status des ISMS für {tenant}.", + "kpiAssets": "Assets im Inventar", + "kpiProcesses": "Prozesse (BIA)", + "kpiCritical": "Kritische Prozesse", + "kpiCriticalHint": "Kritikalität hoch oder sehr hoch", + "kpiRisks": "Offene Risiken", + "kpiRisksHint": "folgt mit dem Risiko-Modul", + "activity": "Letzte Aktivitäten", + "activitySub": "Audit-Log", + "activityEmpty": "Noch keine Aktivitäten." }, "assets": { "title": "Assets & BIA", @@ -140,4 +150,4 @@ "3": "Hoch", "4": "Sehr hoch" } -} +} \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index b8a4020..744a4ad 100644 --- a/messages/en.json +++ b/messages/en.json @@ -45,7 +45,17 @@ "welcome": "Welcome, {name}", "tenant": "Tenant", "roles": "Roles", - "placeholder": "Module dashboards (risks, tasks, SoA coverage) follow in upcoming iterations." + "placeholder": "Module dashboards (risks, tasks, SoA coverage) follow in upcoming iterations.", + "subtitle": "Welcome back, {name} — ISMS status for {tenant}.", + "kpiAssets": "Assets in inventory", + "kpiProcesses": "Processes (BIA)", + "kpiCritical": "Critical processes", + "kpiCriticalHint": "criticality high or very high", + "kpiRisks": "Open risks", + "kpiRisksHint": "coming with the risk module", + "activity": "Recent activity", + "activitySub": "Audit log", + "activityEmpty": "No activity yet." }, "assets": { "title": "Assets & BIA", @@ -140,4 +150,4 @@ "3": "High", "4": "Very high" } -} +} \ No newline at end of file diff --git a/public/gefim-logo.png b/public/gefim-logo.png new file mode 100644 index 0000000..92b0e96 Binary files /dev/null and b/public/gefim-logo.png differ diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 95d52ab..5277f25 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,26 +1,85 @@ -import { getTranslations } from "next-intl/server"; +import { getFormatter, getTranslations } from "next-intl/server"; import { requireSession } from "@/server/auth"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { dbForTenant } from "@/server/db"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; export default async function DashboardPage() { const session = await requireSession(); const t = await getTranslations("dashboard"); + const tc = await getTranslations("common"); + const format = await getFormatter(); + + const db = dbForTenant(session.user.tenantId); + const [assetCount, processCount, criticalCount, activities] = await Promise.all([ + db.asset.count(), + db.process.count(), + db.biaEntry.count({ where: { criticality: { gte: 3 } } }), + db.auditLog.findMany({ + orderBy: { createdAt: "desc" }, + take: 6, + }), + ]); + + const actorNames = new Map(); + const actorIds = [...new Set(activities.map((a) => a.actorId).filter(Boolean))] as string[]; + if (actorIds.length) { + const users = await db.user.findMany({ + where: { id: { in: actorIds } }, + select: { id: true, name: true }, + }); + users.forEach((u) => actorNames.set(u.id, u.name)); + } + + const kpis = [ + { label: t("kpiAssets"), value: String(assetCount), hint: null }, + { label: t("kpiProcesses"), value: String(processCount), hint: null }, + { label: t("kpiCritical"), value: String(criticalCount), hint: t("kpiCriticalHint") }, + { label: t("kpiRisks"), value: tc("none"), hint: t("kpiRisksHint") }, + ]; return (
-

{t("title")}

- +

{t("title")}

+

+ {t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })} +

+ +
+ {kpis.map((kpi) => ( + + +

{kpi.label}

+

{kpi.value}

+ {kpi.hint &&

{kpi.hint}

} +
+
+ ))} +
+ + - {t("welcome", { name: session.user.name ?? "" })} + {t("activity")} + {t("activitySub")} -
-
{t("tenant")}
-
{session.user.tenantSlug}
-
{t("roles")}
-
{session.user.roles.join(", ")}
-
-

{t("placeholder")}

+ {activities.length === 0 && ( +

{t("activityEmpty")}

+ )} +
    + {activities.map((a) => ( +
  • + + {format.relativeTime(a.createdAt)} + + + + {a.actorId ? actorNames.get(a.actorId) ?? "System" : "System"} + {" "} + · {a.action} · {a.entity} + +
  • + ))} +
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 45d4ae6..7935ffa 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import Image from "next/image"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { @@ -14,6 +15,7 @@ import { FolderCheck, Truck, LineChart, + Search, } from "lucide-react"; import { auth, signOut } from "@/server/auth"; import { Button } from "@/components/ui/button"; @@ -43,6 +45,13 @@ export default async function AppLayout({ { href: "/review", label: t("review"), icon: LineChart, enabled: false }, ]; + 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" }); @@ -50,42 +59,69 @@ export default async function AppLayout({ return (
- -
{children}
+ + {children} +
); } diff --git a/src/app/globals.css b/src/app/globals.css index cdbcf5d..668d63a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,15 +4,12 @@ @custom-variant dark (&:is(.dark *)); -/* Helles Basis-Theme; Dark-Mode folgt kontrolliert mit dem shadcn/ui-Theming - (kein automatisches prefers-color-scheme, damit Komponenten konsistent bleiben). */ - @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); + --font-sans: var(--font-open-sans); + --font-mono: ui-monospace, monospace; + --font-heading: var(--font-poppins); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -51,77 +48,85 @@ --radius-4xl: calc(var(--radius) * 2.6); } -body { - font-family: var(--font-sans), Arial, Helvetica, sans-serif; -} - +/* GEFIM-Farbwelt aus dem Mockup (docs/ISMS-Prototyp-GEFIM.html) */ :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --gefim-violet: #5d52a3; + --gefim-magenta: #812d80; + --gefim-blue: #8dc4e0; + --gefim-violet-050: #f2f0f9; + --gefim-violet-100: #e6e2f3; + --gefim-blue-050: #eef7fb; + --grad: linear-gradient(135deg, #5d52a3 0%, #812d80 45%, #8dc4e0 100%); + --grad-soft: linear-gradient(135deg, #5d52a3 0%, #8dc4e0 100%); + + --background: #f5f6fa; + --foreground: #3b3b3a; /* ink */ + --card: #ffffff; + --card-foreground: #3b3b3a; + --popover: #ffffff; + --popover-foreground: #3b3b3a; + --primary: #5d52a3; + --primary-foreground: #ffffff; + --secondary: #f2f0f9; /* violet-050 */ + --secondary-foreground: #5d52a3; + --muted: #f1f2f7; + --muted-foreground: #7a7b86; + --accent: #f2f0f9; + --accent-foreground: #5d52a3; + --destructive: #d64c4c; + --border: #e7e8ef; + --input: #e7e8ef; + --ring: #5d52a3; + --chart-1: #5d52a3; + --chart-2: #8dc4e0; + --chart-3: #812d80; + --chart-4: #2e9e6b; + --chart-5: #e0982e; + --radius: 0.7rem; /* Buttons ~10px, Karten (xl) ~16px wie im Mockup */ + --sidebar: #ffffff; + --sidebar-foreground: #4b4c57; + --sidebar-primary: #5d52a3; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e6e2f3; /* violet-100 für aktive Einträge */ + --sidebar-accent-foreground: #5d52a3; + --sidebar-border: #e7e8ef; + --sidebar-ring: #5d52a3; + --shadow-card: 0 1px 3px rgba(30, 25, 60, 0.06), 0 6px 20px rgba(30, 25, 60, 0.06); } +/* Dark-Mode-Werte folgen kontrolliert später (kein prefers-color-scheme-Automatismus) */ .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: #1c1b22; + --foreground: #ececf1; + --card: #26242e; + --card-foreground: #ececf1; + --popover: #26242e; + --popover-foreground: #ececf1; + --primary: #8b7fd4; + --primary-foreground: #1c1b22; + --secondary: #2e2b3a; + --secondary-foreground: #c9c3ec; + --muted: #2e2b3a; + --muted-foreground: #9a99a6; + --accent: #2e2b3a; + --accent-foreground: #c9c3ec; + --destructive: #e06c6c; + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --ring: #8b7fd4; + --sidebar: #26242e; + --sidebar-foreground: #ececf1; + --sidebar-primary: #8b7fd4; + --sidebar-primary-foreground: #1c1b22; + --sidebar-accent: #2e2b3a; + --sidebar-accent-foreground: #c9c3ec; + --sidebar-border: rgba(255, 255, 255, 0.1); + --sidebar-ring: #8b7fd4; +} + +body { + font-family: var(--font-sans), "Open Sans", system-ui, sans-serif; + font-size: 14px; } @layer base { @@ -134,4 +139,24 @@ body { html { @apply font-sans; } -} \ No newline at end of file + h1, + h2, + h3, + h4 { + font-family: var(--font-heading), "Poppins", sans-serif; + font-weight: 600; + } +} + +@layer utilities { + /* Verlaufs-Flächen wie im Mockup (.btn.primary, Avatare, Akzente) */ + .bg-grad { + background: var(--grad); + } + .bg-grad-soft { + background: var(--grad-soft); + } + .shadow-card { + box-shadow: var(--shadow-card); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 34c4415..c79e4b9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,17 +1,20 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Open_Sans, Poppins } from "next/font/google"; import { NextIntlClientProvider } from "next-intl"; import { getLocale } from "next-intl/server"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +// Self-hosted über next/font (kein Google-CDN-Aufruf zur Laufzeit, DSGVO) +const openSans = Open_Sans({ + variable: "--font-open-sans", subsets: ["latin"], + weight: ["300", "400", "600", "700"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const poppins = Poppins({ + variable: "--font-poppins", subsets: ["latin"], + weight: ["300", "500", "600", "700"], }); export const metadata: Metadata = { @@ -30,9 +33,9 @@ export default async function RootLayout({ return ( - + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 4060d14..d7e06e2 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,11 @@ +import Image from "next/image"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; import { AuthError } from "next-auth"; import { auth, signIn } from "@/server/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; export default async function LoginPage({ searchParams, @@ -9,7 +13,6 @@ export default async function LoginPage({ searchParams: Promise<{ error?: string; callbackUrl?: string }>; }) { const t = await getTranslations("login"); - const tc = await getTranslations("common"); const { error, callbackUrl } = await searchParams; // Already signed in → straight to the app @@ -36,14 +39,20 @@ export default async function LoginPage({ return (
-
-

{tc("appName")}

-

{t("subtitle")}

+
+ GEFIM — Informationssicherheit im Mittelstand +

{t("subtitle")}

{error && (

{t("error")}

@@ -52,51 +61,28 @@ export default async function LoginPage({
- - + +
- - {t("password")} +
- - -

{t("tenantHint")}

+ + +

{t("tenantHint")}

- +
diff --git a/src/components/level-badge.tsx b/src/components/level-badge.tsx index d0b3e95..4ae7662 100644 --- a/src/components/level-badge.tsx +++ b/src/components/level-badge.tsx @@ -12,7 +12,7 @@ export function LevelBadge({ prefix?: string; }) { return ( - + {prefix ? `${prefix}${level}` : label ?? level} ); diff --git a/src/components/nav-link.tsx b/src/components/nav-link.tsx index b2c001c..4f3f716 100644 --- a/src/components/nav-link.tsx +++ b/src/components/nav-link.tsx @@ -22,10 +22,10 @@ export function NavLink({ {children} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b033601..44b130f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -4,11 +4,12 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding font-heading text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + // Primär-Buttons mit GEFIM-Verlauf wie im Mockup + default: "bg-grad-soft text-white hover:opacity-90", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 4458dae..26bcd70 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -12,7 +12,7 @@ function Card({ data-slot="card" data-size={size} className={cn( - "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + "group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground shadow-card ring-1 ring-border [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", className )} {...props} diff --git a/src/lib/levels.ts b/src/lib/levels.ts index 0f2c2f4..1a106da 100644 --- a/src/lib/levels.ts +++ b/src/lib/levels.ts @@ -1,13 +1,13 @@ -/** Farbklassen für Stufen 1–4 (Schutzbedarf, Schadenshöhe, Kritikalität). */ +/** Farbklassen für Stufen 1–4 (Schutzbedarf, Schadenshöhe, Kritikalität) — Pillen-Farben aus dem Mockup. */ export function levelColor(level: number): string { switch (level) { case 2: - return "bg-yellow-100 text-yellow-800 border-yellow-200"; + return "bg-[#fbf1de] text-[#a9760f] border-transparent"; case 3: - return "bg-orange-100 text-orange-800 border-orange-200"; + return "bg-[#fdeadb] text-[#c2681a] border-transparent"; case 4: - return "bg-red-100 text-red-800 border-red-200"; + return "bg-[#fbe4e4] text-[#d64c4c] border-transparent"; default: - return "bg-emerald-100 text-emerald-800 border-emerald-200"; + return "bg-[#e7f6ee] text-[#2e9e6b] border-transparent"; } }