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:
+11
-1
@@ -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",
|
||||
|
||||
+11
-1
@@ -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",
|
||||
|
||||
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 { 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<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 (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<Card className="mt-6 max-w-2xl">
|
||||
<h1 className="text-xl">{t("title")}</h1>
|
||||
<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>
|
||||
<CardTitle>{t("welcome", { name: session.user.name ?? "" })}</CardTitle>
|
||||
<CardTitle>{t("activity")}</CardTitle>
|
||||
<CardDescription>{t("activitySub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("tenant")}</dt>
|
||||
<dd>{session.user.tenantSlug}</dd>
|
||||
<dt className="text-muted-foreground">{t("roles")}</dt>
|
||||
<dd>{session.user.roles.join(", ")}</dd>
|
||||
</dl>
|
||||
<p className="mt-6 text-sm text-muted-foreground">{t("placeholder")}</p>
|
||||
{activities.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("activityEmpty")}</p>
|
||||
)}
|
||||
<ul className="space-y-2 text-sm">
|
||||
{activities.map((a) => (
|
||||
<li key={a.id} className="flex items-baseline gap-3">
|
||||
<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>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
+51
-15
@@ -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 (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r bg-sidebar">
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<Link href="/dashboard" className="font-semibold tracking-tight">
|
||||
{tc("appName")}
|
||||
<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
|
||||
/>
|
||||
</Link>
|
||||
</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) =>
|
||||
item.enabled ? (
|
||||
<NavLink key={item.href} href={item.href} match={item.match}>
|
||||
<item.icon className="size-4" />
|
||||
<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 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}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
<div className="border-t p-3 text-xs text-muted-foreground">
|
||||
<p className="truncate font-medium text-foreground">{session.user.name}</p>
|
||||
<p className="truncate">{session.user.tenantSlug}</p>
|
||||
<form action={logout} className="mt-2">
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full">
|
||||
</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-white/90 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" />
|
||||
<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")}
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+98
-73
@@ -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;
|
||||
}
|
||||
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 { 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 (
|
||||
<html
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+24
-38
@@ -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 (
|
||||
<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">
|
||||
<h1 className="text-xl font-semibold">{tc("appName")}</h1>
|
||||
<h2 className="mt-1 text-sm text-slate-500">{t("subtitle")}</h2>
|
||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
|
||||
<Image
|
||||
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 && (
|
||||
<p
|
||||
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")}
|
||||
</p>
|
||||
@@ -52,51 +61,28 @@ export default async function LoginPage({
|
||||
<form action={login} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="callbackUrl" value={callbackUrl ?? ""} />
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium">
|
||||
{t("email")}
|
||||
</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"
|
||||
/>
|
||||
<Label htmlFor="email">{t("email")}</Label>
|
||||
<Input id="email" name="email" type="email" required autoComplete="email" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium">
|
||||
{t("password")}
|
||||
</label>
|
||||
<input
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
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>
|
||||
<label htmlFor="tenant" className="block text-sm font-medium">
|
||||
{t("tenant")}
|
||||
</label>
|
||||
<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>
|
||||
<Label htmlFor="tenant">{t("tenant")}</Label>
|
||||
<Input id="tenant" name="tenant" autoComplete="organization" className="mt-1" />
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("tenantHint")}</p>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button type="submit" className="w-full">
|
||||
{t("submit")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function LevelBadge({
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge variant="outline" className={levelColor(level)}>
|
||||
<Badge variant="outline" className={`font-bold ${levelColor(level)}`}>
|
||||
{prefix ? `${prefix}${level}` : label ?? level}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -22,10 +22,10 @@ export function NavLink({
|
||||
<Link
|
||||
href={href}
|
||||
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
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground"
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground [&_svg]:opacity-100"
|
||||
: "text-sidebar-foreground hover:bg-secondary hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
+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 {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user