feat(ux): real metrics+context, working file manager, settings redesign, EN routes

Full feedback round — all browser-verified (R12: 7 routes 200, zero console errors)
and functionally tested live against the target host.

- Metrics are real AND honest: poller now stores cpu/mem/DISK/LOAD history (all
  four sparklines real, not synthetic); KPIs show absolute context (Memory
  "1,9 / 7,8 GB", Disk "13 / 80 GB", Load "4 Kerne"); tiles are status-coloured
  and Load is rated against core count (so "yellow" actually means load≈0.7-1.0×
  cores), not a fixed warning tint.
- File manager is functional: Hochladen (Livewire upload -> SFTP put), Download
  (SFTP get -> streamed), and view/edit (new FileEditor modal: SFTP get, binary/
  size guards, SFTP put on save + audit). File names + Bearbeiten open the editor.
- Settings redesigned (/frontend-design): account identity header + section nav
  (Profil / Sicherheit) instead of flat stacked panels.
- Routes are English (R13): /einstellungen -> /settings; rule added to rules.md +
  CLAUDE.md. Dummy data removed: the topbar "Flotte online / Uptime 42d" + dead
  bell button are gone, replaced by a real "<online>/<total> online" pill.

New: app/Livewire/Modals/FileEditor + view; FleetService get/read/write/upload +
Sftp putFromFile/size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-13 00:32:05 +02:00
parent 0a7fc4780c
commit f418ab35ab
15 changed files with 463 additions and 102 deletions

View File

@ -154,6 +154,8 @@ number/IP/path**. All tokens live in `resources/css/app.css` (R3). Token groups:
- **Destructive actions:** wire-elements/modal only — no `confirm()`/Alpine popups. (R5)
- **URLs / route binding:** records exposed in URLs use a **UUID** route key, never the integer
PK (`getRouteKeyName(): 'uuid'`). (R11)
- **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)
- **Responsive:** every screen at **375 / 768 / 1280+**; sidebar → drawer on small; KPI grid
4→2→1; tables scroll/stack; touch targets ≥ 44px. (R7)
- **Docs language:** these meta-docs are in English; **UI strings are German**.

View File

