diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx
index 8d2f9a3..216ab14 100644
--- a/apps/web/src/app/(app)/layout.tsx
+++ b/apps/web/src/app/(app)/layout.tsx
@@ -15,32 +15,30 @@ export default function AppLayout({ children }: { children: ReactNode }) {
useEffect(() => setOpen(false), [path]);
if (loading || !me || me.pendingMfa) return
;
const link = (href: string, label: string) => {label};
+ const inGroup = (hrefs: string[]) => hrefs.some((h) => path === h || path.startsWith(h + '/'));
+ const group = (title: string, hrefs: string[], items: ReactNode) => (
+ {title}
{items}
+ );
async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); }
+ const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][];
+ const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][];
const nav = (
);
diff --git a/apps/web/src/app/(app)/ressourcen/page.tsx b/apps/web/src/app/(app)/ressourcen/page.tsx
index 0bf37e7..7c4d58a 100644
--- a/apps/web/src/app/(app)/ressourcen/page.tsx
+++ b/apps/web/src/app/(app)/ressourcen/page.tsx
@@ -46,7 +46,7 @@ export default function Ressourcen() {
useEffect(() => { if (canAssign) api('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [canAssign]);
const stale = list?.find((r) => r.stale);
return (<>
- Ressourcen
+ Ressourcen
{err && {err}}
{ok && {ok}}
{stale && Einige Angaben sind möglicherweise nicht aktuell: {stale.staleReason} Angezeigt wird der zuletzt bekannte Stand.}
@@ -55,9 +55,22 @@ export default function Ressourcen() {
GROUPS.map((g) => { const rows = list.filter((r) => g.types.includes(r.type)); if (rows.length === 0) return null; const lic = g.key === 'license'; return (
{g.title} ({rows.length})
+ {staff ? (
| Name | {!lic && Typ | }Status | Gültig bis | Stand | {lic && Lizenzschlüssel | }
- {rows.map((r) => {r.name}{staff && !r.orgId && <> ▲Nicht zugewiesen>}{canAssign && !r.orgId && { setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} /> } | {!lic && {TYPE[r.type] ?? r.type} | } |
+ {rows.map((r) =>
{r.name}{!r.orgId && <> ▲Nicht zugewiesen>}{canAssign && !r.orgId && { setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} /> } | {!lic && {TYPE[r.type] ?? r.type} | } |
{r.validUntil ? fmt(r.validUntil) : 'unbefristet'} | {fmt(r.syncedAt)}{r.stale && <> ▲Veraltet>} | {lic && {r.canReveal ? : –} | }
)}
+ ) : (
+ {rows.map((r) => (
+
+
{r.name}
+ {!lic &&
{TYPE[r.type] ?? r.type}
}
+
Gültig bis: {r.validUntil ? fmt(r.validUntil) : 'unbefristet'}
+
Stand: {fmt(r.syncedAt)}{r.stale && <> ▲Veraltet>}
+ {lic && r.canReveal &&
}
+
Details ansehen
+
+ ))}
+ )}
); })}
>);
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 5a3d1e9..6b5e02a 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -1,10 +1,11 @@
-/* Designsystem v2 "Calm Cloud": warmgraue/GitHub-dunkle Flächen, Dunkelblau als vertrauenswürdige Hauptfarbe,
- Türkis nur für aktive Navigation/technische Marker. Hell/Dunkel automatisch über prefers-color-scheme,
- zusätzlich manuell über [data-theme] umschaltbar (siehe ThemeToggle). */
+/* Designsystem v3 "KeyControl": dunkles Navy als vertrauenswürdige, dauerhaft sichtbare Seitenleiste,
+ klares Blau als Primärfarbe, helle Arbeitsfläche mit klar getrennten Bereichen. Hell/Dunkel automatisch
+ über prefers-color-scheme, zusätzlich manuell über [data-theme] umschaltbar (siehe ThemeToggle). */
:root {
- --bg: #f6f8fa; --surface: #ffffff; --surface-2: #eef1f5; --border: #e1e5eb; --text: #172033; --muted: #5b6472;
- --accent: #315efb; --accent2: #14b8a6; --accent-contrast: #ffffff; --focus: #315efb;
+ --bg: #f4f6fa; --surface: #ffffff; --surface-2: #eef1f6; --border: #e3e8f0; --text: #172033; --muted: #5b6472;
+ --accent: #3b6fd8; --accent2: #14b8a6; --accent-contrast: #ffffff; --focus: #3b6fd8;
--ok: #22a06b; --ok-bg: #e4f5ec; --info: #2e6fe0; --info-bg: #e7f1fc; --warn: #e59a17; --warn-bg: #fdf1dc; --err: #dc4c64; --err-bg: #fbe7ea; --lock: #4b5563; --lock-bg: #eceef2;
+ --nav-bg: #173568; --nav-text: #dbe4f7; --nav-muted: #8fa4d4; --nav-border: #24417f; --nav-hover: #1f4084; --nav-active-bg: #2a4d93; --nav-active-text: #ffffff;
--shadow-sm: 0 1px 2px rgba(23, 32, 51, .04), 0 1px 1px rgba(23, 32, 51, .04);
--shadow-md: 0 8px 20px rgba(23, 32, 51, .06), 0 2px 6px rgba(23, 32, 51, .04);
--radius: 12px; --radius-sm: 8px; --s1: 4px; --s2: 8px; --s3: 16px; --s4: 24px; --s5: 40px;
@@ -13,11 +14,13 @@
@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff;
--ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932;
+ --nav-bg: #0a1220; --nav-text: #cdd9f0; --nav-muted: #7c8bb0; --nav-border: #1c2740; --nav-hover: #131f36; --nav-active-bg: #1d3358; --nav-active-text: #ffffff;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35);
} }
:root[data-theme='dark'] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff;
--ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932;
+ --nav-bg: #0a1220; --nav-text: #cdd9f0; --nav-muted: #7c8bb0; --nav-border: #1c2740; --nav-hover: #131f36; --nav-active-bg: #1d3358; --nav-active-text: #ffffff;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35);
}
* { box-sizing: border-box; }
@@ -25,22 +28,30 @@ html { color-scheme: light dark; }
body { margin: 0; font: 16px/1.55 var(--font); background: var(--bg); color: var(--text); }
body, .btn, .badge, .card, input, select, a, .alert { transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease; }
h1 { font-size: 1.7rem; font-weight: 700; margin: 0 0 var(--s3); letter-spacing: -0.015em; } h2 { font-size: 1.15rem; font-weight: 600; margin: 0 0 var(--s3); } h3 { font-size: 1rem; font-weight: 600; margin: 0 0 var(--s2); }
-a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .875rem; }
+a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .9rem; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
.skip { position: absolute; left: -999px; } .skip:focus { left: var(--s3); top: var(--s3); background: var(--surface); padding: var(--s2) var(--s3); z-index: 10; }
-.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
-.nav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--s4) var(--s3); display: flex; flex-direction: column; gap: 2px; }
-.nav .brand { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.01em; padding: 0 var(--s3) var(--s4); }
-.nav a { display: block; padding: 10px var(--s3); border-radius: var(--radius-sm); color: var(--text); text-decoration: none; }
-.nav a:hover { background: var(--surface-2); }
-.nav a[aria-current='page'] { background: color-mix(in srgb, var(--accent2) 14%, transparent); color: var(--accent2); font-weight: 600; }
-.nav .group { margin: var(--s3) var(--s3) var(--s1); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
+.shell { display: grid; grid-template-columns: 288px 1fr; min-height: 100vh; }
+.nav { background: var(--nav-bg); border-right: 1px solid var(--nav-border); padding: var(--s4) var(--s3) var(--s3); display: flex; flex-direction: column; gap: 2px; }
+.nav .brand { font-weight: 700; font-size: 1.2rem; letter-spacing: -0.01em; color: #fff; padding: 0 var(--s2) var(--s4); }
+.nav a { display: block; padding: 11px var(--s3); border-radius: var(--radius-sm); color: var(--nav-text); text-decoration: none; font-size: .96rem; }
+.nav a:hover { background: var(--nav-hover); color: #fff; }
+.nav a[aria-current='page'] { background: var(--nav-active-bg); color: var(--nav-active-text); font-weight: 600; }
+.nav .group { margin: 0; }
+.nav .group > summary { list-style: none; cursor: pointer; margin: var(--s2) var(--s1) 2px; padding: 8px var(--s2); font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--nav-muted); display: flex; align-items: center; justify-content: space-between; border-radius: var(--radius-sm); }
+.nav .group > summary::-webkit-details-marker { display: none; }
+.nav .group > summary:hover { color: #fff; background: var(--nav-hover); }
+.nav .group > summary::after { content: '▸'; font-size: .7rem; transition: transform .15s ease; }
+.nav .group[open] > summary::after { transform: rotate(90deg); }
+.nav .group .items { display: flex; flex-direction: column; gap: 2px; padding-bottom: var(--s2); }
.nav .spacer { flex: 1; }
-.main { padding: var(--s5) var(--s4); max-width: 1100px; width: 100%; }
+.nav .who { padding: var(--s2); color: var(--nav-text); }
+.nav .who .org { color: #fff; font-weight: 600; }
+.main { padding: var(--s5) var(--s4); max-width: 1240px; width: 100%; }
.topbar { display: none; } .bottomnav { display: none; }
@media (max-width: 800px) {
- .shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 30% 56px 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; }
+ .shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 20% 56px 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); padding: var(--s3); background: var(--surface); border-bottom: 1px solid var(--border); }
.main { padding: var(--s4) var(--s3) calc(var(--s4) + 56px); }
.bottomnav { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 6; background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-md); padding-bottom: env(safe-area-inset-bottom, 0); }
@@ -61,7 +72,7 @@ a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--m
input, select { font: inherit; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); min-height: 44px; width: 100%; }
input:focus-visible, select:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; }
.cols { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
-table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .74rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--surface-2); }
+table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--surface-2); }
tbody tr:hover { background: var(--surface-2); }
.tablewrap { overflow-x: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); }
.tablewrap table { border: none; } .tablewrap th:first-child, .tablewrap td:first-child { padding-left: var(--s3); }
@@ -69,6 +80,12 @@ tbody tr:hover { background: var(--surface-2); }
.badge.ok { background: var(--ok-bg); color: var(--ok); } .badge.info { background: var(--info-bg); color: var(--info); } .badge.warn { background: var(--warn-bg); color: var(--warn); } .badge.err { background: var(--err-bg); color: var(--err); } .badge.lock { background: var(--lock-bg); color: var(--lock); }
.alert { border-radius: var(--radius-sm); padding: var(--s3); margin-bottom: var(--s3); border: 1px solid transparent; } .alert.err { background: var(--err-bg); color: var(--err); } .alert.ok { background: var(--ok-bg); color: var(--ok); } .alert.warn { background: var(--warn-bg); color: var(--warn); } .alert.info { background: var(--info-bg); color: var(--info); }
.empty { text-align: center; padding: var(--s5) var(--s3); color: var(--muted); }
+.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s4); }
+.pagehead h1 { margin: 0; }
+.productgrid { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
+.productcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s4); box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: 6px; }
+.productcard h3 { margin: 0; font-size: 1.05rem; }
+.productcard .id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; color: var(--muted); }
.center { min-height: 100vh; display: grid; place-items: center; padding: var(--s3); } .auth { width: 100%; max-width: 420px; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; word-break: break-all; } .codes { columns: 2; background: var(--surface-2); padding: var(--s3); border-radius: var(--radius-sm); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }