From d57d456d0270211f4f440153751feec4e5ac6d11 Mon Sep 17 00:00:00 2001 From: Boban Blaskovic Date: Sun, 31 May 2026 23:04:18 +0200 Subject: [PATCH] Pages: Backups + Team + Security + Settings (14 Pest tests) - backups: 4 metrics (last/storage/recovery/failed), recent backups table with pagination, sticky config card (toggles for daily/snapshots/ replication + storage meter). 4 tests. - team: 4 metrics (members/admins/invites/2fa), members table with avatar+role+scope+2fa+last-active, open invitations table with resend/revoke actions. 3 tests. - security: 4 metrics (blocked24h/firewall-rules/ssl-expiring/2fa), advisories panel (high+critical events), threat feed (live), SSL certificates table with days-to-expiry. 3 tests. - settings: 2-col layout with sticky left nav (workspace/notifications/ billing/api/danger), reactive section switching, workspace form, billing card with quota meter, danger-zone with delete CTA. 4 tests. - CSS additions: clu-settings-grid, clu-settings-nav, clu-danger. - Routes: 4 Volt routes (backups/team/security/settings). --- app/resources/css/app.css | 29 +++ .../livewire/pages/backups/index.blade.php | 196 ++++++++++++++++++ .../livewire/pages/security/index.blade.php | 168 +++++++++++++++ .../livewire/pages/settings/index.blade.php | 173 ++++++++++++++++ .../views/livewire/pages/team/index.blade.php | 140 +++++++++++++ app/routes/web.php | 8 +- app/tests/Feature/Pages/BackupsPageTest.php | 47 +++++ app/tests/Feature/Pages/SecurityPageTest.php | 46 ++++ app/tests/Feature/Pages/SettingsPageTest.php | 39 ++++ app/tests/Feature/Pages/TeamPageTest.php | 38 ++++ 10 files changed, 880 insertions(+), 4 deletions(-) create mode 100644 app/resources/views/livewire/pages/backups/index.blade.php create mode 100644 app/resources/views/livewire/pages/security/index.blade.php create mode 100644 app/resources/views/livewire/pages/settings/index.blade.php create mode 100644 app/resources/views/livewire/pages/team/index.blade.php create mode 100644 app/tests/Feature/Pages/BackupsPageTest.php create mode 100644 app/tests/Feature/Pages/SecurityPageTest.php create mode 100644 app/tests/Feature/Pages/SettingsPageTest.php create mode 100644 app/tests/Feature/Pages/TeamPageTest.php 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 --}} +
+
+
+ Letztes Backup + +
+
{{ $lastBackupAt?->diffForHumans() ?? '—' }}
+
letzter erfolgreicher Lauf
+
+
+
+ Storage + +
+
{{ $totalSizeGb }}GB
+
von {{ $storageQuotaGb }} GB ({{ $storageUsedPct }}%)
+
+
+
+ Recovery-Points + +
+
{{ $recoveryPoints }}
+
30 Tage Retention
+
+
+
+ Fehlgeschlagen + +
+
{{ $failedCount }}
+
@if($failedCount > 0)Aufmerksamkeit nötig @else alle OK @endif
+
+
+ + {{-- 2-col: Recent backups + Sticky summary --}} +
+
+
+

Neueste Backups

+ letzte 7 Tage +
+
+ + + + + + + + + + + + + @forelse ($backups as $b) + + + + + + + + + @empty + + @endforelse + +
SiteTypGrößeWannStatus
+
+
{{ 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 + +
+ + + +
+
Noch keine Backups.
+
+
+ {{ $backups->firstItem() ?? 0 }}–{{ $backups->lastItem() ?? 0 }} von {{ $backups->total() }} +
+ + +
+
+
+ + {{-- 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 +
+ +
+ +
+
+
Blockiert / 24h + +
+
{{ $blockedCount }}
+
↑ brute-force trend
+
+
+
Firewall Rules + +
+
{{ $rulesActive }}
+
aktiv
+
+
+
SSL läuft bald ab + +
+
{{ $sslExpiringSoon }}
+
in den nächsten 30 Tagen
+
+
+
2FA Coverage + +
+
{{ $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
+
+ + + + @forelse ($certs as $c) + + + + + + + @empty + + @endforelse + +
DomainAusstellerTageStatus
{{ $c->domain }}{{ $c->issuer }}{{ $c->days_to_expiry }}d + @if ($c->status === 'critical') + kritisch + @elseif ($c->status === 'warning') + bald ablauf + @else + ok + @endif +
Keine Zertifikate.
+
+
+
+
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') +
+

Benachrichtigungen

+
+ + + +
+
+ @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 +
+ +
+ +
+
+
Mitglieder + +
+
{{ $totalMembers }}
+
aktiv
+
+
+
Admins + +
+
{{ $adminCount }}
+
owner + admin
+
+
+
Einladungen offen + +
+
{{ $pendingInvites }}
+
warten auf Annahme
+
+
+
2FA Coverage + +
+
{{ $twoFaCoverage }}%
+
aktiviert
+
+
+ +
+

Mitglieder

+
+ + + + + + @forelse ($members as $m) + + + + + + + + + @empty + + @endforelse + +
NameRolleScope2FALetzte Aktivität
+
+
{{ 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() ?? '—' }}
+ +
Noch keine Mitglieder.
+
+
+ +
+

Offene Einladungen

{{ $pendingInvites }} pending
+
+ + + + @forelse ($invitations as $i) + + + + + + + + + @empty + + @endforelse + +
EmailRolleEingeladen vonGesendetLäuft ab
{{ $i->email }}{{ strtoupper($i->role) }}{{ $i->invitedBy->name ?? '—' }}{{ $i->sent_at?->diffForHumans() ?? '—' }}{{ $i->expires_at?->diffForHumans() ?? '—' }}
+ + +
Keine offenen Einladungen.
+
+
+
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'); +});