@ -43,9 +43,8 @@ class Dashboard extends Component
}
/** Pad a real history series to a minimum length for a stable sparkline. */
private function pad(array $values, int $current, int $min = 16): array
private function pad(array $values, float|int $current, int $min = 16): array
{
$values = array_map('intval', $values);
if ($values === []) {
$values = [$current];
}
@ -102,19 +101,23 @@ class Dashboard extends Component
$mem = (int) ($latest['mem'] ?? $active?->mem ?? 0);
$disk = (int) ($latest['disk'] ?? $active?->disk ?? 0);
$load = (float) ($latest['load'] ?? 0);
$cores = (int) ($latest['cores'] ?? ($active?->specs['cores'] ?? 1)) ?: 1;
$hist = $active ? $fleet->history($active) : ['cpu' => [], 'mem' => []];
$hist = $active ? $fleet->history($active) : ['cpu' => [], 'mem' => [], 'disk' => [], 'load' => []];
$cpuSeries = $this->pad($hist['cpu'], $cpu);
$memSeries = $this->pad($hist['mem'], $mem);
$diskSeries = $this->series(max(4, $disk), 40, 2);
$loadSeries = $this->series(max(8, (int) round($load * 18)), 40, 6);
$diskSeries = $this->pad($hist['disk'], $disk);
$loadSeries = $this->pad($hist['load'], $load);
$pctTone = fn (int $v): string => $v >= 90 ? 'offline' : ($v >= 75 ? 'warning' : 'online');
$loadRatio = $cores > 0 ? $load / $cores : $load;
return view('livewire.dashboard', [
'active' => $active,
'cpu' => $cpu,
'mem' => $mem,
'disk' => $disk,
'load' => round($load, 2),
'load' => $load,
'cpuSeries' => $cpuSeries,
'memSeries' => $memSeries,
'diskSeries' => $diskSeries,
@ -123,6 +126,17 @@ class Dashboard extends Component
'memTrend' => $this->trend($memSeries),
'diskTrend' => $this->trend($diskSeries),
'loadTrend' => $this->trend($loadSeries, ''),
'cpuTone' => $pctTone($cpu),
'memTone' => $pctTone($mem),
'diskTone' => $pctTone($disk),
'loadTone' => $loadRatio >= 1.0 ? 'offline' : ($loadRatio >= 0.7 ? 'warning' : 'online'),
'memSub' => isset($latest['mem_total']) && $latest['mem_total'] > 0
? number_format((float) $latest['mem_used'], 1, ',', '.').' / '.number_format((float) $latest['mem_total'], 1, ',', '.').' GB'
: null,
'diskSub' => isset($latest['disk_total']) && $latest['disk_total'] > 0
? $latest['disk_used'].' / '.$latest['disk_total'].' GB'
: null,
'loadSub' => $cores.' '.($cores === 1 ? 'Kern' : 'Kerne'),
'services' => $this->svcRows,
'events' => AuditEvent::with('server')->latest()->limit(6)->get(),
]);

View File

@ -8,13 +8,14 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
use Throwable;
#[Layout('layouts.app')]
#[Title('Dateien — Clusev')]
class Index extends Component
{
use WithFleetContext;
use WithFileUploads, WithFleetContext;
/** Current working directory in the SFTP browser. */
public string $path = '/';
@ -23,6 +24,8 @@ class Index extends Component
public bool $ready = false;
public $upload = null;
/** @var array<int, array{name:string,type:string,size:?int,perms:string,owner:string,modified:string}> */
public array $entries = [];
@ -68,6 +71,69 @@ class Index extends Component
$this->load();
}
/** Upload the selected file into the current directory (SFTP put). */
public function updatedUpload(FleetService $fleet): void
{
$this->validate(['upload' => ['file', 'max:51200']]); // 50 MB
$active = $this->activeServer();
if ($active && $active->credential_exists && $this->upload) {
try {
$name = basename($this->upload->getClientOriginalName());
$fleet->uploadFile($active, rtrim($this->path, '/').'/'.$name, $this->upload->getRealPath());
$this->dispatch('notify', message: "{$name}“ hochgeladen.");
} catch (Throwable $e) {
$this->dispatch('notify', message: 'Upload fehlgeschlagen: '.$e->getMessage());
}
}
$this->reset('upload');
$this->load();
}
/** Stream a remote file to the browser as a download (SFTP get). */
public function download(string $name, FleetService $fleet)
{
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
return null;
}
try {
$content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name));
} catch (Throwable $e) {
$this->dispatch('notify', message: $e->getMessage());
return null;
}
return response()->streamDownload(fn () => print($content), basename($name));
}
/** Open the view/edit modal for a file. */
public function edit(string $name): void
{
$active = $this->activeServer();
if (! $active) {
return;
}
$this->dispatch('openModal',
component: 'modals.file-editor',
arguments: [
'serverId' => $active->id,
'path' => rtrim($this->path, '/').'/'.basename($name),
'name' => basename($name),
],
);
}
#[On('fileSaved')]
public function reloadAfterSave(): void
{
$this->load();
}
/**
* Breadcrumb segments derived from $path (root first, then each folder).
*

View File

@ -0,0 +1,100 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/**
* View / edit a remote text file over SFTP. Loads lazily (wire:init), refuses
* binary or oversized files, and writes back on save with an AuditEvent.
*/
class FileEditor extends ModalComponent
{
public int $serverId;
public string $path;
public string $name;
public string $content = '';
public bool $binary = false;
public bool $tooBig = false;
public bool $loaded = false;
public ?string $error = null;
public function mount(int $serverId, string $path, string $name): void
{
$this->serverId = $serverId;
$this->path = $path;
$this->name = $name;
}
public function load(FleetService $fleet): void
{
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
$this->loaded = true;
return;
}
try {
$r = $fleet->readFile($server, $this->path);
$this->content = $r['content'];
$this->binary = $r['binary'];
$this->tooBig = $r['tooBig'];
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
$this->loaded = true;
}
public function save(FleetService $fleet): void
{
$this->error = null;
$server = Server::find($this->serverId);
if (! $server) {
$this->error = 'Server nicht gefunden.';
return;
}
try {
$fleet->writeFile($server, $this->path, $this->content);
} catch (Throwable $e) {
$this->error = $e->getMessage();
return;
}
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $server->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'file.edit',
'target' => $this->path,
'ip' => request()->ip(),
]);
$this->dispatch('fileSaved');
$this->dispatch('notify', message: '„'.$this->name.'" gespeichert.');
$this->closeModal();
}
public function render()
{
return view('livewire.modals.file-editor');
}
}

