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).
master
Boban Blaskovic 2026-05-31 23:04:18 +02:00
parent 9ea55e33bd
commit d57d456d02
10 changed files with 880 additions and 4 deletions

View File

@ -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);
}
}

View File

@ -0,0 +1,196 @@
<?php
use App\Models\Backup;
use App\Models\Site;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('layouts.app')] class extends Component {
use WithPagination;
public bool $dailyEnabled = true;
public bool $snapshotsEnabled = true;
public bool $replicationEnabled = false;
public function with(): array
{
$last = Backup::orderByDesc('completed_at')->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(),
];
}
}; ?>
<div>
<div class="clu-topbar">
<div>
<h1 class="clu-page-title">Backups</h1>
<div class="clu-page-sub">{{ $recoveryPoints }} wiederherstellungspunkte · {{ $totalSites }} sites</div>
</div>
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> aktiv</span>
<div class="spacer"></div>
<button class="clu-btn-primary" style="height:36px;padding:0 14px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="M21 12c0 5-9 9-9 9s-9-4-9-9V5l9-3 9 3v7z"/>
</svg>
Backup jetzt
</button>
</div>
{{-- Metrics --}}
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));">
<div class="clu-metric success">
<div class="clu-metric-row">
<span class="clu-metric-label">Letztes Backup</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/></svg></span>
</div>
<div class="clu-metric-value">{{ $lastBackupAt?->diffForHumans() ?? '—' }}</div>
<div class="clu-metric-delta">letzter erfolgreicher Lauf</div>
</div>
<div class="clu-metric">
<div class="clu-metric-row">
<span class="clu-metric-label">Storage</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v6c0 1.7 4 3 9 3s9-1.3 9-3V5M3 11v6c0 1.7 4 3 9 3s9-1.3 9-3v-6"/></svg></span>
</div>
<div class="clu-metric-value">{{ $totalSizeGb }}<span class="unit">GB</span></div>
<div class="clu-metric-delta">von {{ $storageQuotaGb }} GB ({{ $storageUsedPct }}%)</div>
</div>
<div class="clu-metric">
<div class="clu-metric-row">
<span class="clu-metric-label">Recovery-Points</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9"/><path d="M3 4v5h5M12 7v5l4 2"/></svg></span>
</div>
<div class="clu-metric-value">{{ $recoveryPoints }}</div>
<div class="clu-metric-delta">30 Tage Retention</div>
</div>
<div class="clu-metric @if($failedCount > 0) danger @endif">
<div class="clu-metric-row">
<span class="clu-metric-label">Fehlgeschlagen</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/></svg></span>
</div>
<div class="clu-metric-value">{{ $failedCount }}</div>
<div class="clu-metric-delta @if($failedCount > 0) bad @endif">@if($failedCount > 0)Aufmerksamkeit nötig @else alle OK @endif</div>
</div>
</div>
{{-- 2-col: Recent backups + Sticky summary --}}
<div class="grid gap-[14px]" style="grid-template-columns:minmax(0,2fr) minmax(280px,1fr);">
<div class="clu-card">
<div class="clu-card-head">
<h3>Neueste Backups</h3>
<span class="meta">letzte 7 Tage</span>
</div>
<div class="clu-card-body tight" style="overflow-x:auto;">
<table class="clu-table">
<thead>
<tr>
<th>Site</th>
<th>Typ</th>
<th>Größe</th>
<th>Wann</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse ($backups as $b)
<tr>
<td>
<div class="clu-site-cell">
<div class="clu-favicon">{{ mb_substr($b->site->domain ?? '?', 0, 1) }}</div>
<div class="clu-site-meta">
<span class="clu-site-domain">{{ $b->site->domain ?? '—' }}</span>
<span class="clu-site-server">{{ $b->storage_location ?? 's3://clupilot-backups' }}</span>
</div>
</div>
</td>
<td><span class="clu-pill muted">{{ ucfirst($b->type) }}</span></td>
<td class="font-mono">{{ $b->size_mb }} MB</td>
<td class="font-mono">{{ $b->completed_at?->diffForHumans() ?? '—' }}</td>
<td>
@if ($b->status === 'ok')
<span class="clu-health">ok</span>
@elseif ($b->status === 'failed')
<span class="clu-health bad">failed</span>
@else
<span class="clu-health warn">{{ $b->status }}</span>
@endif
</td>
<td>
<div class="clu-row-actions">
<button title="Restore" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9"/><path d="M3 4v5h5"/></svg>
</button>
<button title="Download" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v14M5 12l7 7 7-7M5 21h14"/></svg>
</button>
<button title="Mehr" type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
</div>
</td>
</tr>
@empty
<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--color-muted);">Noch keine Backups.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="clu-pager">
<span class="clu-pager-meta">{{ $backups->firstItem() ?? 0 }}{{ $backups->lastItem() ?? 0 }} von {{ $backups->total() }}</span>
<div class="flex gap-[6px]">
<button class="clu-ghost-btn" wire:click="previousPage" @if($backups->onFirstPage()) disabled @endif> Zurück</button>
<button class="clu-ghost-btn" wire:click="nextPage" @if(! $backups->hasMorePages()) disabled @endif>Weiter </button>
</div>
</div>
</div>
{{-- Sticky summary --}}
<div class="clu-card" style="height:fit-content;position:sticky;top:90px;">
<div class="clu-card-head"><h3>Konfiguration</h3></div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
<label class="clu-checkbox justify-between" style="width:100%;justify-content:space-between;">
<span>Daily Backups</span>
<input type="checkbox" wire:model.live="dailyEnabled" />
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
</label>
<label class="clu-checkbox" style="width:100%;justify-content:space-between;">
<span>Hourly Snapshots</span>
<input type="checkbox" wire:model.live="snapshotsEnabled" />
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
</label>
<label class="clu-checkbox" style="width:100%;justify-content:space-between;">
<span>Multi-Region Replication</span>
<input type="checkbox" wire:model.live="replicationEnabled" />
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
</label>
<div style="border-top:1px solid var(--color-hairline);padding-top:14px;">
<div class="clu-stat-label">Storage</div>
<div class="clu-plan-meter mt-2"><span style="width:{{ $storageUsedPct }}%;"></span></div>
<div class="clu-plan-stats">
<span>{{ $totalSizeGb }} GB used</span>
<span>{{ $storageUsedPct }}%</span>
</div>
</div>
<div class="clu-feed-meta" style="line-height:1.5;">
AES-256 verschlüsselt · S3 Frankfurt · 30 Tage Retention
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,168 @@
<?php
use App\Models\Certificate;
use App\Models\SecurityEvent;
use App\Models\TeamMember;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.app')] class extends Component {
public function with(): array
{
$events24h = SecurityEvent::where('occurred_at', '>=', 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(),
];
}
}; ?>
<div>
<div class="clu-topbar">
<div>
<h1 class="clu-page-title">Sicherheit</h1>
<div class="clu-page-sub">{{ $blockedCount }} blockiert / 24h · {{ $advisories->count() }} advisories</div>
</div>
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> firewall aktiv</span>
<div class="spacer"></div>
<button class="clu-btn-primary" style="height:36px;padding:0 14px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 5l-2 7h6l-2 7"/><circle cx="12" cy="12" r="9"/></svg>
Scan starten
</button>
</div>
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));">
<div class="clu-metric danger">
<div class="clu-metric-row"><span class="clu-metric-label">Blockiert / 24h</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M5 5l14 14"/></svg></span>
</div>
<div class="clu-metric-value">{{ $blockedCount }}</div>
<div class="clu-metric-delta bad"> brute-force trend</div>
</div>
<div class="clu-metric">
<div class="clu-metric-row"><span class="clu-metric-label">Firewall Rules</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/></svg></span>
</div>
<div class="clu-metric-value">{{ $rulesActive }}</div>
<div class="clu-metric-delta">aktiv</div>
</div>
<div class="clu-metric @if($sslExpiringSoon > 0) warning @endif">
<div class="clu-metric-row"><span class="clu-metric-label">SSL läuft bald ab</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></span>
</div>
<div class="clu-metric-value">{{ $sslExpiringSoon }}</div>
<div class="clu-metric-delta @if($sslExpiringSoon > 0) warn @endif">in den nächsten 30 Tagen</div>
</div>
<div class="clu-metric success">
<div class="clu-metric-row"><span class="clu-metric-label">2FA Coverage</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 12l2 2 4-4"/></svg></span>
</div>
<div class="clu-metric-value">{{ $twoFaCoverage }}<span class="unit">%</span></div>
<div class="clu-metric-delta">Team mit 2FA</div>
</div>
</div>
@if ($advisories->isNotEmpty())
<div class="clu-card mb-[18px]">
<div class="clu-card-head">
<h3>Offene Advisories</h3>
<span class="meta">{{ $advisories->count() }} aktiv</span>
</div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:10px;">
@foreach ($advisories as $a)
<div class="flex items-start gap-[12px] p-[12px] rounded-[11px]"
style="background:rgba(219,59,59,0.06);border:1px solid rgba(219,59,59,0.18);">
<div class="clu-feed-ico error" style="flex:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.3 3.86l-8.55 14.83A2 2 0 0 0 3.46 22h17.08a2 2 0 0 0 1.71-3.31L13.7 3.86a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[13px]">{{ $a->message ?? ucfirst(str_replace('_', ' ', $a->type)) }}</div>
<div class="clu-feed-meta">{{ strtoupper($a->severity) }} · {{ $a->source_ip ?? '—' }} · {{ $a->occurred_at?->diffForHumans() }}</div>
</div>
<button class="clu-ghost-btn" type="button">Action</button>
</div>
@endforeach
</div>
</div>
@endif
<div class="grid gap-[14px]" style="grid-template-columns:minmax(0,1fr) minmax(280px,1fr);">
<div class="clu-card">
<div class="clu-card-head"><h3>Threat Feed</h3><span class="meta">live</span></div>
<div class="clu-card-body tight">
<div class="clu-feed">
@forelse ($feedEvents as $e)
<div class="clu-feed-row">
<div class="clu-feed-ico @if($e->severity === 'critical') error @elseif($e->severity === 'high') warning @endif">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/></svg>
</div>
<div class="clu-feed-body">
<div class="clu-feed-msg">{{ $e->message ?? ucfirst(str_replace('_', ' ', $e->type)) }}</div>
<div class="clu-feed-meta">{{ $e->source_ip ?? '—' }} · {{ $e->country ?? '—' }} · {{ $e->occurred_at?->diffForHumans() }}</div>
</div>
</div>
@empty
<div class="clu-card-body" style="text-align:center;padding:30px;color:var(--color-muted);">Keine Events.</div>
@endforelse
</div>
</div>
</div>
<div class="clu-card">
<div class="clu-card-head"><h3>SSL Zertifikate</h3><span class="meta">{{ $certs->count() }} nächste</span></div>
<div class="clu-card-body tight" style="overflow-x:auto;">
<table class="clu-table">
<thead><tr><th>Domain</th><th>Aussteller</th><th>Tage</th><th>Status</th></tr></thead>
<tbody>
@forelse ($certs as $c)
<tr>
<td>{{ $c->domain }}</td>
<td class="font-mono text-[11.5px]">{{ $c->issuer }}</td>
<td class="font-mono">{{ $c->days_to_expiry }}d</td>
<td>
@if ($c->status === 'critical')
<span class="clu-health bad">kritisch</span>
@elseif ($c->status === 'warning')
<span class="clu-health warn">bald ablauf</span>
@else
<span class="clu-health">ok</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="4" style="text-align:center;padding:30px;color:var(--color-muted);">Keine Zertifikate.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,173 @@
<?php
use App\Models\Site;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.app')] class extends Component {
public string $workspaceName = 'Acme Cluster';
public string $workspaceSlug = 'acme-cluster';
public string $description = 'WordPress fleet management for clients.';
public string $region = 'eu-central-1';
public string $timezone = 'Europe/Berlin';
public bool $emailNotifications = true;
public bool $slackNotifications = false;
public bool $weeklyDigest = true;
public string $section = 'workspace';
public function setSection(string $s): void
{
$this->section = $s;
}
public function with(): array
{
return [
'sitesUsed' => Site::count(),
'sitesQuota' => 100,
'monthlyCost' => 249,
];
}
}; ?>
<div>
<div class="clu-topbar">
<div>
<h1 class="clu-page-title">Einstellungen</h1>
<div class="clu-page-sub">workspace · benachrichtigungen · billing · api</div>
</div>
<div class="spacer"></div>
</div>
<div class="clu-settings-grid">
{{-- Left nav --}}
<nav class="clu-settings-nav">
<a href="#" wire:click.prevent="setSection('workspace')" class="@if($section==='workspace') active @endif">Workspace</a>
<a href="#" wire:click.prevent="setSection('notifications')" class="@if($section==='notifications') active @endif">Benachrichtigungen</a>
<a href="#" wire:click.prevent="setSection('billing')" class="@if($section==='billing') active @endif">Billing</a>
<a href="#" wire:click.prevent="setSection('api')" class="@if($section==='api') active @endif">API</a>
<a href="#" wire:click.prevent="setSection('danger')" class="@if($section==='danger') active @endif" style="color:var(--color-danger);">Danger Zone</a>
</nav>
{{-- Right content --}}
<div class="flex flex-col gap-[14px]">
@if ($section === 'workspace')
<div class="clu-card">
<div class="clu-card-head"><h3>Workspace</h3><span class="meta">Slug nicht änderbar nach Anlage.</span></div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
<div class="clu-field-row">
<div class="flex flex-col gap-[6px]">
<label class="text-[11.5px] text-(--color-muted) font-semibold">Name</label>
<div class="clu-input"><input wire:model="workspaceName" type="text" /></div>
</div>
<div class="flex flex-col gap-[6px]">
<label class="text-[11.5px] text-(--color-muted) font-semibold">Slug</label>
<div class="clu-input with-suffix">
<input value="{{ $workspaceSlug }}" type="text" disabled />
<span class="clu-input-suffix">.clupilot.io</span>
</div>
</div>
</div>
<div class="flex flex-col gap-[6px]">
<label class="text-[11.5px] text-(--color-muted) font-semibold">Beschreibung</label>
<div class="clu-input"><input wire:model="description" type="text" /></div>
</div>
<div class="clu-field-row">
<div class="flex flex-col gap-[6px]">
<label class="text-[11.5px] text-(--color-muted) font-semibold">Region</label>
<select wire:model="region" class="clu-input" style="appearance:auto;">
<option value="eu-central-1">eu-central-1 (Frankfurt)</option>
<option value="eu-west-1">eu-west-1 (Ireland)</option>
<option value="us-east-1">us-east-1 (Virginia)</option>
</select>
</div>
<div class="flex flex-col gap-[6px]">
<label class="text-[11.5px] text-(--color-muted) font-semibold">Zeitzone</label>
<select wire:model="timezone" class="clu-input" style="appearance:auto;">
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
<div class="flex gap-[8px] justify-end pt-[8px] border-t border-(--color-hairline)">
<button class="clu-ghost-btn" type="button">Verwerfen</button>
<button class="clu-btn-primary" style="height:36px;padding:0 16px;" type="button">Speichern</button>
</div>
</div>
</div>
@endif
@if ($section === 'notifications')
<div class="clu-card">
<div class="clu-card-head"><h3>Benachrichtigungen</h3></div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
<span>E-Mail Benachrichtigungen</span>
<input type="checkbox" wire:model.live="emailNotifications" />
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
</label>
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
<span>Slack Webhooks</span>
<input type="checkbox" wire:model.live="slackNotifications" />
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
</label>
<label class="clu-checkbox" style="justify-content:space-between;width:100%;">
<span>Wöchentlicher Digest</span>
<input type="checkbox" wire:model.live="weeklyDigest" />
<span class="box"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5"><path d="M5 12l5 5L20 7"/></svg></span>
</label>
</div>
</div>
@endif
@if ($section === 'billing')
<div class="clu-card">
<div class="clu-card-head"><h3>Billing</h3><span class="meta">Plan + Quota</span></div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:14px;">
<div class="flex items-center justify-between">
<div>
<div class="font-semibold">PRO Plan</div>
<div class="text-[12px] text-(--color-muted)">monatlich abgerechnet</div>
</div>
<span class="clu-pill accent">{{ $monthlyCost }}/mo</span>
</div>
<div>
<div class="clu-stat-label">Sites Quota</div>
<div class="clu-plan-meter mt-2"><span style="width:{{ min(100, (int)round($sitesUsed/$sitesQuota*100)) }}%"></span></div>
<div class="clu-plan-stats"><span>{{ $sitesUsed }} / {{ $sitesQuota }}</span><span>{{ (int)round($sitesUsed/$sitesQuota*100) }}%</span></div>
</div>
<div class="flex justify-between items-center pt-[14px] border-t border-(--color-hairline)">
<span class="text-[12.5px] text-(--color-muted)">Zahlungsmethode: Visa **** 4242</span>
<button class="clu-ghost-btn" type="button">Ändern</button>
</div>
</div>
</div>
@endif
@if ($section === 'api')
<div class="clu-card">
<div class="clu-card-head"><h3>API Tokens</h3></div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:12px;">
<div class="font-mono text-[12px] p-[12px] rounded-[9px]" style="background:rgba(255,255,255,0.5);border:1px solid var(--color-hairline);">
clu_pat_•••••••••••••••••••••••••••••• <button type="button" class="ml-2 text-(--color-accent) text-[11px]">kopieren</button>
</div>
<button class="clu-btn-primary" style="height:36px;padding:0 16px;align-self:flex-start;" type="button">Neuen Token erstellen</button>
</div>
</div>
@endif
@if ($section === 'danger')
<div class="clu-card clu-danger">
<div class="clu-card-head"><h3 style="color:var(--color-danger);">Danger Zone</h3></div>
<div class="clu-card-body" style="display:flex;flex-direction:column;gap:12px;">
<p class="text-[13px]">Workspace löschen entfernt alle Sites, Server-Verbindungen und Backups unwiderruflich.</p>
<button class="clu-ghost-btn" style="color:var(--color-danger);border-color:rgba(219,59,59,0.3);align-self:flex-start;" type="button">Workspace löschen…</button>
</div>
</div>
@endif
</div>
</div>
</div>

