fix(ux): editor click bug (@js→index), self-hosted fonts, split-brand auth, R14

- Fix Alpine "Invalid or unexpected token" on file/service/key actions: @js()
  does NOT compile inside a Blade component (x-btn) attribute, so a literal
  @js(...) reached the DOM and Alpine choked on it. Switched all 10 wire:click
  arguments to loop-index lookups (files: open/go/download/edit/confirmDelete;
  services: confirm; keys: confirmKeyRemoval) — the component resolves the value
  server-side by index. {{ $loop->index }} compiles in component attributes; @js
  does not.
- Self-host fonts (R14): Chakra Petch / Space Grotesk / JetBrains Mono as local
  .woff2 in resources/fonts/, declared via @font-face in app.css, Vite-bundled
  with relative urls so they resolve in dev AND the prod build. No Google Fonts
  / CDN link or @import. New rule R14 added to rules.md + CLAUDE.md.
- Auth redesign (split-brand, per reference): new auth layout with a brand panel
  (faux terminal + glow mesh, hidden below lg) + redesigned login,
  two-factor-challenge, two-factor-setup, password-change forms.
- x-btn: add size="lg" (h-11, >=44px touch target) for the full-width auth CTA;
  one shared button component, no bespoke styles.
- Remove unused Laravel welcome.blade.php (was full of raw hex — R3 cleanup).

Verified (R12): /login 200 / 0 console errors, fonts load (Chakra+Grotesk+JB),
brand panel visible @1440; file-editor REAL click -> modal opens, textarea
renders, 0 Alpine errors; editor load/save roundtrip (/etc/hostname='debian');
all 7 routes 200 / 0 console errors. Full rules audit R1-R14 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-13 01:16:31 +02:00
parent f418ab35ab
commit aa7a7b1db8
23 changed files with 316 additions and 327 deletions

View File

