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
parent
9ea55e33bd
commit
d57d456d02
|
|
@ -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-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-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); }
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -17,10 +17,10 @@ Route::view('profile', 'profile')
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Volt::route('sites', 'pages.sites.index')->name('sites.index');
|
Volt::route('sites', 'pages.sites.index')->name('sites.index');
|
||||||
Volt::route('servers', 'pages.servers.index')->name('servers.index');
|
Volt::route('servers', 'pages.servers.index')->name('servers.index');
|
||||||
Route::view('backups', 'placeholder', ['title' => 'Backups'])->name('backups.index');
|
Volt::route('backups', 'pages.backups.index')->name('backups.index');
|
||||||
Route::view('security', 'placeholder', ['title' => 'Sicherheit'])->name('security.index');
|
Volt::route('security', 'pages.security.index')->name('security.index');
|
||||||
Route::view('team', 'placeholder', ['title' => 'Team'])->name('team.index');
|
Volt::route('team', 'pages.team.index')->name('team.index');
|
||||||
Route::view('settings', 'placeholder', ['title' => 'Einstellungen'])->name('settings.index');
|
Volt::route('settings', 'pages.settings.index')->name('settings.index');
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__ . '/auth.php';
|
require __DIR__ . '/auth.php';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue