diff --git a/app/resources/css/app.css b/app/resources/css/app.css
index f36fd09..1d2a6f5 100644
--- a/app/resources/css/app.css
+++ b/app/resources/css/app.css
@@ -1029,4 +1029,33 @@
.clu-gauge-val { font-family: var(--font-mono); font-size: 11px; color: var(--color-fg); width: 36px; text-align: right; }
.clu-server-spec { display: flex; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--color-muted); }
.clu-server-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--color-hairline); }
+
+ /* Settings nav (left column) */
+ .clu-settings-grid { display: grid; grid-template-columns: 220px 1fr; gap: 14px; }
+ @media (max-width: 820px) { .clu-settings-grid { grid-template-columns: 1fr; } }
+ .clu-settings-nav {
+ display: flex; flex-direction: column; gap: 2px;
+ padding: 8px;
+ background: var(--color-glass-strong);
+ border: 1px solid var(--color-glass-border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-glass-inset);
+ height: fit-content;
+ position: sticky; top: 90px;
+ }
+ .clu-settings-nav a {
+ padding: 9px 11px;
+ border-radius: var(--radius-xs);
+ color: var(--color-muted);
+ font-size: 13px; font-weight: 500;
+ transition: background .15s, color .15s;
+ }
+ .clu-settings-nav a:hover { background: rgba(255,255,255,0.55); color: var(--color-fg); text-decoration: none; }
+ .clu-settings-nav a.active { background: var(--color-accent-soft); color: var(--color-accent); }
+
+ /* Danger zone */
+ .clu-danger {
+ border: 1px solid rgba(219,59,59,0.25);
+ background: rgba(219,59,59,0.04);
+ }
}
diff --git a/app/resources/views/livewire/pages/backups/index.blade.php b/app/resources/views/livewire/pages/backups/index.blade.php
new file mode 100644
index 0000000..55703eb
--- /dev/null
+++ b/app/resources/views/livewire/pages/backups/index.blade.php
@@ -0,0 +1,196 @@
+first();
+ $totalSizeBytes = (int) Backup::sum('size_bytes');
+ $failedCount = Backup::where('status', 'failed')->count();
+ $totalCount = Backup::count();
+
+ return [
+ 'backups' => Backup::query()->with('site')->orderByDesc('completed_at')->paginate(10),
+ 'lastBackupAt' => $last?->completed_at,
+ 'totalSize' => $totalSizeBytes,
+ 'totalSizeGb' => round($totalSizeBytes / 1024 / 1024 / 1024, 1),
+ 'recoveryPoints' => $totalCount,
+ 'failedCount' => $failedCount,
+ 'storageQuotaGb' => 500,
+ 'storageUsedPct' => min(100, (int) round(($totalSizeBytes / 1024 / 1024 / 1024) / 500 * 100)),
+ 'totalSites' => Site::count(),
+ ];
+ }
+}; ?>
+
+
+
+
+
Backups
+
{{ $recoveryPoints }} wiederherstellungspunkte · {{ $totalSites }} sites
+
+
aktiv
+
+
+
+
+ {{-- Metrics --}}
+
+
+
+
{{ $lastBackupAt?->diffForHumans() ?? '—' }}
+
letzter erfolgreicher Lauf
+
+
+
+
{{ $totalSizeGb }}GB
+
von {{ $storageQuotaGb }} GB ({{ $storageUsedPct }}%)
+
+
+
+
{{ $recoveryPoints }}
+
30 Tage Retention
+
+
+
+
{{ $failedCount }}
+
@if($failedCount > 0)Aufmerksamkeit nötig @else alle OK @endif
+
+
+
+ {{-- 2-col: Recent backups + Sticky summary --}}
+
+
+
+
Neueste Backups
+ letzte 7 Tage
+
+
+
+
+
+ | Site |
+ Typ |
+ Größe |
+ Wann |
+ Status |
+ |
+
+
+
+ @forelse ($backups as $b)
+
+
+
+ {{ mb_substr($b->site->domain ?? '?', 0, 1) }}
+
+ {{ $b->site->domain ?? '—' }}
+ {{ $b->storage_location ?? 's3://clupilot-backups' }}
+
+
+ |
+ {{ ucfirst($b->type) }} |
+ {{ $b->size_mb }} MB |
+ {{ $b->completed_at?->diffForHumans() ?? '—' }} |
+
+ @if ($b->status === 'ok')
+ ok
+ @elseif ($b->status === 'failed')
+ failed
+ @else
+ {{ $b->status }}
+ @endif
+ |
+
+
+ |
+
+ @empty
+ | Noch keine Backups. |
+ @endforelse
+
+
+
+
+
+
+ {{-- Sticky summary --}}
+
+
Konfiguration
+
+
+
+
+
+
+
Storage
+
+
+ {{ $totalSizeGb }} GB used
+ {{ $storageUsedPct }}%
+
+
+
+
+ AES-256 verschlüsselt · S3 Frankfurt · 30 Tage Retention
+
+
+
+
+
diff --git a/app/resources/views/livewire/pages/security/index.blade.php b/app/resources/views/livewire/pages/security/index.blade.php
new file mode 100644
index 0000000..36c61f8
--- /dev/null
+++ b/app/resources/views/livewire/pages/security/index.blade.php
@@ -0,0 +1,168 @@
+=', now()->subDay())->count();
+ $blocked24h = SecurityEvent::where('occurred_at', '>=', now()->subDay())->where('blocked', true)->count();
+
+ $highEvents = SecurityEvent::query()
+ ->whereIn('severity', ['high', 'critical'])
+ ->orderByDesc('occurred_at')
+ ->limit(8)
+ ->get();
+
+ $feedEvents = SecurityEvent::query()
+ ->orderByDesc('occurred_at')
+ ->limit(12)
+ ->get();
+
+ $certs = Certificate::query()
+ ->with('site')
+ ->orderBy('expires_at')
+ ->limit(10)
+ ->get();
+
+ $totalMembers = TeamMember::count() ?: 1;
+ $with2fa = TeamMember::where('two_fa_enabled', true)->count();
+
+ return [
+ 'eventsTotal' => $events24h,
+ 'blockedCount' => $blocked24h,
+ 'advisories' => $highEvents,
+ 'feedEvents' => $feedEvents,
+ 'certs' => $certs,
+ 'twoFaCoverage' => (int) round($with2fa / $totalMembers * 100),
+ 'rulesActive' => 42,
+ 'sslExpiringSoon' => Certificate::where('expires_at', '<=', now()->addDays(30))->count(),
+ ];
+ }
+}; ?>
+
+
+
+
+
Sicherheit
+
{{ $blockedCount }} blockiert / 24h · {{ $advisories->count() }} advisories
+
+
firewall aktiv
+
+
+
+
+
+
+
+
{{ $blockedCount }}
+
↑ brute-force trend
+
+
+
+
{{ $rulesActive }}
+
aktiv
+
+
+
+
{{ $sslExpiringSoon }}
+
in den nächsten 30 Tagen
+
+
+
+
{{ $twoFaCoverage }}%
+
Team mit 2FA
+
+
+
+ @if ($advisories->isNotEmpty())
+
+
+
Offene Advisories
+ {{ $advisories->count() }} aktiv
+
+
+ @foreach ($advisories as $a)
+
+
+
+
{{ $a->message ?? ucfirst(str_replace('_', ' ', $a->type)) }}
+
{{ strtoupper($a->severity) }} · {{ $a->source_ip ?? '—' }} · {{ $a->occurred_at?->diffForHumans() }}
+
+
+
+ @endforeach
+
+
+ @endif
+
+
+
+
Threat Feed
live
+
+
+ @forelse ($feedEvents as $e)
+
+
+
+
{{ $e->message ?? ucfirst(str_replace('_', ' ', $e->type)) }}
+
{{ $e->source_ip ?? '—' }} · {{ $e->country ?? '—' }} · {{ $e->occurred_at?->diffForHumans() }}
+
+
+ @empty
+
Keine Events.
+ @endforelse
+
+
+
+
+
+
SSL Zertifikate
{{ $certs->count() }} nächste
+
+
+ | Domain | Aussteller | Tage | Status |
+
+ @forelse ($certs as $c)
+
+ | {{ $c->domain }} |
+ {{ $c->issuer }} |
+ {{ $c->days_to_expiry }}d |
+
+ @if ($c->status === 'critical')
+ kritisch
+ @elseif ($c->status === 'warning')
+ bald ablauf
+ @else
+ ok
+ @endif
+ |
+
+ @empty
+ | Keine Zertifikate. |
+ @endforelse
+
+
+
+
+
+
diff --git a/app/resources/views/livewire/pages/settings/index.blade.php b/app/resources/views/livewire/pages/settings/index.blade.php
new file mode 100644
index 0000000..a998458
--- /dev/null
+++ b/app/resources/views/livewire/pages/settings/index.blade.php
@@ -0,0 +1,173 @@
+section = $s;
+ }
+
+ public function with(): array
+ {
+ return [
+ 'sitesUsed' => Site::count(),
+ 'sitesQuota' => 100,
+ 'monthlyCost' => 249,
+ ];
+ }
+}; ?>
+
+
+
+
+
Einstellungen
+
workspace · benachrichtigungen · billing · api
+
+
+
+
+
+ {{-- Left nav --}}
+
+
+ {{-- Right content --}}
+
+ @if ($section === 'workspace')
+
+
Workspace
Slug nicht änderbar nach Anlage.
+
+
+
+
+
+
+
+ .clupilot.io
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @endif
+
+ @if ($section === 'notifications')
+
+ @endif
+
+ @if ($section === 'billing')
+
+
Billing
Plan + Quota
+
+
+
+
PRO Plan
+
monatlich abgerechnet
+
+
€{{ $monthlyCost }}/mo
+
+
+
Sites Quota
+
+
{{ $sitesUsed }} / {{ $sitesQuota }}{{ (int)round($sitesUsed/$sitesQuota*100) }}%
+
+
+ Zahlungsmethode: Visa **** 4242
+
+
+
+
+ @endif
+
+ @if ($section === 'api')
+
+
API Tokens
+
+
+ clu_pat_••••••••••••••••••••••••••••••
+
+
+
+
+ @endif
+
+ @if ($section === 'danger')
+
+
Danger Zone
+
+
Workspace löschen entfernt alle Sites, Server-Verbindungen und Backups unwiderruflich.
+
+
+
+ @endif
+
+
+
diff --git a/app/resources/views/livewire/pages/team/index.blade.php b/app/resources/views/livewire/pages/team/index.blade.php
new file mode 100644
index 0000000..390b2b4
--- /dev/null
+++ b/app/resources/views/livewire/pages/team/index.blade.php
@@ -0,0 +1,140 @@
+with('user')->orderBy('role')->get();
+ $invitations = Invitation::query()
+ ->with('invitedBy')
+ ->whereNull('accepted_at')
+ ->orderByDesc('sent_at')
+ ->get();
+
+ return [
+ 'members' => $members,
+ 'invitations' => $invitations,
+ 'totalMembers' => $members->count(),
+ 'adminCount' => $members->whereIn('role', ['owner', 'admin'])->count(),
+ 'pendingInvites' => $invitations->count(),
+ 'twoFaCoverage' => $members->count() ? (int) round($members->where('two_fa_enabled')->count() / $members->count() * 100) : 0,
+ ];
+ }
+}; ?>
+
+
+
+
+
Team
+
{{ $totalMembers }} mitglieder · {{ $pendingInvites }} einladungen offen
+
+
{{ $twoFaCoverage }}% mit 2FA
+
+
+
+
+
+
+
+
{{ $totalMembers }}
+
aktiv
+
+
+
+
{{ $adminCount }}
+
owner + admin
+
+
+
+
{{ $pendingInvites }}
+
warten auf Annahme
+
+
+
+
{{ $twoFaCoverage }}%
+
aktiviert
+
+
+
+
+
Mitglieder
+
+
+
+ | Name | Rolle | Scope | 2FA | Letzte Aktivität | |
+
+
+ @forelse ($members as $m)
+
+
+
+ {{ mb_strtoupper(mb_substr($m->user->name ?? '?', 0, 2)) }}
+
+ {{ $m->user->name ?? '—' }}
+ {{ $m->user->email ?? '—' }}
+
+
+ |
+ {{ strtoupper($m->role) }} |
+ {{ $m->scope ?? '—' }} |
+
+ @if ($m->two_fa_enabled)
+ aktiv
+ @else
+ fehlt
+ @endif
+ |
+ {{ $m->last_active_at?->diffForHumans() ?? '—' }} |
+
+
+ |
+
+ @empty
+ | Noch keine Mitglieder. |
+ @endforelse
+
+
+
+
+
+
+
Offene Einladungen
{{ $pendingInvites }} pending
+
+
+ | Email | Rolle | Eingeladen von | Gesendet | Läuft ab | |
+
+ @forelse ($invitations as $i)
+
+ | {{ $i->email }} |
+ {{ strtoupper($i->role) }} |
+ {{ $i->invitedBy->name ?? '—' }} |
+ {{ $i->sent_at?->diffForHumans() ?? '—' }} |
+ {{ $i->expires_at?->diffForHumans() ?? '—' }} |
+ |
+
+ @empty
+ | Keine offenen Einladungen. |
+ @endforelse
+
+
+
+
+
diff --git a/app/routes/web.php b/app/routes/web.php
index eebbc24..88b82ac 100644
--- a/app/routes/web.php
+++ b/app/routes/web.php
@@ -17,10 +17,10 @@ Route::view('profile', 'profile')
Route::middleware(['auth'])->group(function () {
Volt::route('sites', 'pages.sites.index')->name('sites.index');
Volt::route('servers', 'pages.servers.index')->name('servers.index');
- Route::view('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
- Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');
- Route::view('team', 'placeholder', ['title' => 'Team'])->name('team.index');
- Route::view('settings', 'placeholder', ['title' => 'Einstellungen'])->name('settings.index');
+ Volt::route('backups', 'pages.backups.index')->name('backups.index');
+ Volt::route('security', 'pages.security.index')->name('security.index');
+ Volt::route('team', 'pages.team.index')->name('team.index');
+ Volt::route('settings', 'pages.settings.index')->name('settings.index');
});
require __DIR__ . '/auth.php';
diff --git a/app/tests/Feature/Pages/BackupsPageTest.php b/app/tests/Feature/Pages/BackupsPageTest.php
new file mode 100644
index 0000000..557ef06
--- /dev/null
+++ b/app/tests/Feature/Pages/BackupsPageTest.php
@@ -0,0 +1,47 @@
+assertRedirect(route('login'));
+});
+
+it('renders backups page chrome', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+
+ actingAs($user)->get(route('backups.index'))
+ ->assertStatus(200)
+ ->assertSeeText('Backups')
+ ->assertSeeText('Letztes Backup')
+ ->assertSeeText('Storage')
+ ->assertSeeText('Recovery-Points')
+ ->assertSeeText('Fehlgeschlagen')
+ ->assertSeeText('Neueste Backups')
+ ->assertSeeText('Daily Backups')
+ ->assertSeeText('Hourly Snapshots')
+ ->assertSee('clu-pager', false);
+});
+
+it('shows seeded backups in the table', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+ $server = Server::factory()->create();
+ $site = Site::factory()->create(['server_id' => $server->id, 'domain' => 'backup-test.de']);
+ Backup::factory()->create(['site_id' => $site->id, 'status' => 'ok']);
+
+ actingAs($user)->get(route('backups.index'))
+ ->assertStatus(200)
+ ->assertSeeText('backup-test.de');
+});
+
+it('toggles config switches', function () {
+ livewire('pages.backups.index')
+ ->assertSet('replicationEnabled', false)
+ ->set('replicationEnabled', true)
+ ->assertSet('replicationEnabled', true);
+});
diff --git a/app/tests/Feature/Pages/SecurityPageTest.php b/app/tests/Feature/Pages/SecurityPageTest.php
new file mode 100644
index 0000000..9e8acca
--- /dev/null
+++ b/app/tests/Feature/Pages/SecurityPageTest.php
@@ -0,0 +1,46 @@
+assertRedirect(route('login'));
+});
+
+it('renders security page chrome', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+
+ actingAs($user)->get(route('security.index'))
+ ->assertStatus(200)
+ ->assertSeeText('Sicherheit')
+ ->assertSeeText('Blockiert / 24h')
+ ->assertSeeText('Firewall Rules')
+ ->assertSeeText('SSL läuft bald ab')
+ ->assertSeeText('2FA Coverage')
+ ->assertSeeText('Threat Feed')
+ ->assertSeeText('SSL Zertifikate');
+});
+
+it('lists security events and certs', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+ $site = Site::factory()->create();
+
+ SecurityEvent::factory()->critical()->create([
+ 'site_id' => $site->id,
+ 'message' => 'CRIT-EVT-XYZ',
+ 'occurred_at' => now()->subMinutes(5),
+ ]);
+ Certificate::factory()->create([
+ 'site_id' => $site->id,
+ 'domain' => 'cert-watchme.de',
+ ]);
+
+ actingAs($user)->get(route('security.index'))
+ ->assertStatus(200)
+ ->assertSeeText('CRIT-EVT-XYZ')
+ ->assertSeeText('cert-watchme.de');
+});
diff --git a/app/tests/Feature/Pages/SettingsPageTest.php b/app/tests/Feature/Pages/SettingsPageTest.php
new file mode 100644
index 0000000..e8f61d6
--- /dev/null
+++ b/app/tests/Feature/Pages/SettingsPageTest.php
@@ -0,0 +1,39 @@
+assertRedirect(route('login'));
+});
+
+it('renders settings page chrome', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+
+ actingAs($user)->get(route('settings.index'))
+ ->assertStatus(200)
+ ->assertSeeText('Einstellungen')
+ ->assertSeeText('Workspace')
+ ->assertSeeText('Benachrichtigungen')
+ ->assertSeeText('Billing')
+ ->assertSeeText('API')
+ ->assertSeeText('Danger Zone');
+});
+
+it('switches sections', function () {
+ livewire('pages.settings.index')
+ ->assertSet('section', 'workspace')
+ ->call('setSection', 'billing')
+ ->assertSet('section', 'billing')
+ ->assertSeeText('PRO Plan')
+ ->call('setSection', 'danger')
+ ->assertSeeText('Workspace löschen');
+});
+
+it('persists workspace fields in form state', function () {
+ livewire('pages.settings.index')
+ ->set('workspaceName', 'Neue GmbH')
+ ->assertSet('workspaceName', 'Neue GmbH');
+});
diff --git a/app/tests/Feature/Pages/TeamPageTest.php b/app/tests/Feature/Pages/TeamPageTest.php
new file mode 100644
index 0000000..9ea983c
--- /dev/null
+++ b/app/tests/Feature/Pages/TeamPageTest.php
@@ -0,0 +1,38 @@
+assertRedirect(route('login'));
+});
+
+it('renders team page chrome', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+
+ actingAs($user)->get(route('team.index'))
+ ->assertStatus(200)
+ ->assertSeeText('Team')
+ ->assertSeeText('Mitglieder')
+ ->assertSeeText('Admins')
+ ->assertSeeText('Einladungen offen')
+ ->assertSeeText('2FA Coverage')
+ ->assertSeeText('Offene Einladungen');
+});
+
+it('lists team members and invitations', function () {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+ $u1 = User::factory()->create(['name' => 'Anna Test', 'email' => 'anna@ex.test']);
+ TeamMember::factory()->create(['user_id' => $u1->id, 'role' => 'admin']);
+ Invitation::factory()->create(['email' => 'pending@ex.test', 'invited_by_user_id' => $user->id]);
+
+ actingAs($user)->get(route('team.index'))
+ ->assertStatus(200)
+ ->assertSeeText('Anna Test')
+ ->assertSeeText('anna@ex.test')
+ ->assertSeeText('ADMIN')
+ ->assertSeeText('pending@ex.test');
+});