@ -49,7 +49,7 @@ dashboard**, connecting **agentless over SSH**. The operator sees and steers the
| SSH | **phpseclib** (exec + SFTP) | | SSH | **phpseclib** (exec + SFTP) |
| Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade | | Charts | JS lib (ApexCharts / Chart.js / uPlot) as an **Alpine island** in Blade |
| Icons | **Lucide**, inline SVG via a Blade `x-icon` component | | Icons | **Lucide**, inline SVG via a Blade `x-icon` component |
| Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — self-host `.woff2`, no CDN in prod | | Fonts | **Chakra Petch** (display) · **Space Grotesk** (sans) · **JetBrains Mono** (numbers/paths) — **self-hosted only** (`public/fonts/*.woff2` + `@font-face` in `app.css`), **never a CDN/Google Fonts link or `@import`** (R14) |
> **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**. > **Version note (2026-06-11):** handoff §3 specifies Laravel 12; **Laravel 13** is used (it is the current release; user decision). Livewire 3 / Tailwind 4 / wire-elements/modal / Reverb are unaffected; PHP pinned to **8.3**.
@ -156,6 +156,9 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
PK (`getRouteKeyName(): 'uuid'`). (R11) PK (`getRouteKeyName(): 'uuid'`). (R11)
- **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not - **Route paths + names are English**, always — `/settings` not `/einstellungen`, `/files` not
`/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13) `/dateien`. German lives in the visible nav label, never in the `href`/route name. (R13)
- **Fonts self-hosted only**`.woff2` in `resources/fonts/` (Vite-bundled, relative
`url('../fonts/…')`) + `@font-face` in `app.css`; never a Google Fonts / CDN `<link>` or
`@import`. (R14)
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid - **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7) 4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
- **Docs language:** these meta-docs are in English; **UI strings are German**. - **Docs language:** these meta-docs are in English; **UI strings are German**.

View File

@ -53,14 +53,20 @@ class Index extends Component
$this->ready = true; $this->ready = true;
} }
public function open(string $name): void public function open(int $index): void
{ {
$name = $this->entries[$index]['name'] ?? null;
if ($name === null) {
return;
}
$this->path = rtrim($this->path, '/').'/'.$name; $this->path = rtrim($this->path, '/').'/'.$name;
$this->load(); $this->load();
} }
public function go(string $path): void public function go(int $index): void
{ {
$path = $this->crumbs[$index]['path'] ?? '/';
$this->path = $path !== '' ? $path : '/'; $this->path = $path !== '' ? $path : '/';
$this->load(); $this->load();
} }
@ -92,10 +98,11 @@ class Index extends Component
} }
/** Stream a remote file to the browser as a download (SFTP get). */ /** Stream a remote file to the browser as a download (SFTP get). */
public function download(string $name, FleetService $fleet) public function download(int $index, FleetService $fleet)
{ {
$name = $this->entries[$index]['name'] ?? null;
$active = $this->activeServer(); $active = $this->activeServer();
if (! $active || ! $active->credential_exists) { if ($name === null || ! $active || ! $active->credential_exists) {
return null; return null;
} }
@ -111,10 +118,11 @@ class Index extends Component
} }
/** Open the view/edit modal for a file. */ /** Open the view/edit modal for a file. */
public function edit(string $name): void public function edit(int $index): void
{ {
$name = $this->entries[$index]['name'] ?? null;
$active = $this->activeServer(); $active = $this->activeServer();
if (! $active) { if ($name === null || ! $active) {
return; return;
} }
@ -156,8 +164,13 @@ class Index extends Component
* Delete a file (R5): opens the confirm modal, which writes the AuditEvent * Delete a file (R5): opens the confirm modal, which writes the AuditEvent
* and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later. * and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later.
*/ */
public function confirmDelete(string $name): void public function confirmDelete(int $index): void
{ {
$name = $this->entries[$index]['name'] ?? null;
if ($name === null) {
return;
}
$this->dispatch('openModal', $this->dispatch('openModal',
component: 'modals.confirm-action', component: 'modals.confirm-action',
arguments: [ arguments: [

View File

@ -95,8 +95,16 @@ class Show extends Component
* Revoke an SSH key (R5): opens the confirm modal, which writes the * Revoke an SSH key (R5): opens the confirm modal, which writes the
* AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later. * AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later.
*/ */
public function confirmKeyRemoval(string $fingerprint, string $comment): void public function confirmKeyRemoval(int $index): void
{ {
$key = $this->sshKeys[$index] ?? null;
if ($key === null) {
return;
}
$fingerprint = $key['fingerprint'];
$comment = $key['comment'];
$this->dispatch('openModal', $this->dispatch('openModal',
component: 'modals.confirm-action', component: 'modals.confirm-action',
arguments: [ arguments: [

View File

@ -61,8 +61,13 @@ class Index extends Component
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which * modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
* applyService() handles. SSH exec (systemctl) is wired in behind this later. * applyService() handles. SSH exec (systemctl) is wired in behind this later.
*/ */
public function confirm(string $op, string $name): void public function confirm(string $op, int $index): void
{ {
$name = $this->filteredServices[$index]['name'] ?? null;
if ($name === null) {
return;
}
[$heading, $phrase, $label, $action, $icon] = match ($op) { [$heading, $phrase, $label, $action, $icon] = match ($op) {
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'], 'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'], 'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],

View File

@ -6,6 +6,50 @@
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
/* ── Self-hosted fonts (R14 — local only, never a CDN/@import from Google) ─ */
@font-face {
font-family: 'Chakra Petch';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/chakra-petch-400.woff2') format('woff2');
}
@font-face {
font-family: 'Chakra Petch';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../fonts/chakra-petch-500.woff2') format('woff2');
}
@font-face {
font-family: 'Chakra Petch';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../fonts/chakra-petch-600.woff2') format('woff2');
}
@font-face {
font-family: 'Chakra Petch';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../fonts/chakra-petch-700.woff2') format('woff2');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url('../fonts/space-grotesk.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('../fonts/jetbrains-mono.woff2') format('woff2');
}
/* ── Clusev design tokens — "Tactical Terminal" (handoff §6) ───────────── */ /* ── Clusev design tokens — "Tactical Terminal" (handoff §6) ───────────── */
@theme { @theme {
/* surfaces */ /* surfaces */

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,4 @@
@props(['variant' => 'secondary', 'icon' => false, 'href' => null]) @props(['variant' => 'secondary', 'icon' => false, 'href' => null, 'size' => 'sm'])
@php @php
// One button style for the whole app — compact, consistent (R10). // One button style for the whole app — compact, consistent (R10).
$variants = [ $variants = [
@ -9,10 +9,14 @@
'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink', 'ghost' => 'text-ink-2 hover:bg-raised hover:text-ink',
'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline', 'ghost-danger' => 'text-ink-3 hover:bg-offline/10 hover:text-offline',
]; ];
$shape = $icon ? 'h-8 w-8 justify-center' : 'h-8 px-3'; // sm = compact toolbar/row buttons; lg = full-height primary CTA (≥44px touch target, R7).
$classes = 'inline-flex items-center gap-1.5 rounded-md text-xs font-medium transition-colors ' $sizes = [
'sm' => ($icon ? 'h-8 w-8' : 'h-8 px-3').' text-xs',
'lg' => ($icon ? 'h-11 w-11' : 'h-11 px-4').' text-sm',
];
$classes = 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors '
.'disabled:opacity-50 disabled:pointer-events-none ' .'disabled:opacity-50 disabled:pointer-events-none '
.$shape.' '.($variants[$variant] ?? $variants['secondary']); .($sizes[$size] ?? $sizes['sm']).' '.($variants[$variant] ?? $variants['secondary']);
@endphp @endphp
@if ($href) @if ($href)
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a> <a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>

View File

@ -3,6 +3,7 @@
// Lucide SVG paths (24x24, stroke). Add icons here as needed. // Lucide SVG paths (24x24, stroke). Add icons here as needed.
$paths = [ $paths = [
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>', 'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>', 'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>', 'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',

View File

@ -8,20 +8,73 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
</head> </head>
<body class="grid min-h-screen place-items-center bg-void px-4 py-8 font-sans text-ink antialiased selection:bg-accent/25"> <body class="min-h-screen bg-void font-sans text-ink antialiased selection:bg-accent/25">
<div class="relative z-10 w-full max-w-sm"> <div class="grid min-h-screen lg:grid-cols-[1.05fr_0.95fr]">
<div class="mb-6 flex items-center justify-center gap-2.5"> {{-- Brand panel hidden below lg --}}
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]"> <aside class="relative hidden overflow-hidden border-r border-line bg-base p-10 lg:flex lg:flex-col lg:justify-between xl:p-12">
<x-icon name="server" class="h-5 w-5" /> {{-- atmospheric glow mesh --}}
</span> <div class="pointer-events-none absolute -left-28 -top-28 h-96 w-96 rounded-full bg-accent/10 blur-3xl" aria-hidden="true"></div>
<span class="font-display text-2xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span> <div class="pointer-events-none absolute -bottom-32 -right-24 h-96 w-96 rounded-full bg-cyan/[0.07] blur-3xl" aria-hidden="true"></div>
</div>
<div class="rounded-lg border border-line bg-surface p-6 shadow-panel"> {{-- top: wordmark --}}
{{ $slot }} <div class="relative z-10 flex items-center gap-2.5">
</div> <span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
<x-icon name="server" class="h-5 w-5" />
</span>
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
</div>
<p class="mt-4 text-center font-mono text-[11px] text-ink-4">Fleet Control · agentless über SSH</p> {{-- middle: headline + faux terminal --}}
<div class="relative z-10 flex max-w-md flex-col gap-7">
<div class="space-y-3">
<h1 class="text-balance font-display text-4xl font-bold leading-[1.12] tracking-tight text-ink">
Eine Konsole für deine <span class="text-accent">gesamte Flotte</span>.
</h1>
<p class="max-w-sm text-sm leading-relaxed text-ink-2">
Agentenlos über SSH. Metriken, Dienste, Dateien und ein lückenloses Audit-Log sicher aus einem Panel gesteuert.
</p>
</div>
<div class="overflow-hidden rounded-lg border border-line bg-inset shadow-panel">
<div class="flex items-center gap-2 border-b border-line bg-raised px-3 py-2.5">
<span class="flex gap-1.5">
<span class="h-2 w-2 rounded-full bg-offline/50"></span>
<span class="h-2 w-2 rounded-full bg-warning/50"></span>
<span class="h-2 w-2 rounded-full bg-online/50"></span>
</span>
<span class="font-mono text-[10px] text-ink-4">ssh · control-plane</span>
</div>
<div class="space-y-1.5 p-4 font-mono text-xs leading-relaxed">
<p class="text-ink-2"><span class="text-ink-4">$</span> clusev connect <span class="text-accent-text">10.10.90.0/24</span></p>
<p class="text-online">24 Hosts erreichbar · Host-Keys gepinnt</p>
<p class="text-ink-2"><span class="text-ink-4">$</span> fleet status <span class="text-accent-text">--live</span></p>
<p class="text-ink-3">cpu 31% · mem 48% · load 0.86 · 0 Alarme</p>
<p class="flex items-center text-ink-2"><span class="text-ink-4">$</span><span class="ml-2 inline-block h-3.5 w-[7px] animate-pulse bg-accent"></span></p>
</div>
</div>
</div>
{{-- bottom: copyright + status --}}
<div class="relative z-10 flex items-center justify-between gap-4 font-mono text-[11px] text-ink-4">
<span>© {{ date('Y') }} Clusev · Fleet Control</span>
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-online"></span> betriebsbereit</span>
</div>
</aside>
{{-- Form panel --}}
<main class="relative flex items-center justify-center px-5 py-10 sm:px-8">
<div class="w-full max-w-sm">
{{-- wordmark for small screens (brand panel hidden) --}}
<div class="mb-8 flex items-center gap-2.5 lg:hidden">
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
<x-icon name="server" class="h-5 w-5" />
</span>
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
</div>
{{ $slot }}
</div>
</main>
</div> </div>
@livewireScripts @livewireScripts
</body> </body>

View File

@ -1,33 +1,51 @@
<div> @php
<h1 class="font-display text-lg font-semibold text-ink">Anmelden</h1> $field = 'h-11 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
<p class="mt-1 text-sm text-ink-3">Zugang zum Clusev-Control-Panel.</p> $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<form wire:submit="authenticate" class="mt-5 space-y-4"> <div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Control Plane</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Anmelden</h1>
<p class="font-mono text-xs text-ink-3">Zugang zum Clusev-Panel.</p>
</div>
<form wire:submit="authenticate" class="space-y-4">
<div> <div>
<label for="email" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">E-Mail</label> <label for="email" class="{{ $label }}">E-Mail</label>
<input wire:model="email" id="email" type="email" autocomplete="username" autofocus <input wire:model="email" id="email" type="email" autocomplete="username" autofocus
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink placeholder:text-ink-4 focus-visible:border-accent/50" class="{{ $field }}" placeholder="admin@clusev.local" />
placeholder="admin@clusev.local" /> @error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@error('email') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
<label for="password" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Passwort</label> <label for="password" class="{{ $label }}">Passwort</label>
<input wire:model="password" id="password" type="password" autocomplete="current-password" <input wire:model="password" id="password" type="password" autocomplete="current-password"
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink placeholder:text-ink-4 focus-visible:border-accent/50" class="{{ $field }}" placeholder="••••••••" />
placeholder="••••••••" /> @error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@error('password') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div> </div>
<label class="flex items-center gap-2 text-sm text-ink-2"> <label class="flex items-center gap-2 font-mono text-xs text-ink-2">
<input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" /> <input wire:model="remember" type="checkbox" class="h-4 w-4 rounded border-line bg-inset accent-accent" />
Angemeldet bleiben Angemeldet bleiben
</label> </label>
<button type="submit" wire:loading.attr="disabled" wire:target="authenticate" <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="authenticate">
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60"> <svg wire:loading wire:target="authenticate" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<span wire:loading.remove wire:target="authenticate">Anmelden</span> <span wire:loading.remove wire:target="authenticate">Anmelden</span>
<span wire:loading wire:target="authenticate">Prüfe…</span> <span wire:loading wire:target="authenticate">Prüfe…</span>
</button> </x-btn>
</form> </form>
<div class="flex items-center gap-3">
<span class="h-px flex-1 bg-line"></span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-ink-4">Gesichert</span>
<span class="h-px flex-1 bg-line"></span>
</div>
<p class="flex items-start gap-2 font-mono text-[11px] leading-relaxed text-ink-4">
<x-icon name="shield" class="mt-px h-3.5 w-3.5 shrink-0 text-online" />
Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log.
</p>
</div> </div>

View File

@ -1,31 +1,38 @@
<div> @php
<h1 class="font-display text-lg font-semibold text-ink">Passwort ändern</h1> $field = 'h-11 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
<p class="mt-1 text-sm text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p> $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<form wire:submit="update" class="mt-5 space-y-4"> <div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Sicherheit</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Passwort ändern</h1>
<p class="font-mono text-xs leading-relaxed text-ink-3">Lege ein eigenes Passwort fest (min. 12 Zeichen, Groß-/Kleinschreibung + Zahl).</p>
</div>
<form wire:submit="update" class="space-y-4">
<div> <div>
<label for="current" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Aktuelles Passwort</label> <label for="current" class="{{ $label }}">Aktuelles Passwort</label>
<input wire:model="current" id="current" type="password" autocomplete="current-password" <input wire:model="current" id="current" type="password" autocomplete="current-password" class="{{ $field }}" />
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" /> @error('current') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@error('current') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
<label for="password" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Neues Passwort</label> <label for="password" class="{{ $label }}">Neues Passwort</label>
<input wire:model="password" id="password" type="password" autocomplete="new-password" <input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" />
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" /> @error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@error('password') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
<label for="password_confirmation" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Neues Passwort bestätigen</label> <label for="password_confirmation" class="{{ $label }}">Neues Passwort bestätigen</label>
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" <input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" />
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus-visible:border-accent/50" />
</div> </div>
<button type="submit" wire:loading.attr="disabled" wire:target="update" <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="update">
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60"> <svg wire:loading wire:target="update" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Speichern &amp; weiter <span wire:loading.remove wire:target="update">Speichern &amp; weiter</span>
</button> <span wire:loading wire:target="update">Speichere…</span>
</x-btn>
</form> </form>
</div> </div>

View File

@ -1,19 +1,34 @@
<div> @php
<h1 class="font-display text-lg font-semibold text-ink">Zwei-Faktor-Bestätigung</h1> $field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
<p class="mt-1 text-sm text-ink-3">Gib den 6-stelligen Code aus deiner Authenticator-App ein.</p> $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<form wire:submit="verify" class="mt-5 space-y-4"> <div class="space-y-7">
<div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Bestätigung</h1>
<p class="font-mono text-xs text-ink-3">6-stelliger Code aus deiner Authenticator-App.</p>
</div>
<form wire:submit="verify" class="space-y-4">
<div> <div>
<label for="code" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Code</label> <label for="code" class="{{ $label }}">Code</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" autofocus <input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center font-mono text-lg tracking-[0.4em] text-ink placeholder:text-ink-4 focus-visible:border-accent/50" class="{{ $field }}" placeholder="000000" />
placeholder="000000" /> @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@error('code') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div> </div>
<button type="submit" wire:loading.attr="disabled" wire:target="verify" <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="verify">
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60"> <svg wire:loading wire:target="verify" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Bestätigen <span wire:loading.remove wire:target="verify">Bestätigen</span>
</button> <span wire:loading wire:target="verify">Prüfe…</span>
</x-btn>
</form> </form>
<div class="flex justify-center">
<a href="{{ route('login') }}" wire:navigate class="inline-flex items-center gap-1.5 font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> Zurück zur Anmeldung
</a>
</div>
</div> </div>

View File

@ -1,30 +1,38 @@
<div> @php
<h1 class="font-display text-lg font-semibold text-ink">Zwei-Faktor einrichten</h1> $field = 'h-14 w-full rounded-md border border-line bg-inset text-center font-mono text-2xl font-semibold tracking-[0.5em] text-ink caret-accent placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
<p class="mt-1 text-sm text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, ) und bestätige mit einem Code.</p> $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="mt-5 flex justify-center"> <div class="space-y-6">
<div class="rounded-md bg-ink p-2"> <div class="space-y-1.5">
<p class="font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-accent-text">Zwei-Faktor</p>
<h1 class="font-display text-2xl font-bold tracking-tight text-ink">Einrichten</h1>
<p class="font-mono text-xs leading-relaxed text-ink-3">Scanne den Code mit einer Authenticator-App (Aegis, Google Authenticator, ) und bestätige mit einem Code.</p>
</div>
<div class="flex flex-col items-center gap-3">
<div class="rounded-lg border border-line bg-ink p-3">
<img src="{{ $this->qrCode() }}" alt="2FA-QR-Code" class="h-44 w-44" /> <img src="{{ $this->qrCode() }}" alt="2FA-QR-Code" class="h-44 w-44" />
</div> </div>
<div class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center">
<p class="font-mono text-[10px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p>
<p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p>
</div>
</div> </div>
<div class="mt-3 text-center"> <form wire:submit="confirm" class="space-y-4">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-4">Geheimnis (manuell)</p>
<p class="mt-1 break-all font-mono text-xs text-ink-2">{{ $secret }}</p>
</div>
<form wire:submit="confirm" class="mt-5 space-y-4">
<div> <div>
<label for="code" class="mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3">Bestätigungs-Code</label> <label for="code" class="{{ $label }}">Bestätigungs-Code</label>
<input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" autofocus <input wire:model="code" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" autofocus
class="w-full rounded-md border border-line bg-inset px-3 py-2 text-center font-mono text-lg tracking-[0.4em] text-ink placeholder:text-ink-4 focus-visible:border-accent/50" class="{{ $field }}" placeholder="000000" />
placeholder="000000" /> @error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
@error('code') <p class="mt-1.5 text-xs text-offline">{{ $message }}</p> @enderror
</div> </div>
<button type="submit" wire:loading.attr="disabled" wire:target="confirm" <x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="confirm">
class="flex min-h-11 w-full items-center justify-center rounded-md bg-accent px-4 py-2 font-display text-sm font-semibold uppercase tracking-wide text-void transition-colors hover:bg-accent-bright disabled:opacity-60"> <svg wire:loading wire:target="confirm" class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
Aktivieren <span wire:loading.remove wire:target="confirm">Aktivieren</span>
</button> <span wire:loading wire:target="confirm">Prüfe…</span>
</x-btn>
</form> </form>
</div> </div>

View File

@ -37,7 +37,7 @@
@if (! $loop->first) @if (! $loop->first)
<span class="font-mono text-xs text-ink-4">/</span> <span class="font-mono text-xs text-ink-4">/</span>
@endif @endif
<button type="button" wire:click="go(@js($crumb['path']))" @class([ <button type="button" wire:click="go({{ $loop->index }})" @class([
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs', 'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
'text-ink-3' => $loop->last, 'text-ink-3' => $loop->last,
'text-accent-text hover:text-accent' => ! $loop->last, 'text-accent-text hover:text-accent' => ! $loop->last,
@ -90,11 +90,11 @@
</span> </span>
@if ($isDir) @if ($isDir)
<button type="button" wire:click="open(@js($e['name']))" class="min-w-0 flex-1 text-left"> <button type="button" wire:click="open({{ $loop->index }})" class="min-w-0 flex-1 text-left">
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span> <span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
</button> </button>
@else @else
<button type="button" wire:click="edit(@js($e['name']))" class="min-w-0 flex-1 truncate text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">{{ $e['name'] }}</button> <button type="button" wire:click="edit({{ $loop->index }})" class="min-w-0 flex-1 truncate text-left font-mono text-sm text-ink-2 transition-colors hover:text-accent-text">{{ $e['name'] }}</button>
@endif @endif
</div> </div>
@ -119,10 +119,10 @@
{{-- Row actions --}} {{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end"> <div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir) @unless ($isDir)
<x-btn variant="ghost" wire:click="download(@js($e['name']))">Download</x-btn> <x-btn variant="ghost" wire:click="download({{ $loop->index }})">Download</x-btn>
<x-btn variant="ghost" wire:click="edit(@js($e['name']))">Bearbeiten</x-btn> <x-btn variant="ghost" wire:click="edit({{ $loop->index }})">Bearbeiten</x-btn>
@endunless @endunless
<x-btn variant="ghost-danger" wire:click="confirmDelete(@js($e['name']))">Löschen</x-btn> <x-btn variant="ghost-danger" wire:click="confirmDelete({{ $loop->index }})">Löschen</x-btn>
</div> </div>
</div> </div>
@endforeach @endforeach

View File

@ -205,7 +205,7 @@
</div> </div>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p> <p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
</div> </div>
<x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval(@js($key['fingerprint']), @js($key['comment']))" title="Schlüssel entfernen"> <x-btn variant="ghost-danger" icon wire:click="confirmKeyRemoval({{ $loop->index }})" title="Schlüssel entfernen">
<x-icon name="trash" class="h-3.5 w-3.5" /> <x-icon name="trash" class="h-3.5 w-3.5" />
</x-btn> </x-btn>
</div> </div>

View File

@ -70,9 +70,9 @@
{{-- R5: wire to wire-elements/modal --}} {{-- R5: wire to wire-elements/modal --}}
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<x-btn variant="secondary" wire:click="confirm('start', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn> <x-btn variant="secondary" wire:click="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">Starten</x-btn>
<x-btn variant="secondary" wire:click="confirm('stop', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn> <x-btn variant="secondary" wire:click="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Stopp</x-btn>
<x-btn variant="accent" wire:click="confirm('restart', @js($svc['name']))" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn> <x-btn variant="accent" wire:click="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">Neustart</x-btn>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because one or more lines are too long

View File

@ -22,6 +22,8 @@
- [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets). - [ ] **R10** — Reused existing `@theme` tokens + Blade components (no ad-hoc colors/widgets).
- [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK. - [ ] **R11** — Records exposed in URLs are addressed by **UUID**, never the integer PK.
- [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton). - [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton).
- [ ] **R13** — route **paths + names are English** (`/settings`, not `/einstellungen`); German only in the visible label.
- [ ] **R14** — fonts are **self-hosted locally** (`resources/fonts/*.woff2` bundled by Vite + `@font-face` in `app.css`) — **no Google Fonts / CDN `<link>` or `@import`**.
- [ ] **Secrets**`git status` shows **no** `.env*`, token, key, or home-dir dotfile staged. - [ ] **Secrets**`git status` shows **no** `.env*`, token, key, or home-dir dotfile staged.
--- ---
@ -393,6 +395,37 @@ Route::get('/dateien', ...); // → use /files
--- ---
## R14 — Fonts are self-hosted locally, never loaded from a CDN
**Why.** A self-hosted control plane runs in private/air-gapped networks and must not leak
requests to third parties or break when the internet is unreachable. Every font ships **with the
app** — `.woff2` files in `public/fonts/`, declared via `@font-face` in `resources/css/app.css`.
**No** `<link href="fonts.googleapis.com">`, no `@import url(...)` from a CDN, no `fonts.gstatic.com`.
✅ **Correct**
```css
/* resources/css/app.css — local file in resources/fonts/, bundled + hashed by Vite
(relative url so it resolves on the Vite dev server AND in the prod build) */
@font-face {
font-family: 'Space Grotesk';
font-weight: 300 700;
font-display: swap;
src: url('../fonts/space-grotesk.woff2') format('woff2');
}
```
> Use a **relative** `url('../fonts/…')` (not `/fonts/…`): an absolute path resolves against the
> Vite dev-server origin in dev → 404. Relative lets Vite resolve + emit the asset for both modes.
❌ **Forbidden**
```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk"> <!-- ❌ CDN -->
```
```css
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch'); /* ❌ CDN import */
```
---
## Appendix — Secret hygiene ## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in