View File

@ -15,6 +15,8 @@ use Livewire\Component;
#[Title('Einstellungen — Clusev')]
class Index extends Component
{
public string $tab = 'profile';
public string $name = '';
public string $email = '';

View File

@ -79,15 +79,25 @@ class FleetService
.'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; '
.'echo '.self::MARK.'stat2===; head -1 /proc/stat; '
.'echo '.self::MARK.'load===; cat /proc/loadavg; '
.'echo '.self::MARK.'disk===; df -B1 --output=pcent / | tail -1';
.'echo '.self::MARK.'cores===; nproc; '
.'echo '.self::MARK.'disk===; df -B1 --output=size,used,pcent / | tail -1';
$s = $this->sections($ssh->exec($cmd));
$memTotalKb = $this->memTotalKb($s['mem'] ?? '');
$memAvailKb = (int) (preg_match('/MemAvailable:\s+(\d+)/', $s['mem'] ?? '', $mm) ? $mm[1] : 0);
$df = preg_split('/\s+/', trim($s['disk'] ?? '')); // size used pcent
return [
'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''),
'mem' => $this->memPercent($s['mem'] ?? ''),
'disk' => (int) trim(str_replace('%', '', $s['disk'] ?? '0')),
'disk' => (int) str_replace('%', '', $df[2] ?? '0'),
'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0),
'cores' => max(1, (int) trim($s['cores'] ?? '1')),
'mem_used' => $memTotalKb > 0 ? round(($memTotalKb - $memAvailKb) / 1048576, 1) : 0.0,
'mem_total' => $memTotalKb > 0 ? round($memTotalKb / 1048576, 1) : 0.0,
'disk_used' => isset($df[1]) && ctype_digit($df[1]) ? (int) round((int) $df[1] / 1_000_000_000) : 0,
'disk_total' => isset($df[0]) && ctype_digit($df[0]) ? (int) round((int) $df[0] / 1_000_000_000) : 0,
];
}
@ -114,7 +124,7 @@ class FleetService
Cache::put("metrics:latest:{$server->id}", $m, now()->addMinutes(5));
$history = Cache::get("metrics:history:{$server->id}", []);
$history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem']];
$history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem'], 'disk' => $m['disk'], 'load' => round($m['load'], 2)];
Cache::put("metrics:history:{$server->id}", array_slice($history, -40), now()->addHour());
}
@ -136,6 +146,8 @@ class FleetService
return [
'cpu' => array_map('intval', array_column($h, 'cpu')),
'mem' => array_map('intval', array_column($h, 'mem')),
'disk' => array_map('intval', array_column($h, 'disk')),
'load' => array_map('floatval', array_column($h, 'load')),
];
}
@ -373,6 +385,68 @@ class FleetService
}
}
/** Raw file content for download (capped at 50 MB). */
public function getFile(Server $server, string $path): string
{
$sftp = (new Sftp($this->vault))->connect($server);
try {
if ($sftp->size($path) > 50_000_000) {
throw new RuntimeException('Datei zu groß für den Download (>50 MB).');
}
return (string) $sftp->get($path);
} finally {
$sftp->disconnect();
}
}
/**
* Read a file for the editor: size-capped, with binary detection.
*
* @return array{content: string, binary: bool, tooBig: bool}
*/
public function readFile(Server $server, string $path, int $maxBytes = 262144): array
{
$sftp = (new Sftp($this->vault))->connect($server);
try {
$size = $sftp->size($path);
$content = $size > $maxBytes ? '' : (string) $sftp->get($path);
$binary = $content !== '' && (str_contains(substr($content, 0, 8000), "\0") || ! mb_check_encoding($content, 'UTF-8'));
return ['content' => $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
} finally {
$sftp->disconnect();
}
}
public function writeFile(Server $server, string $path, string $content): void
{
$sftp = (new Sftp($this->vault))->connect($server);
try {
if (! $sftp->put($path, $content)) {
throw new RuntimeException('Speichern fehlgeschlagen (Rechte?).');
}
} finally {
$sftp->disconnect();
}
}
public function uploadFile(Server $server, string $remotePath, string $localPath): void
{
$sftp = (new Sftp($this->vault))->connect($server);
try {
if (! $sftp->putFromFile($remotePath, $localPath)) {
throw new RuntimeException('Upload fehlgeschlagen (Rechte?).');
}
} finally {
$sftp->disconnect();
}
}
/**
* systemctl start/stop/restart. Runs directly as root, else via `sudo -n`
* (needs a root or nopasswd-sudo credential).

View File

@ -76,6 +76,16 @@ class Sftp
return $this->client()->delete($remote);
}
public function putFromFile(string $remote, string $localPath): bool
{
return $this->client()->put($remote, $localPath, SftpProtocol::SOURCE_LOCAL_FILE);
}
public function size(string $remote): int
{
return (int) ($this->client()->filesize($remote) ?: 0);
}
public function disconnect(): void
{
$this->sftp?->disconnect();

View File

@ -31,7 +31,7 @@
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">Audit-Log</x-nav-item>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">Konto</p>
<x-nav-item icon="settings" href="/einstellungen" :active="request()->is('einstellungen*')">Einstellungen</x-nav-item>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">Einstellungen</x-nav-item>
</nav>
{{-- User --}}

View File

@ -9,15 +9,10 @@
<h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1>
<div class="ml-auto flex min-w-0 items-center gap-2">
<div class="hidden sm:block">
<x-status-pill status="online">Flotte online</x-status-pill>
</div>
<span class="hidden font-mono text-xs text-ink-3 md:inline">Uptime 42d</span>
<button type="button"
class="relative grid min-h-11 min-w-11 place-items-center rounded-md text-ink-2 hover:bg-raised hover:text-ink"
aria-label="Benachrichtigungen">
<x-icon name="bell" class="h-[18px] w-[18px]" />
<span class="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-accent"></span>
</button>
@php
$fleetTotal = \App\Models\Server::count();
$fleetOnline = \App\Models\Server::where('status', 'online')->count();
@endphp
<x-status-pill :status="$fleetOnline > 0 ? 'online' : 'offline'">{{ $fleetOnline }} / {{ $fleetTotal }} online</x-status-pill>
</div>
</header>

View File

@ -28,10 +28,10 @@
{{-- KPI tiles with sparklines (active server) --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-metric label="CPU" :value="$cpu" unit="%" icon="cpu" tone="accent" :series="$cpuSeries" :trend="$cpuTrend" />
<x-metric label="Memory" :value="$mem" unit="%" icon="activity" tone="cyan" :series="$memSeries" :trend="$memTrend" />
<x-metric label="Disk" :value="$disk" unit="%" icon="server" tone="online" :series="$diskSeries" :trend="$diskTrend" />
<x-metric label="Load avg" :value="number_format($load, 2)" icon="activity" tone="warning" :series="$loadSeries" :trend="$loadTrend" sub="1 min" />
<x-metric label="CPU" :value="$cpu" unit="%" icon="cpu" :tone="$cpuTone" :series="$cpuSeries" :trend="$cpuTrend" />
<x-metric label="Memory" :value="$mem" unit="%" icon="activity" :tone="$memTone" :series="$memSeries" :trend="$memTrend" :sub="$memSub" />
<x-metric label="Disk" :value="$disk" unit="%" icon="server" :tone="$diskTone" :series="$diskSeries" :trend="$diskTrend" :sub="$diskSub" />
<x-metric label="Load avg" :value="number_format($load, 2)" icon="activity" :tone="$loadTone" :series="$loadSeries" :trend="$loadTrend" :sub="$loadSub" />
</div>
{{-- Big dual-series chart --}}

View File

@ -49,9 +49,12 @@
{{-- Listing --}}
<x-panel :title="$path" :subtitle="$dirs . ' Ordner · ' . $files . ' Dateien'" :padded="false">
<x-slot:actions>
<x-btn variant="accent">
<x-icon name="plus" class="h-3.5 w-3.5" /> Hochladen
</x-btn>
<label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15">
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" />
<svg wire:loading wire:target="upload" class="h-3.5 w-3.5 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>
Hochladen
<input type="file" wire:model="upload" class="hidden" />
</label>
</x-slot:actions>
{{-- Column header (desktop only) --}}
@ -91,7 +94,7 @@
<span class="truncate font-mono text-sm text-ink group-hover:text-accent-text">{{ $e['name'] }}/</span>
</button>
@else
<p class="min-w-0 flex-1 truncate font-mono text-sm text-ink-2">{{ $e['name'] }}</p>
<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>
@endif
</div>
@ -116,9 +119,9 @@
{{-- Row actions --}}
<div class="flex shrink-0 items-center gap-1 lg:justify-end">
@unless ($isDir)
<x-btn variant="ghost">Download</x-btn>
<x-btn variant="ghost" wire:click="download(@js($e['name']))">Download</x-btn>
<x-btn variant="ghost" wire:click="edit(@js($e['name']))">Bearbeiten</x-btn>
@endunless
<x-btn variant="ghost">Bearbeiten</x-btn>
<x-btn variant="ghost-danger" wire:click="confirmDelete(@js($e['name']))">Löschen</x-btn>
</div>
</div>

View File

@ -0,0 +1,36 @@
<div class="p-5 sm:p-6" wire:init="load">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="audit" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="truncate font-display text-base font-semibold text-ink">{{ $name }}</h2>
<p class="mt-1 truncate font-mono text-[11px] text-ink-3">{{ $path }}</p>
</div>
</div>
<div class="mt-4">
@if (! $loaded)
<div class="space-y-2"><x-skeleton class="h-3 w-full" /><x-skeleton class="h-3 w-5/6" /><x-skeleton class="h-3 w-2/3" /><x-skeleton class="h-3 w-3/4" /></div>
@elseif ($error)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $error }}</p>
@elseif ($tooBig)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />Datei zu groß zum Bearbeiten (über 256 KB) bitte herunterladen.</p>
@elseif ($binary)
<p class="flex items-center gap-1.5 font-mono text-[11px] text-warning"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />Binärdatei kann nicht im Editor angezeigt werden.</p>
@else
<textarea wire:model="content" rows="14" spellcheck="false"
class="w-full resize-y rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2 focus:border-accent/40 focus:outline-none"></textarea>
@endif
</div>
<div class="mt-5 flex items-center justify-end gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">Schließen</x-btn>
@if ($loaded && ! $error && ! $tooBig && ! $binary)
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 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
</x-btn>
@endif
</div>
</div>

View File

@ -1,83 +1,120 @@
@php
$u = auth()->user();
$initials = strtoupper(mb_substr($u->name, 0, 2));
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-sans text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
$err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline';
$tabs = [
['key' => 'profile', 'label' => 'Profil', 'icon' => 'settings'],
['key' => 'security', 'label' => 'Sicherheit', 'icon' => 'shield'],
];
@endphp
<div class="mx-auto max-w-3xl space-y-5">
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Konto</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Einstellungen</h2>
<div class="space-y-5">
{{-- Identity header --}}
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
<span class="grid h-14 w-14 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 font-display text-xl font-semibold text-accent">{{ $initials }}</span>
<div class="min-w-0 flex-1">
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">Konto</p>
<h2 class="mt-0.5 truncate font-display text-xl font-semibold text-ink">{{ $u->name }}</h2>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $u->email }}</p>
</div>
<div class="flex items-center gap-2">
<x-badge tone="neutral">Administrator</x-badge>
<span @class([
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px]',
'border-online/30 text-online' => $twoFactorEnabled,
'border-line text-ink-3' => ! $twoFactorEnabled,
])>
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />{{ $twoFactorEnabled ? '2FA aktiv' : '2FA aus' }}
</span>
</div>
</div>
{{-- Profil --}}
<x-panel title="Profil" subtitle="Name und E-Mail">
<form wire:submit="updateProfile" class="space-y-4">
<div>
<label class="{{ $label }}">Name</label>
<input wire:model="name" type="text" class="{{ $field }}" />
@error('name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">E-Mail</label>
<input wire:model="email" type="email" class="{{ $field }} font-mono" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updateProfile" wire:loading.attr="disabled">
<svg wire:loading wire:target="updateProfile" class="h-3.5 w-3.5 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
</x-btn>
</div>
</form>
</x-panel>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[200px_minmax(0,1fr)]">
{{-- Section nav --}}
<nav class="flex gap-1 lg:flex-col">
@foreach ($tabs as $t)
<button type="button" wire:click="$set('tab', '{{ $t['key'] }}')" @class([
'inline-flex min-h-10 flex-1 items-center gap-2 rounded-md px-3 text-sm transition-colors lg:flex-none',
'bg-accent/10 text-accent-text shadow-[inset_2px_0_0_var(--color-accent)]' => $tab === $t['key'],
'text-ink-2 hover:bg-raised hover:text-ink' => $tab !== $t['key'],
])>
<x-icon :name="$t['icon']" class="h-4 w-4" />{{ $t['label'] }}
</button>
@endforeach
</nav>
{{-- Passwort --}}
<x-panel title="Passwort" subtitle="Mindestens 10 Zeichen">
<form wire:submit="updatePassword" class="space-y-4">
<div>
<label class="{{ $label }}">Aktuelles Passwort</label>
<input wire:model="current_password" type="password" autocomplete="current-password" class="{{ $field }} font-mono" />
@error('current_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">Neues Passwort</label>
<input wire:model="password" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">Wiederholen</label>
<input wire:model="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
</div>
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updatePassword" wire:loading.attr="disabled">
<svg wire:loading wire:target="updatePassword" class="h-3.5 w-3.5 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>
Passwort ändern
</x-btn>
</div>
</form>
</x-panel>
{{-- 2FA --}}
<x-panel title="Zwei-Faktor-Authentifizierung" subtitle="TOTP (Authenticator-App)">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}</p>
</div>
</div>
@if ($twoFactorEnabled)
<x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">Deaktivieren</x-btn>
{{-- Content --}}
<div class="space-y-5">
@if ($tab === 'profile')
<x-panel title="Profil" subtitle="Name und E-Mail">
<form wire:submit="updateProfile" class="space-y-4">
<div>
<label class="{{ $label }}">Name</label>
<input wire:model="name" type="text" class="{{ $field }}" />
@error('name') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">E-Mail</label>
<input wire:model="email" type="email" class="{{ $field }} font-mono" />
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updateProfile" wire:loading.attr="disabled">
<svg wire:loading wire:target="updateProfile" class="h-3.5 w-3.5 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
</x-btn>
</div>
</form>
</x-panel>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> Einrichten
</x-btn>
<x-panel title="Passwort" subtitle="Mindestens 10 Zeichen">
<form wire:submit="updatePassword" class="space-y-4">
<div>
<label class="{{ $label }}">Aktuelles Passwort</label>
<input wire:model="current_password" type="password" autocomplete="current-password" class="{{ $field }} font-mono" />
@error('current_password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="{{ $label }}">Neues Passwort</label>
<input wire:model="password" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">Wiederholen</label>
<input wire:model="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }} font-mono" />
</div>
</div>
<div class="flex justify-end">
<x-btn variant="primary" type="submit" wire:target="updatePassword" wire:loading.attr="disabled">
<svg wire:loading wire:target="updatePassword" class="h-3.5 w-3.5 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>
Passwort ändern
</x-btn>
</div>
</form>
</x-panel>
<x-panel title="Zwei-Faktor-Authentifizierung" subtitle="TOTP (Authenticator-App)">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $twoFactorEnabled ? '2FA ist aktiv' : '2FA ist nicht eingerichtet' }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? 'Der Login erfordert einen TOTP-Code.' : 'Empfohlen — schützt den Login mit einem zweiten Faktor.' }}</p>
</div>
</div>
@if ($twoFactorEnabled)
<x-btn variant="ghost-danger" wire:click="confirmDisableTwoFactor">Deaktivieren</x-btn>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> Einrichten
</x-btn>
@endif
</div>
</x-panel>
@endif
</div>
</x-panel>
</div>
</div>

View File

@ -40,6 +40,6 @@ Route::middleware('auth')->group(function () {
Route::get('/files', Files\Index::class)->name('files.index');
Route::get('/audit', Audit\Index::class)->name('audit.index');
Route::get('/einstellungen', Settings\Index::class)->name('settings');
Route::get('/settings', Settings\Index::class)->name('settings');
});
});

View File

@ -371,6 +371,28 @@ Testing only the skeleton state of a wire:init page and assuming the loaded stat
---
## R13 — Routes and URL paths are English, always
**Why.** UI copy is German (R9), but code-level identifiers — route paths, route names,
URL segments, query keys — stay **English**, like every other identifier in the codebase.
German belongs in the visible label, never in the `href`.
✅ **Correct**
```php
Route::get('/settings', Settings\Index::class)->name('settings'); // path + name English
```
```blade
<x-nav-item href="/settings">Einstellungen</x-nav-item> {{-- German label, English path --}}
```
❌ **Forbidden**
```php
Route::get('/einstellungen', ...)->name('einstellungen'); // German in the URL/name
Route::get('/dateien', ...); // → use /files
```
---
## Appendix — Secret hygiene
The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in