View File

@ -0,0 +1,140 @@
<?php
use App\Models\Invitation;
use App\Models\TeamMember;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('layouts.app')] class extends Component {
public function with(): array
{
$members = TeamMember::query()->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,
];
}
}; ?>
<div>
<div class="clu-topbar">
<div>
<h1 class="clu-page-title">Team</h1>
<div class="clu-page-sub">{{ $totalMembers }} mitglieder · {{ $pendingInvites }} einladungen offen</div>
</div>
<span class="clu-status-chip"><span class="clu-pulse-dot"></span> {{ $twoFaCoverage }}% mit 2FA</span>
<div class="spacer"></div>
<button class="clu-btn-primary" style="height:36px;padding:0 14px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
Einladen
</button>
</div>
<div class="grid gap-[14px] mb-[18px]" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));">
<div class="clu-metric">
<div class="clu-metric-row"><span class="clu-metric-label">Mitglieder</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="8" r="3.5"/><path d="M3 20v-1c0-3 3-5 6-5s6 2 6 5v1"/></svg></span>
</div>
<div class="clu-metric-value">{{ $totalMembers }}</div>
<div class="clu-metric-delta">aktiv</div>
</div>
<div class="clu-metric">
<div class="clu-metric-row"><span class="clu-metric-label">Admins</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"/></svg></span>
</div>
<div class="clu-metric-value">{{ $adminCount }}</div>
<div class="clu-metric-delta">owner + admin</div>
</div>
<div class="clu-metric warning">
<div class="clu-metric-row"><span class="clu-metric-label">Einladungen offen</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18v14H3z"/><path d="M3 5l9 9 9-9"/></svg></span>
</div>
<div class="clu-metric-value">{{ $pendingInvites }}</div>
<div class="clu-metric-delta warn">warten auf Annahme</div>
</div>
<div class="clu-metric success">
<div class="clu-metric-row"><span class="clu-metric-label">2FA Coverage</span>
<span class="clu-metric-ico"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9 12l2 2 4-4"/></svg></span>
</div>
<div class="clu-metric-value">{{ $twoFaCoverage }}<span class="unit">%</span></div>
<div class="clu-metric-delta">aktiviert</div>
</div>
</div>
<div class="clu-card mb-[18px]">
<div class="clu-card-head"><h3>Mitglieder</h3></div>
<div class="clu-card-body tight" style="overflow-x:auto;">
<table class="clu-table">
<thead>
<tr><th>Name</th><th>Rolle</th><th>Scope</th><th>2FA</th><th>Letzte Aktivität</th><th></th></tr>
</thead>
<tbody>
@forelse ($members as $m)
<tr>
<td>
<div class="clu-site-cell">
<div class="clu-avatar" style="background:linear-gradient(135deg,#5a8bff,#8a63ff);width:28px;height:28px;border-radius:8px;font-size:11px;">{{ mb_strtoupper(mb_substr($m->user->name ?? '?', 0, 2)) }}</div>
<div class="clu-site-meta">
<span class="clu-site-domain">{{ $m->user->name ?? '—' }}</span>
<span class="clu-site-server">{{ $m->user->email ?? '—' }}</span>
</div>
</div>
</td>
<td><span class="clu-pill @if($m->role === 'owner') accent @elseif($m->role === 'admin') warning @else muted @endif">{{ strtoupper($m->role) }}</span></td>
<td class="font-mono text-[11.5px]">{{ $m->scope ?? '—' }}</td>
<td>
@if ($m->two_fa_enabled)
<span class="clu-health">aktiv</span>
@else
<span class="clu-health warn">fehlt</span>
@endif
</td>
<td class="font-mono">{{ $m->last_active_at?->diffForHumans() ?? '—' }}</td>
<td><div class="clu-row-actions">
<button title="Bearbeiten" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg></button>
</div></td>
</tr>
@empty
<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--color-muted);">Noch keine Mitglieder.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="clu-card">
<div class="clu-card-head"><h3>Offene Einladungen</h3><span class="meta">{{ $pendingInvites }} pending</span></div>
<div class="clu-card-body tight" style="overflow-x:auto;">
<table class="clu-table">
<thead><tr><th>Email</th><th>Rolle</th><th>Eingeladen von</th><th>Gesendet</th><th>Läuft ab</th><th></th></tr></thead>
<tbody>
@forelse ($invitations as $i)
<tr>
<td class="font-mono">{{ $i->email }}</td>
<td><span class="clu-pill muted">{{ strtoupper($i->role) }}</span></td>
<td>{{ $i->invitedBy->name ?? '—' }}</td>
<td class="font-mono">{{ $i->sent_at?->diffForHumans() ?? '—' }}</td>
<td class="font-mono">{{ $i->expires_at?->diffForHumans() ?? '—' }}</td>
<td><div class="clu-row-actions">
<button title="Erneut senden" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9"/><path d="M3 4v5h5"/></svg></button>
<button title="Zurückziehen" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/></svg></button>
</div></td>
</tr>
@empty
<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--color-muted);">Keine offenen Einladungen.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>

View File

@ -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';

View File

@ -0,0 +1,47 @@
<?php
use App\Models\Backup;
use App\Models\Server;
use App\Models\Site;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
use function Pest\Livewire\livewire;
it('redirects guests to login', function () {
get(route('backups.index'))->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);
});

View File

@ -0,0 +1,46 @@
<?php
use App\Models\Certificate;
use App\Models\SecurityEvent;
use App\Models\Site;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
it('redirects guests to login', function () {
get(route('security.index'))->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');
});

View File

@ -0,0 +1,39 @@
<?php
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
use function Pest\Livewire\livewire;
it('redirects guests to login', function () {
get(route('settings.index'))->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');
});

View File

@ -0,0 +1,38 @@
<?php
use App\Models\Invitation;
use App\Models\TeamMember;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
it('redirects guests to login', function () {
get(route('team.index'))->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');
});