Iteration 2: Assets & BIA als gemeinsames Modul
- App-Shell mit Seitennavigation (alle 12 Module, kommende ausgegraut), shadcn/ui-Setup (Base-UI-Variante) mit hellem Theme - Asset-Inventar: filterbare Liste (Suche/Typ/Status), Anlegen/Bearbeiten/ Löschen, Detailansicht mit Schutzbedarf (C/I/A 1–4), Abhängigkeiten (beide Richtungen, hinzufügen/entfernen), zugeordneten Prozessen mit Primär-/Sekundär-Rolle und Platzhalter für zugeordnete Risiken - Prozesse & BIA: Liste mit Kritikalität/RTO/MTD, Prozess-Detail mit Asset-Zuordnung nach Rolle (primär/sekundär), BIA-Formular (RTO/RPO/MTD, Schadenshöhe je Schutzziel, Kritikalität nach Max-Prinzip) - Server-Actions mit Zod-Validierung, requirePermission und Audit-Log für jede schreibende Aktion; Tenant-Guard um upsert-Injektion erweitert - Prisma: Asset, AssetRelation, Process, ProcessAsset, BiaEntry inkl. RLS-Policies; Seed mit Beispiel-Assets, Prozessen und BIA - Im Browser verifiziert: CRUD, Relationen, BIA-Speichern, Audit-Einträge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { updateAsset } from "@/server/actions/assets";
|
||||
import { AssetForm } from "@/components/asset-form";
|
||||
|
||||
export default async function EditAssetPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const t = await getTranslations("assets");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const asset = await db.asset.findUnique({ where: { id } });
|
||||
if (!asset) notFound();
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const action = updateAsset.bind(null, asset.id);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("editTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<AssetForm action={action} asset={asset} users={users} cancelHref={`/assets/${asset.id}`} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Pencil, Trash2, X } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import {
|
||||
addAssetRelation,
|
||||
deleteAsset,
|
||||
removeAssetRelation,
|
||||
} from "@/server/actions/assets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
|
||||
export default async function AssetDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:read");
|
||||
const t = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tRole = await getTranslations("processRole");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const asset = await db.asset.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
|
||||
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
|
||||
processAssets: { include: { process: { select: { id: true, name: true } } } },
|
||||
},
|
||||
});
|
||||
if (!asset) notFound();
|
||||
|
||||
const canWrite = hasPermission(session, "asset:write");
|
||||
const otherAssets = canWrite
|
||||
? await db.asset.findMany({
|
||||
where: { id: { not: asset.id } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const removeAction = deleteAsset.bind(null, asset.id);
|
||||
const addRelationAction = addAssetRelation.bind(null, asset.id);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{asset.name}</h1>
|
||||
<Badge variant="secondary">{tType(asset.type)}</Badge>
|
||||
<Badge variant="outline">{tStatus(asset.status)}</Badge>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/assets/${asset.id}/edit`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
<form action={removeAction}>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("description")}</dt>
|
||||
<dd className="whitespace-pre-wrap">{asset.description ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{asset.owner?.name ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("location")}</dt>
|
||||
<dd>{asset.location ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("protection")}</dt>
|
||||
<dd className="flex gap-1">
|
||||
<LevelBadge level={asset.confidentiality} prefix="C" />
|
||||
<LevelBadge level={asset.integrity} prefix="I" />
|
||||
<LevelBadge level={asset.availability} prefix="A" />
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("tags")}</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{asset.tags.length === 0
|
||||
? tc("none")
|
||||
: asset.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("linkedRisks")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("linkedRisksPlaceholder")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("relations")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground">{t("relationHint")}</p>
|
||||
{asset.relationsFrom.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1">
|
||||
{asset.relationsFrom.map((rel) => (
|
||||
<li key={rel.id} className="flex items-center gap-2">
|
||||
<Link href={`/assets/${rel.relatedAsset.id}`} className="hover:underline">
|
||||
{rel.relatedAsset.name}
|
||||
</Link>
|
||||
{canWrite && (
|
||||
<form action={removeAssetRelation.bind(null, asset.id, rel.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground">{t("relationReverseHint")}</p>
|
||||
{asset.relationsTo.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1">
|
||||
{asset.relationsTo.map((rel) => (
|
||||
<li key={rel.id}>
|
||||
<Link href={`/assets/${rel.asset.id}`} className="hover:underline">
|
||||
{rel.asset.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{canWrite && otherAssets.length > 0 && (
|
||||
<form action={addRelationAction} className="flex gap-2 border-t pt-3">
|
||||
<select
|
||||
name="relatedAssetId"
|
||||
required
|
||||
className="h-9 flex-1 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
{otherAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("addRelation")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("processes")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{asset.processAssets.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1">
|
||||
{asset.processAssets.map((pa) => (
|
||||
<li key={pa.id} className="flex items-center gap-2">
|
||||
<Link href={`/processes/${pa.process.id}`} className="hover:underline">
|
||||
{pa.process.name}
|
||||
</Link>
|
||||
<Badge variant={pa.role === "PRIMARY" ? "default" : "secondary"}>
|
||||
{tRole(pa.role)}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-muted-foreground">{t("inheritedNote")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { createAsset } from "@/server/actions/assets";
|
||||
import { AssetForm } from "@/components/asset-form";
|
||||
|
||||
export default async function NewAssetPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const t = await getTranslations("assets");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("createTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<AssetForm action={createAsset} users={users} cancelHref="/assets" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { AssetStatus, AssetType } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
import { ModuleTabs } from "@/components/module-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
|
||||
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
|
||||
|
||||
export default async function AssetsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; type?: string; status?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:read");
|
||||
const t = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const { q, type, status } = await searchParams;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const assets = await db.asset.findMany({
|
||||
where: {
|
||||
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
|
||||
...(type && ASSET_TYPES.includes(type as AssetType) ? { type: type as AssetType } : {}),
|
||||
...(status && ASSET_STATUS.includes(status as AssetStatus)
|
||||
? { status: status as AssetStatus }
|
||||
: {}),
|
||||
},
|
||||
include: { owner: { select: { name: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const canWrite = hasPermission(session, "asset:write");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/assets/new" />}>
|
||||
<Plus className="size-4" /> {t("newAsset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<ModuleTabs
|
||||
active="/assets"
|
||||
tabs={[
|
||||
{ href: "/assets", label: t("tabAssets") },
|
||||
{ href: "/processes", label: t("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form method="GET" className="mt-4 flex flex-wrap gap-2">
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder={tc("search")}
|
||||
className="w-56"
|
||||
/>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue={type ?? ""}
|
||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
<option value="">{t("allTypes")}</option>
|
||||
{ASSET_TYPES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tType(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={status ?? ""}
|
||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
<option value="">{t("allStatus")}</option>
|
||||
{ASSET_STATUS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tStatus(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary">
|
||||
{tc("search").replace("…", "")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("protection")} (C/I/A)</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assets.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{assets.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell>
|
||||
<Link href={`/assets/${asset.id}`} className="font-medium hover:underline">
|
||||
{asset.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{tType(asset.type)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex gap-1">
|
||||
<LevelBadge level={asset.confidentiality} prefix="C" />
|
||||
<LevelBadge level={asset.integrity} prefix="I" />
|
||||
<LevelBadge level={asset.availability} prefix="A" />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{asset.owner?.name ?? tc("none")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{tStatus(asset.status)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("dashboard");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<Card className="mt-6 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("welcome", { name: session.user.name ?? "" })}</CardTitle>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
ShieldAlert,
|
||||
ClipboardCheck,
|
||||
KanbanSquare,
|
||||
Siren,
|
||||
BookOpenText,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
FolderCheck,
|
||||
Truck,
|
||||
LineChart,
|
||||
} from "lucide-react";
|
||||
import { auth, signOut } from "@/server/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("nav");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const nav = [
|
||||
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
|
||||
{ href: "/assets", label: t("assetsBia"), icon: Boxes, enabled: true, match: ["/assets", "/processes"] },
|
||||
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: false },
|
||||
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: false },
|
||||
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: false },
|
||||
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: false },
|
||||
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: false },
|
||||
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
|
||||
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: false },
|
||||
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
|
||||
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: false },
|
||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
||||
];
|
||||
|
||||
async function logout() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r bg-sidebar">
|
||||
<div className="flex h-14 items-center border-b px-4">
|
||||
<Link href="/dashboard" className="font-semibold tracking-tight">
|
||||
{tc("appName")}
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||
{nav.map((item) =>
|
||||
item.enabled ? (
|
||||
<NavLink key={item.href} href={item.href} match={item.match}>
|
||||
<item.icon className="size-4" />
|
||||
{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"
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
{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">
|
||||
{tc("logout")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { updateProcess } from "@/server/actions/processes";
|
||||
import { ProcessForm } from "@/components/process-form";
|
||||
|
||||
export default async function EditProcessPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const t = await getTranslations("processes");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const process = await db.process.findUnique({ where: { id } });
|
||||
if (!process) notFound();
|
||||
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const action = updateProcess.bind(null, process.id);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("editTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<ProcessForm
|
||||
action={action}
|
||||
process={process}
|
||||
users={users}
|
||||
cancelHref={`/processes/${process.id}`}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Pencil, Trash2, X } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import {
|
||||
assignAsset,
|
||||
deleteProcess,
|
||||
saveBia,
|
||||
unassignAsset,
|
||||
} from "@/server/actions/processes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
|
||||
const LEVELS = [1, 2, 3, 4] as const;
|
||||
|
||||
export default async function ProcessDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:read");
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tRole = await getTranslations("processRole");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const { id } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const process = await db.process.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
bia: true,
|
||||
processAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { role: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!process) notFound();
|
||||
|
||||
const canWrite = hasPermission(session, "bia:write");
|
||||
const assignedIds = process.processAssets.map((pa) => pa.assetId);
|
||||
const availableAssets = canWrite
|
||||
? await db.asset.findMany({
|
||||
where: { id: { notIn: assignedIds } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
||||
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
|
||||
const selectClass =
|
||||
"h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const bia = process.bia;
|
||||
|
||||
const assetList = (items: typeof primary) => {
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{items.map((pa) => (
|
||||
<li key={pa.id} className="flex items-center gap-2">
|
||||
<Link href={`/assets/${pa.asset.id}`} className="hover:underline">
|
||||
{pa.asset.name}
|
||||
</Link>
|
||||
<span className="flex gap-1">
|
||||
<LevelBadge level={pa.asset.confidentiality} prefix="C" />
|
||||
<LevelBadge level={pa.asset.integrity} prefix="I" />
|
||||
<LevelBadge level={pa.asset.availability} prefix="A" />
|
||||
</span>
|
||||
{canWrite && (
|
||||
<form action={unassignAsset.bind(null, process!.id, pa.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" nativeButton={false} render={<Link href="/processes" />}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{process.name}</h1>
|
||||
{bia && (
|
||||
<LevelBadge level={bia.criticality} label={tCrit(String(bia.criticality))} />
|
||||
)}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/processes/${process.id}/edit`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
<form action={deleteProcess.bind(null, process.id)}>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{t("description")}</dt>
|
||||
<dd className="whitespace-pre-wrap">{process.description ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{process.owner?.name ?? tc("none")}</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("linkedRisks")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("linkedRisksPlaceholder")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("assets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{t("primaryAssets")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
||||
{primary.length === 0 ? <p>{tc("none")}</p> : assetList(primary)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t("secondaryAssets")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
||||
{secondary.length === 0 ? <p>{tc("none")}</p> : assetList(secondary)}
|
||||
</div>
|
||||
{canWrite && availableAssets.length > 0 && (
|
||||
<form
|
||||
action={assignAsset.bind(null, process.id)}
|
||||
className="flex flex-wrap gap-2 border-t pt-3"
|
||||
>
|
||||
<select name="assetId" required className={`${selectClass} flex-1`}>
|
||||
{availableAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="role" defaultValue="SECONDARY" className={selectClass}>
|
||||
<option value="PRIMARY">{tRole("PRIMARY")}</option>
|
||||
<option value="SECONDARY">{tRole("SECONDARY")}</option>
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("assignAsset")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("bia")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!canWrite && !bia && (
|
||||
<p className="text-sm text-muted-foreground">{t("noBia")}</p>
|
||||
)}
|
||||
{!canWrite && bia && (
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">RTO</dt>
|
||||
<dd>{bia.rtoHours != null ? `${bia.rtoHours} h` : tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">RPO</dt>
|
||||
<dd>{bia.rpoHours != null ? `${bia.rpoHours} h` : tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">MTD</dt>
|
||||
<dd>{bia.mtdHours != null ? `${bia.mtdHours} h` : tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("impact")}</dt>
|
||||
<dd className="flex gap-1">
|
||||
<LevelBadge level={bia.impactC} prefix="C" />
|
||||
<LevelBadge level={bia.impactI} prefix="I" />
|
||||
<LevelBadge level={bia.impactA} prefix="A" />
|
||||
</dd>
|
||||
</dl>
|
||||
)}
|
||||
{canWrite && (
|
||||
<form action={saveBia.bind(null, process.id)} className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["rtoHours", t("rto"), t("rtoLong"), bia?.rtoHours],
|
||||
["rpoHours", t("rpo"), t("rpoLong"), bia?.rpoHours],
|
||||
["mtdHours", t("mtd"), t("mtdLong"), bia?.mtdHours],
|
||||
] as const
|
||||
).map(([name, label, hint, value]) => (
|
||||
<div key={name}>
|
||||
<Label htmlFor={name} title={hint}>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={value ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend className="font-medium">{t("impact")}</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["impactC", tAssets("confidentiality"), bia?.impactC],
|
||||
["impactI", tAssets("integrity"), bia?.impactI],
|
||||
["impactA", tAssets("availability"), bia?.impactA],
|
||||
] as const
|
||||
).map(([name, label, value]) => (
|
||||
<div key={name}>
|
||||
<Label htmlFor={name}>{label}</Label>
|
||||
<select
|
||||
id={name}
|
||||
name={name}
|
||||
defaultValue={value ?? 1}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l} — {tCrit(String(l))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div>
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
defaultValue={bia?.notes ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { createProcess } from "@/server/actions/processes";
|
||||
import { ProcessForm } from "@/components/process-form";
|
||||
|
||||
export default async function NewProcessPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const t = await getTranslations("processes");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const users = await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("createTitle")}</h1>
|
||||
<div className="mt-6">
|
||||
<ProcessForm action={createProcess} users={users} cancelHref="/processes" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
import { ModuleTabs } from "@/components/module-tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export default async function ProcessesPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:read");
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const processes = await db.process.findMany({
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
bia: { select: { criticality: true, rtoHours: true, mtdHours: true } },
|
||||
_count: { select: { processAssets: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const canWrite = hasPermission(session, "bia:write");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">{tAssets("title")}</h1>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/processes/new" />}>
|
||||
<Plus className="size-4" /> {t("newProcess")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<ModuleTabs
|
||||
active="/processes"
|
||||
tabs={[
|
||||
{ href: "/assets", label: tAssets("tabAssets") },
|
||||
{ href: "/processes", label: tAssets("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
<TableHead>{t("criticality")}</TableHead>
|
||||
<TableHead>RTO</TableHead>
|
||||
<TableHead>MTD</TableHead>
|
||||
<TableHead>{t("assets")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{processes.map((process) => (
|
||||
<TableRow key={process.id}>
|
||||
<TableCell>
|
||||
<Link href={`/processes/${process.id}`} className="font-medium hover:underline">
|
||||
{process.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.owner?.name ?? tc("none")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{process.bia ? (
|
||||
<LevelBadge
|
||||
level={process.bia.criticality}
|
||||
label={tCrit(String(process.bia.criticality))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("noBia")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.bia?.rtoHours != null ? `${process.bia.rtoHours} h` : tc("none")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.bia?.mtdHours != null ? `${process.bia.mtdHours} h` : tc("none")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process._count.processAssets}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth, signOut } from "@/server/auth";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
async function logout() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 p-6">
|
||||
<header className="flex items-center justify-between border-b border-slate-200 pb-4">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<form action={logout}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100"
|
||||
>
|
||||
{tc("logout")}
|
||||
</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<section className="mt-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<p className="text-lg">{t("welcome", { name: session.user.name ?? "" })}</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-slate-500">{t("tenant")}</dt>
|
||||
<dd>{session.user.tenantSlug}</dd>
|
||||
<dt className="text-slate-500">{t("roles")}</dt>
|
||||
<dd>{session.user.roles.join(", ")}</dd>
|
||||
</dl>
|
||||
<p className="mt-6 text-sm text-slate-400">{t("placeholder")}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+123
-7
@@ -1,21 +1,137 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@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). */
|
||||
:root {
|
||||
--background: #f8fafc;
|
||||
--foreground: #0f172a;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user