GUI ans Mockup-Design angepasst (GEFIM-Branding)
- GEFIM-Farbwelt aus dem Mockup als shadcn-Theme: Violett #5d52a3 als Primärfarbe, Magenta/Hellblau-Akzente, Ink-Text, #f5f6fa-Hintergrund, Verlaufs-Utilities (bg-grad, bg-grad-soft), Karten-Schatten - Fonts Poppins (Überschriften/Buttons) + Open Sans (Text), self-hosted über next/font — kein Google-CDN-Aufruf zur Laufzeit (DSGVO) - Sidebar mit extrahiertem GEFIM-Logo (public/gefim-logo.png), aktive Navigation in Violett wie im Prototyp - Sticky Topbar mit globaler Suche (leitet vorerst auf die Asset-Suche), Nutzer-Info und Verlaufs-Avatar mit Initialen; Logout in die Topbar - Primär-Buttons mit Verlaufshintergrund, Level-Badges als Pillen in Mockup-Farben, Login-Seite im GEFIM-Look - Dashboard im Mockup-Stil: KPI-Karten (Assets, Prozesse, kritische Prozesse, Risiken-Platzhalter) und Letzte-Aktivitäten-Feed aus dem echten Audit-Log Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+12
-2
@@ -45,7 +45,17 @@
|
|||||||
"welcome": "Willkommen, {name}",
|
"welcome": "Willkommen, {name}",
|
||||||
"tenant": "Mandant",
|
"tenant": "Mandant",
|
||||||
"roles": "Rollen",
|
"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": {
|
"assets": {
|
||||||
"title": "Assets & BIA",
|
"title": "Assets & BIA",
|
||||||
@@ -140,4 +150,4 @@
|
|||||||
"3": "Hoch",
|
"3": "Hoch",
|
||||||
"4": "Sehr hoch"
|
"4": "Sehr hoch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+12
-2
@@ -45,7 +45,17 @@
|
|||||||
"welcome": "Welcome, {name}",
|
"welcome": "Welcome, {name}",
|
||||||
"tenant": "Tenant",
|
"tenant": "Tenant",
|
||||||
"roles": "Roles",
|
"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": {
|
"assets": {
|
||||||
"title": "Assets & BIA",
|
"title": "Assets & BIA",
|
||||||
@@ -140,4 +150,4 @@
|
|||||||
"3": "High",
|
"3": "High",
|
||||||
"4": "Very high"
|
"4": "Very high"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -1,26 +1,85 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
import { requireSession } from "@/server/auth";
|
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() {
|
export default async function DashboardPage() {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const t = await getTranslations("dashboard");
|
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<string, string>();
|
||||||
|
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 (
|
return (
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
<h1 className="text-xl">{t("title")}</h1>
|
||||||
<Card className="mt-6 max-w-2xl">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<Card key={kpi.label}>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-[12.5px] font-semibold text-muted-foreground">{kpi.label}</p>
|
||||||
|
<p className="mt-1.5 font-heading text-3xl font-bold leading-none">{kpi.value}</p>
|
||||||
|
{kpi.hint && <p className="mt-1.5 text-xs text-muted-foreground">{kpi.hint}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mt-6 max-w-3xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("welcome", { name: session.user.name ?? "" })}</CardTitle>
|
<CardTitle>{t("activity")}</CardTitle>
|
||||||
|
<CardDescription>{t("activitySub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
{activities.length === 0 && (
|
||||||
<dt className="text-muted-foreground">{t("tenant")}</dt>
|
<p className="text-sm text-muted-foreground">{t("activityEmpty")}</p>
|
||||||
<dd>{session.user.tenantSlug}</dd>
|
)}
|
||||||
<dt className="text-muted-foreground">{t("roles")}</dt>
|
<ul className="space-y-2 text-sm">
|
||||||
<dd>{session.user.roles.join(", ")}</dd>
|
{activities.map((a) => (
|
||||||
</dl>
|
<li key={a.id} className="flex items-baseline gap-3">
|
||||||
<p className="mt-6 text-sm text-muted-foreground">{t("placeholder")}</p>
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{format.relativeTime(a.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong className="font-semibold">
|
||||||
|
{a.actorId ? actorNames.get(a.actorId) ?? "System" : "System"}
|
||||||
|
</strong>{" "}
|
||||||
|
· {a.action} · {a.entity}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+52
-16
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
FolderCheck,
|
FolderCheck,
|
||||||
Truck,
|
Truck,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { auth, signOut } from "@/server/auth";
|
import { auth, signOut } from "@/server/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -43,6 +45,13 @@ export default async function AppLayout({
|
|||||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
{ 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() {
|
async function logout() {
|
||||||
"use server";
|
"use server";
|
||||||
await signOut({ redirectTo: "/login" });
|
await signOut({ redirectTo: "/login" });
|
||||||
@@ -50,42 +59,69 @@ export default async function AppLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<aside className="flex w-60 shrink-0 flex-col border-r bg-sidebar">
|
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
||||||
<div className="flex h-14 items-center border-b px-4">
|
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
|
||||||
<Link href="/dashboard" className="font-semibold tracking-tight">
|
<Link href="/dashboard">
|
||||||
{tc("appName")}
|
<Image
|
||||||
|
src="/gefim-logo.png"
|
||||||
|
alt="GEFIM — Informationssicherheit im Mittelstand"
|
||||||
|
width={150}
|
||||||
|
height={53}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
||||||
{nav.map((item) =>
|
{nav.map((item) =>
|
||||||
item.enabled ? (
|
item.enabled ? (
|
||||||
<NavLink key={item.href} href={item.href} match={item.match}>
|
<NavLink key={item.href} href={item.href} match={item.match}>
|
||||||
<item.icon className="size-4" />
|
<item.icon className="size-[18px] opacity-85" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
key={item.href}
|
key={item.href}
|
||||||
title={tc("comingSoon")}
|
title={tc("comingSoon")}
|
||||||
className="flex cursor-not-allowed items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
|
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-4" />
|
<item.icon className="size-[18px]" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t p-3 text-xs text-muted-foreground">
|
</aside>
|
||||||
<p className="truncate font-medium text-foreground">{session.user.name}</p>
|
|
||||||
<p className="truncate">{session.user.tenantSlug}</p>
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<form action={logout} className="mt-2">
|
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-white/90 px-6 py-2.5 backdrop-blur-md">
|
||||||
<Button type="submit" variant="outline" size="sm" className="w-full">
|
<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" />
|
||||||
|
<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>
|
||||||
|
<form action={logout}>
|
||||||
|
<Button type="submit" variant="ghost" size="sm">
|
||||||
{tc("logout")}
|
{tc("logout")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</header>
|
||||||
</aside>
|
{children}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-74
@@ -4,15 +4,12 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-open-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: ui-monospace, monospace;
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-poppins);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -51,77 +48,85 @@
|
|||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* GEFIM-Farbwelt aus dem Mockup (docs/ISMS-Prototyp-GEFIM.html) */
|
||||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--gefim-violet: #5d52a3;
|
||||||
--foreground: oklch(0.145 0 0);
|
--gefim-magenta: #812d80;
|
||||||
--card: oklch(1 0 0);
|
--gefim-blue: #8dc4e0;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--gefim-violet-050: #f2f0f9;
|
||||||
--popover: oklch(1 0 0);
|
--gefim-violet-100: #e6e2f3;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--gefim-blue-050: #eef7fb;
|
||||||
--primary: oklch(0.205 0 0);
|
--grad: linear-gradient(135deg, #5d52a3 0%, #812d80 45%, #8dc4e0 100%);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--grad-soft: linear-gradient(135deg, #5d52a3 0%, #8dc4e0 100%);
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--background: #f5f6fa;
|
||||||
--muted: oklch(0.97 0 0);
|
--foreground: #3b3b3a; /* ink */
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--card: #ffffff;
|
||||||
--accent: oklch(0.97 0 0);
|
--card-foreground: #3b3b3a;
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--popover: #ffffff;
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--popover-foreground: #3b3b3a;
|
||||||
--border: oklch(0.922 0 0);
|
--primary: #5d52a3;
|
||||||
--input: oklch(0.922 0 0);
|
--primary-foreground: #ffffff;
|
||||||
--ring: oklch(0.708 0 0);
|
--secondary: #f2f0f9; /* violet-050 */
|
||||||
--chart-1: oklch(0.87 0 0);
|
--secondary-foreground: #5d52a3;
|
||||||
--chart-2: oklch(0.556 0 0);
|
--muted: #f1f2f7;
|
||||||
--chart-3: oklch(0.439 0 0);
|
--muted-foreground: #7a7b86;
|
||||||
--chart-4: oklch(0.371 0 0);
|
--accent: #f2f0f9;
|
||||||
--chart-5: oklch(0.269 0 0);
|
--accent-foreground: #5d52a3;
|
||||||
--radius: 0.625rem;
|
--destructive: #d64c4c;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--border: #e7e8ef;
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--input: #e7e8ef;
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--ring: #5d52a3;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--chart-1: #5d52a3;
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--chart-2: #8dc4e0;
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--chart-3: #812d80;
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--chart-4: #2e9e6b;
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--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 {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: #1c1b22;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: #ececf1;
|
||||||
--card: oklch(0.205 0 0);
|
--card: #26242e;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: #ececf1;
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: #26242e;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: #ececf1;
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: #8b7fd4;
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: #1c1b22;
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: #2e2b3a;
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: #c9c3ec;
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: #2e2b3a;
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: #9a99a6;
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: #2e2b3a;
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: #c9c3ec;
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: #e06c6c;
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: rgba(255, 255, 255, 0.15);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: #8b7fd4;
|
||||||
--chart-1: oklch(0.87 0 0);
|
--sidebar: #26242e;
|
||||||
--chart-2: oklch(0.556 0 0);
|
--sidebar-foreground: #ececf1;
|
||||||
--chart-3: oklch(0.439 0 0);
|
--sidebar-primary: #8b7fd4;
|
||||||
--chart-4: oklch(0.371 0 0);
|
--sidebar-primary-foreground: #1c1b22;
|
||||||
--chart-5: oklch(0.269 0 0);
|
--sidebar-accent: #2e2b3a;
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar-accent-foreground: #c9c3ec;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-border: rgba(255, 255, 255, 0.1);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-ring: #8b7fd4;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
}
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
body {
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
font-family: var(--font-sans), "Open Sans", system-ui, sans-serif;
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -134,4 +139,24 @@ body {
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-7
@@ -1,17 +1,20 @@
|
|||||||
import type { Metadata } from "next";
|
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 { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
// Self-hosted über next/font (kein Google-CDN-Aufruf zur Laufzeit, DSGVO)
|
||||||
variable: "--font-geist-sans",
|
const openSans = Open_Sans({
|
||||||
|
variable: "--font-open-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "600", "700"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const poppins = Poppins({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-poppins",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -30,9 +33,9 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={locale}
|
lang={locale}
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${openSans.variable} ${poppins.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col bg-slate-50 text-slate-900">
|
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+24
-38
@@ -1,7 +1,11 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthError } from "next-auth";
|
import { AuthError } from "next-auth";
|
||||||
import { auth, signIn } from "@/server/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({
|
export default async function LoginPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -9,7 +13,6 @@ export default async function LoginPage({
|
|||||||
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
|
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations("login");
|
const t = await getTranslations("login");
|
||||||
const tc = await getTranslations("common");
|
|
||||||
const { error, callbackUrl } = await searchParams;
|
const { error, callbackUrl } = await searchParams;
|
||||||
|
|
||||||
// Already signed in → straight to the app
|
// Already signed in → straight to the app
|
||||||
@@ -36,14 +39,20 @@ export default async function LoginPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 items-center justify-center p-6">
|
<main className="flex flex-1 items-center justify-center p-6">
|
||||||
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
|
||||||
<h1 className="text-xl font-semibold">{tc("appName")}</h1>
|
<Image
|
||||||
<h2 className="mt-1 text-sm text-slate-500">{t("subtitle")}</h2>
|
src="/gefim-logo.png"
|
||||||
|
alt="GEFIM — Informationssicherheit im Mittelstand"
|
||||||
|
width={170}
|
||||||
|
height={60}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<h2 className="mt-4 text-sm font-normal text-muted-foreground">{t("subtitle")}</h2>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700"
|
className="mt-4 rounded-lg bg-[#fbe4e4] px-3 py-2 text-sm text-[#d64c4c]"
|
||||||
>
|
>
|
||||||
{t("error")}
|
{t("error")}
|
||||||
</p>
|
</p>
|
||||||
@@ -52,51 +61,28 @@ export default async function LoginPage({
|
|||||||
<form action={login} className="mt-6 space-y-4">
|
<form action={login} className="mt-6 space-y-4">
|
||||||
<input type="hidden" name="callbackUrl" value={callbackUrl ?? ""} />
|
<input type="hidden" name="callbackUrl" value={callbackUrl ?? ""} />
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium">
|
<Label htmlFor="email">{t("email")}</Label>
|
||||||
{t("email")}
|
<Input id="email" name="email" type="email" required autoComplete="email" className="mt-1" />
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium">
|
<Label htmlFor="password">{t("password")}</Label>
|
||||||
{t("password")}
|
<Input
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="tenant" className="block text-sm font-medium">
|
<Label htmlFor="tenant">{t("tenant")}</Label>
|
||||||
{t("tenant")}
|
<Input id="tenant" name="tenant" autoComplete="organization" className="mt-1" />
|
||||||
</label>
|
<p className="mt-1 text-xs text-muted-foreground">{t("tenantHint")}</p>
|
||||||
<input
|
|
||||||
id="tenant"
|
|
||||||
name="tenant"
|
|
||||||
type="text"
|
|
||||||
autoComplete="organization"
|
|
||||||
title={t("tenantHint")}
|
|
||||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-slate-400">{t("tenantHint")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button type="submit" className="w-full">
|
||||||
type="submit"
|
|
||||||
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
{t("submit")}
|
{t("submit")}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function LevelBadge({
|
|||||||
prefix?: string;
|
prefix?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={levelColor(level)}>
|
<Badge variant="outline" className={`font-bold ${levelColor(level)}`}>
|
||||||
{prefix ? `${prefix}${level}` : label ?? level}
|
{prefix ? `${prefix}${level}` : label ?? level}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export function NavLink({
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] font-semibold transition-colors",
|
||||||
active
|
active
|
||||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
? "bg-sidebar-accent text-sidebar-accent-foreground [&_svg]:opacity-100"
|
||||||
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground"
|
: "text-sidebar-foreground hover:bg-secondary hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
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:
|
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",
|
"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:
|
secondary:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function Card({
|
|||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+5
-5
@@ -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 {
|
export function levelColor(level: number): string {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case 2:
|
case 2:
|
||||||
return "bg-yellow-100 text-yellow-800 border-yellow-200";
|
return "bg-[#fbf1de] text-[#a9760f] border-transparent";
|
||||||
case 3:
|
case 3:
|
||||||
return "bg-orange-100 text-orange-800 border-orange-200";
|
return "bg-[#fdeadb] text-[#c2681a] border-transparent";
|
||||||
case 4:
|
case 4:
|
||||||
return "bg-red-100 text-red-800 border-red-200";
|
return "bg-[#fbe4e4] text-[#d64c4c] border-transparent";
|
||||||
default:
|
default:
|
||||||
return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
return "bg-[#e7f6ee] text-[#2e9e6b] border-transparent";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user