feat(admin): dark Tactical-Terminal operator console

Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware
+ migration/seed; a plain customer user is seeded to prove the gate). Distinct dark
aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design
token to a dark graphite / signal-orange palette, so ALL shared components (button,
card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island
now reads tokens from its own element, so charts are theme-aware.

Sections (each full-page class-based Livewire, English routes R13, localized DE/EN):
- Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts.
- Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage).
- Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper.
- Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments.
- Locale-aware month labels/currency (Carbon/Number).

18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44
green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark).
Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 08:35:03 +02:00
parent c4cff8f67c
commit 1aa7535fc4
26 changed files with 971 additions and 3 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators (is_admin) may reach the admin console.
*/
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin, 403);
return $next($request);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale);
return view('livewire.admin.customers', [
'rows' => [
['name' => 'Kanzlei Berger', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'kanzlei-berger', 'status' => 'active'],
['name' => 'Ordination Dr. Fux', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'ordination-fux', 'status' => 'provisioning'],
['name' => 'Architekturbüro Lang', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'buero-lang', 'status' => 'provisioning'],
['name' => 'Weingut Prantl', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'weingut-prantl', 'status' => 'provisioning'],
['name' => 'Steuerberatung Reiss', 'plan' => 'Team', 'mrr' => $eur(198), 'instance' => 'stb-reiss', 'status' => 'active'],
['name' => 'Notariat Külz', 'plan' => 'Enterprise', 'mrr' => $eur(449), 'instance' => 'notariat-kuelz', 'status' => 'active'],
['name' => 'Praxis Sommer', 'plan' => 'Solo', 'mrr' => $eur(79), 'instance' => 'praxis-sommer', 'status' => 'suspended'],
],
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => ['Solo', 'Team', 'Enterprise'],
'datasets' => [[
'data' => [21, 17, 4],
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Hosts extends Component
{
public function render()
{
return view('livewire.admin.hosts', [
'rows' => [
['name' => 'pve-fsn-1', 'ip' => '10.10.0.11', 'instances' => 12, 'used' => 72, 'cpu' => 58, 'status' => 'active'],
['name' => 'pve-fsn-2', 'ip' => '10.10.0.12', 'instances' => 11, 'used' => 64, 'cpu' => 47, 'status' => 'active'],
['name' => 'pve-fsn-3', 'ip' => '10.10.0.13', 'instances' => 13, 'used' => 81, 'cpu' => 74, 'status' => 'warning'],
['name' => 'pve-hel-1', 'ip' => '10.20.0.11', 'instances' => 3, 'used' => 38, 'cpu' => 22, 'status' => 'active'],
],
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Instances extends Component
{
public function render()
{
return view('livewire.admin.instances', [
'rows' => [
['sub' => 'kanzlei-berger', 'host' => 'pve-fsn-1', 'vmid' => 1042, 'storage' => '235 / 500 GB', 'version' => 'NC 31.0.4', 'status' => 'active'],
['sub' => 'stb-reiss', 'host' => 'pve-fsn-1', 'vmid' => 1051, 'storage' => '88 / 250 GB', 'version' => 'NC 31.0.4', 'status' => 'active'],
['sub' => 'notariat-kuelz', 'host' => 'pve-fsn-2', 'vmid' => 1067, 'storage' => '612 / 1000 GB', 'version' => 'NC 31.0.4', 'status' => 'active'],
['sub' => 'ordination-fux', 'host' => 'pve-fsn-2', 'vmid' => 1072, 'storage' => '2 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'],
['sub' => 'buero-lang', 'host' => 'pve-fsn-3', 'vmid' => 1074, 'storage' => '—', 'version' => 'NC 31.0.4', 'status' => 'provisioning'],
['sub' => 'weingut-prantl', 'host' => 'pve-hel-1', 'vmid' => 1075, 'storage' => '4 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'],
['sub' => 'praxis-sommer', 'host' => 'pve-fsn-3', 'vmid' => 1039, 'storage' => '41 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'suspended'],
],
]);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Overview extends Component
{
public function render()
{
$locale = app()->getLocale();
$months = collect(range(11, 0))
->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM'))
->all();
return view('livewire.admin.overview', [
'kpis' => [
['label' => __('admin.kpi.customers'), 'value' => '42', 'delta' => '+3', 'tone' => 'success'],
['label' => __('admin.kpi.instances'), 'value' => '39', 'sub' => __('admin.kpi.instances_sub'), 'tone' => 'success'],
['label' => __('admin.kpi.hosts'), 'value' => '4', 'sub' => __('admin.kpi.hosts_sub'), 'tone' => 'muted'],
['label' => __('admin.kpi.mrr'), 'value' => Number::currency(7842, in: 'EUR', locale: $locale), 'delta' => '+6,2 %', 'tone' => 'success'],
],
'fleetChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => __('admin.kpi.instances'),
'data' => [21, 23, 25, 27, 28, 30, 31, 33, 35, 36, 38, 39],
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.12',
'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],
'revenueChart' => [
'type' => 'bar',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'MRR',
'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842],
'backgroundColor' => 'token:accent/0.85', 'borderRadius' => 3,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],
'hostChart' => [
'type' => 'bar',
'data' => [
'labels' => ['pve-fsn-1', 'pve-fsn-2', 'pve-fsn-3', 'pve-hel-1'],
'datasets' => [[
'label' => '%',
'data' => [72, 64, 81, 38],
'backgroundColor' => ['token:accent/0.85', 'token:accent/0.85', 'token:danger/0.85', 'token:success-bright/0.85'],
'borderRadius' => 3,
]],
],
'options' => [
'indexAxis' => 'y',
'scales' => ['x' => ['max' => 100, 'grid' => ['color' => 'token:border']], 'y' => ['grid' => ['display' => false]]],
'plugins' => ['legend' => ['display' => false]],
],
],
'runs' => [
['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'],
['customer' => 'Architekturbüro Lang', 'step' => __('admin.run.dns'), 'state' => 'running'],
['customer' => 'Weingut Prantl', 'step' => __('admin.run.acceptance'), 'state' => 'running'],
],
'alerts' => [
['level' => 'warning', 'text' => __('admin.alert.host_load', ['host' => 'pve-fsn-3'])],
['level' => 'info', 'text' => __('admin.alert.cert_soon', ['n' => 3])],
],
]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Provisioning extends Component
{
public function render()
{
return view('livewire.admin.provisioning', [
'runs' => [
['customer' => 'Ordination Dr. Fux', 'pipeline' => 'customer', 'step' => 'DeployApplicationStack', 'n' => '8/15', 'attempt' => 1, 'state' => 'running'],
['customer' => 'Architekturbüro Lang', 'pipeline' => 'customer', 'step' => 'ConfigureDnsAndTls', 'n' => '11/15', 'attempt' => 2, 'state' => 'running'],
['customer' => 'Weingut Prantl', 'pipeline' => 'customer', 'step' => 'RunAcceptanceChecks', 'n' => '14/15', 'attempt' => 1, 'state' => 'running'],
['customer' => 'Praxis Sommer', 'pipeline' => 'customer', 'step' => 'CloneVirtualMachine', 'n' => '3/15', 'attempt' => 3, 'state' => 'failed'],
['customer' => 'Steuerberatung Reiss', 'pipeline' => 'customer', 'step' => 'CompleteProvisioning', 'n' => '15/15', 'attempt' => 1, 'state' => 'done'],
],
'steps' => [
['label' => 'ValidateOrder', 'state' => 'done'],
['label' => 'ReserveResources', 'state' => 'done'],
['label' => 'CloneVirtualMachine', 'state' => 'done'],
['label' => 'ConfigureCloudInit', 'state' => 'done'],
['label' => 'StartVirtualMachine', 'state' => 'done'],
['label' => 'WaitForGuestAgent', 'state' => 'done'],
['label' => 'ConfigureNetwork', 'state' => 'done'],
['label' => 'DeployApplicationStack', 'state' => 'running'],
['label' => 'ConfigureNextcloud', 'state' => 'pending'],
['label' => 'RunAcceptanceChecks', 'state' => 'pending'],
],
]);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Revenue extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale);
$months = collect(range(11, 0))
->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM'))
->all();
return view('livewire.admin.revenue', [
'kpis' => [
['label' => __('admin.rev.mrr'), 'value' => $eur(7842), 'delta' => '+6,2 %'],
['label' => __('admin.rev.arr'), 'value' => $eur(94104), 'delta' => '+18 %'],
['label' => __('admin.rev.arpu'), 'value' => $eur(187), 'delta' => '+1,4 %'],
['label' => __('admin.rev.churn'), 'value' => '1,8 %', 'delta' => '0,3 %'],
],
'mrrChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'MRR',
'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842],
'borderColor' => 'token:accent', 'backgroundColor' => 'token:accent/0.12',
'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],
'planChart' => [
'type' => 'doughnut',
'data' => [
'labels' => ['Solo', 'Team', 'Enterprise'],
'datasets' => [[
'data' => [1659, 3366, 1796],
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'],
'borderWidth' => 0,
]],
],
'options' => ['cutout' => '62%', 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]]],
],
'payments' => [
['customer' => 'Notariat Külz', 'amount' => $eur(449), 'when' => '01.07.'],
['customer' => 'Kanzlei Berger', 'amount' => $eur(198), 'when' => '01.07.'],
['customer' => 'Steuerberatung Reiss', 'amount' => $eur(198), 'when' => '01.07.'],
['customer' => 'Ordination Dr. Fux', 'amount' => $eur(79), 'when' => '30.06.'],
],
]);
}
}

View File

@ -11,7 +11,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
#[Fillable(['name', 'email', 'password'])]
#[Fillable(['name', 'email', 'password', 'is_admin'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
@ -28,6 +28,7 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
];
}
}

View File

@ -13,7 +13,9 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};

View File

@ -23,6 +23,17 @@ class DatabaseSeeder extends Seeder
[
'name' => 'Admin',
'password' => Hash::make(env('SEED_ADMIN_PASSWORD', 'password')),
'is_admin' => true,
],
);
// A plain customer to exercise the portal (and prove the admin gate).
User::updateOrCreate(
['email' => 'kunde@clupilot.local'],
[
'name' => 'Dr. S. Berger',
'password' => Hash::make(env('SEED_ADMIN_PASSWORD', 'password')),
'is_admin' => false,
],
);
}

91
lang/de/admin.php Normal file
View File

@ -0,0 +1,91 @@
<?php
return [
'console' => 'Operator-Konsole',
'badge' => 'Admin',
'to_portal' => 'Zum Kundenportal',
'nav' => [
'overview' => 'Übersicht',
'customers' => 'Kunden',
'instances' => 'Instanzen',
'hosts' => 'Hosts',
'provisioning' => 'Provisioning',
'revenue' => 'Umsatz',
],
'overview_title' => 'Fleet-Übersicht',
'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.',
'systems_ok' => 'Alle Systeme normal',
'kpi' => [
'customers' => 'Kunden',
'instances' => 'Aktive Instanzen',
'instances_sub' => '3 in Bereitstellung',
'hosts' => 'Hosts',
'hosts_sub' => '3 · Falkenstein · 1 · Helsinki',
'mrr' => 'MRR',
],
'fleet_growth' => 'Flotten-Wachstum',
'fleet_growth_sub' => 'Aktive Instanzen, 12 Monate.',
'host_load' => 'Host-Auslastung',
'host_load_sub' => 'Belegter Speicher je Host.',
'mrr_trend' => 'MRR-Verlauf',
'active_runs' => 'Laufende Bereitstellungen',
'view_all' => 'Alle ansehen',
'alerts' => 'Hinweise',
'run_detail' => 'Aktueller Lauf',
'run' => [
'deploy' => 'DeployApplicationStack',
'dns' => 'ConfigureDnsAndTls',
'acceptance' => 'RunAcceptanceChecks',
],
'alert' => [
'host_load' => 'Host :host über 80 % Speicherauslastung.',
'cert_soon' => ':n Zertifikate laufen in 14 Tagen ab.',
],
'customers_sub' => 'Alle Kunden und ihre Pakete.',
'by_plan' => 'Nach Paket',
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
'instances_label' => 'Instanzen',
'hosts_sub' => 'Proxmox-Hosts und Kapazität.',
'storage_used' => 'Speicher belegt',
'provisioning_sub' => 'Laufende und abgeschlossene Bereitstellungen.',
'revenue_sub' => 'Umsatzkennzahlen der Plattform.',
'mrr_by_plan' => 'MRR nach Paket',
'recent_payments' => 'Letzte Zahlungen',
'col' => [
'customer' => 'Kunde',
'plan' => 'Paket',
'mrr' => 'MRR',
'status' => 'Status',
'subdomain' => 'Subdomain',
'host' => 'Host',
'storage' => 'Speicher',
'version' => 'Version',
'step' => 'Schritt',
'attempt' => 'Versuch',
],
'status' => [
'active' => 'Aktiv',
'provisioning' => 'Bereitstellung',
'suspended' => 'Ausgesetzt',
'warning' => 'Warnung',
],
'state_running' => 'Läuft',
'state_done' => 'Fertig',
'state_failed' => 'Fehlgeschlagen',
'rev' => [
'mrr' => 'MRR',
'arr' => 'ARR',
'arpu' => 'ARPU',
'churn' => 'Churn (30 T)',
],
];

91
lang/en/admin.php Normal file
View File

@ -0,0 +1,91 @@
<?php
return [
'console' => 'Operator console',
'badge' => 'Admin',
'to_portal' => 'To customer portal',
'nav' => [
'overview' => 'Overview',
'customers' => 'Customers',
'instances' => 'Instances',
'hosts' => 'Hosts',
'provisioning' => 'Provisioning',
'revenue' => 'Revenue',
],
'overview_title' => 'Fleet overview',
'overview_sub' => 'The whole platform at a glance.',
'systems_ok' => 'All systems normal',
'kpi' => [
'customers' => 'Customers',
'instances' => 'Active instances',
'instances_sub' => '3 provisioning',
'hosts' => 'Hosts',
'hosts_sub' => '3 · Falkenstein · 1 · Helsinki',
'mrr' => 'MRR',
],
'fleet_growth' => 'Fleet growth',
'fleet_growth_sub' => 'Active instances, 12 months.',
'host_load' => 'Host load',
'host_load_sub' => 'Used storage per host.',
'mrr_trend' => 'MRR trend',
'active_runs' => 'Active provisioning',
'view_all' => 'View all',
'alerts' => 'Alerts',
'run_detail' => 'Current run',
'run' => [
'deploy' => 'DeployApplicationStack',
'dns' => 'ConfigureDnsAndTls',
'acceptance' => 'RunAcceptanceChecks',
],
'alert' => [
'host_load' => 'Host :host over 80% storage usage.',
'cert_soon' => ':n certificates expire in 14 days.',
],
'customers_sub' => 'All customers and their plans.',
'by_plan' => 'By plan',
'instances_sub' => 'All provisioned cloud instances.',
'instances_label' => 'instances',
'hosts_sub' => 'Proxmox hosts and capacity.',
'storage_used' => 'Storage used',
'provisioning_sub' => 'Running and completed provisioning.',
'revenue_sub' => 'Platform revenue metrics.',
'mrr_by_plan' => 'MRR by plan',
'recent_payments' => 'Recent payments',
'col' => [
'customer' => 'Customer',
'plan' => 'Plan',
'mrr' => 'MRR',
'status' => 'Status',
'subdomain' => 'Subdomain',
'host' => 'Host',
'storage' => 'Storage',
'version' => 'Version',
'step' => 'Step',
'attempt' => 'Attempt',
],
'status' => [
'active' => 'Active',
'provisioning' => 'Provisioning',
'suspended' => 'Suspended',
'warning' => 'Warning',
],
'state_running' => 'Running',
'state_done' => 'Done',
'state_failed' => 'Failed',
'rev' => [
'mrr' => 'MRR',
'arr' => 'ARR',
'arpu' => 'ARPU',
'churn' => 'Churn (30d)',
],
];

View File

@ -0,0 +1,33 @@
/* CluPilot admin console "Tactical Terminal": dark graphite, signal-orange,
ops status triad. Scoped to .theme-admin so it overrides the light portal
tokens only inside the admin layout every shared component adapts for free. */
.theme-admin {
--bg: #0b0e13;
--surface: #12161d;
--surface-2: #1a2029;
--surface-hover: #1c232d;
--border: #232b37;
--border-strong: #313b4a;
--text-strong: #eef1f6;
--text: #c2cad6;
--text-muted: #8b95a5;
--text-faint: #626d7e;
/* signal orange — brighter for dark; dark text sits on the fill (AA) */
--accent: #ff7a2f;
--accent-hover: #ff8f4d;
--accent-active: #ff7a2f;
--accent-press: #e8630f;
--accent-subtle: rgba(255, 122, 47, .14);
--accent-border: rgba(255, 122, 47, .32);
--accent-ring: rgba(255, 122, 47, .45);
--on-accent: #0b0e13;
--accent-text: #ff9152;
--success: #35d07f; --success-bg: rgba(53, 208, 127, .13); --success-border: rgba(53, 208, 127, .32); --success-bright: #35d07f;
--warning: #e8b931; --warning-bg: rgba(232, 185, 49, .13); --warning-border: rgba(232, 185, 49, .32);
--danger: #ff5247; --danger-bg: rgba(255, 82, 71, .13); --danger-border: rgba(255, 82, 71, .32);
--info: #5b9bff; --info-bg: rgba(91, 155, 255, .13); --info-border: rgba(91, 155, 255, .32);
}

View File

@ -10,6 +10,7 @@
/* Design tokens, then Tailwind v3 layers. */
@import './portal-tokens.css';
@import './admin-tokens.css';
@tailwind base;
@tailwind components;

View File

@ -74,7 +74,9 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('chart', (config) => ({
instance: null,
init() {
const css = getComputedStyle(document.documentElement);
// Read tokens from this element so charts honour a scoped theme
// (e.g. .theme-admin dark tokens), not just :root.
const css = getComputedStyle(this.$el);
Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif';
Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085';
Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec';

View File

@ -19,6 +19,11 @@
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
'check' => '<polyline points="20 6 9 17 4 12"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'box' => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'bell' => '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
];
$body = $icons[$name] ?? '';
@endphp

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? __('admin.console') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}}
<body class="theme-admin min-h-full bg-bg text-body antialiased" x-data="{ nav: false }">
<div class="flex min-h-screen">
<aside
class="fixed inset-y-0 left-0 z-40 w-60 border-r border-line bg-surface px-3 py-4 transition-transform lg:static lg:translate-x-0"
:class="nav ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center justify-between px-3 pb-4">
<div class="flex items-center gap-2">
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
<span class="rounded bg-accent-subtle px-1.5 py-0.5 font-mono text-[0.6rem] font-semibold uppercase tracking-wider text-accent-text">{{ __('admin.badge') }}</span>
</div>
<button type="button" class="lg:hidden text-muted" @click="nav = false" aria-label="{{ __('dashboard.close_nav') }}">
<x-ui.icon name="chevron-down" class="size-5 rotate-90" />
</button>
</div>
<nav class="space-y-1">
@foreach ([
['admin.overview', 'gauge', 'overview'],
['admin.customers', 'users', 'customers'],
['admin.instances', 'box', 'instances'],
['admin.hosts', 'server', 'hosts'],
['admin.provisioning', 'activity', 'provisioning'],
['admin.revenue', 'trending-up', 'revenue'],
] as [$route, $icon, $key])
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
{{ __('admin.nav.'.$key) }}
</x-ui.nav-item>
@endforeach
</nav>
<div class="mt-auto border-t border-line px-2 pt-4">
<a href="{{ route('dashboard') }}" class="flex items-center gap-2 rounded px-2 py-2 text-xs font-medium text-muted hover:bg-surface-hover hover:text-body">
<x-ui.icon name="external-link" class="size-4" />{{ __('admin.to_portal') }}
</a>
</div>
</aside>
<div x-show="nav" x-cloak @click="nav = false" class="fixed inset-0 z-30 bg-black/40 lg:hidden"></div>
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-14 items-center justify-between border-b border-line bg-surface px-4">
<button type="button" class="lg:hidden inline-flex min-h-11 min-w-11 items-center justify-center rounded text-muted hover:bg-surface-hover" @click="nav = true" aria-label="{{ __('dashboard.open_nav') }}">
<x-ui.icon name="menu" />
</button>
<p class="hidden font-mono text-xs text-faint sm:block">{{ __('admin.console') }}</p>
<div class="flex-1"></div>
@auth
<div class="relative" x-data="{ open: false }" @keydown.escape="open = false">
<button type="button" class="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface-hover" @click="open = !open" :aria-expanded="open" aria-haspopup="menu">
<span class="flex size-8 items-center justify-center rounded-pill bg-accent-subtle text-sm font-semibold text-accent-text">{{ \Illuminate\Support\Str::upper(\Illuminate\Support\Str::substr(auth()->user()->name, 0, 1)) }}</span>
<span class="hidden text-sm text-body sm:block">{{ auth()->user()->name }}</span>
<x-ui.icon name="chevron-down" class="size-4 text-muted" />
</button>
<div x-show="open" x-cloak @click.outside="open = false" class="absolute right-0 mt-1 w-48 rounded-lg border border-line bg-surface py-1 shadow-md">
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-body hover:bg-surface-hover">
<x-ui.icon name="log-out" class="size-4" />{{ __('dashboard.logout') }}
</button>
</form>
</div>
</div>
@endauth
</header>
<main class="mx-auto w-full max-w-[1240px] flex-1 p-6 lg:p-8">
{{ $slot }}
</main>
</div>
</div>
<div
x-data="{ show: false, msg: '', timer: null }"
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 2400)"
x-show="show" x-cloak x-transition.opacity.duration.200ms
role="status" aria-live="polite"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md"
>
<span x-text="msg"></span>
</div>
@livewireScripts
</body>
</html>

View File

@ -0,0 +1,41 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.customers') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.customers_sub') }}</p>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.plan') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.mrr') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $r['name'] }}</p>
<p class="font-mono text-xs text-muted">{{ $r['instance'] }}.clupilot.com</p>
</td>
<td class="px-4 py-3 text-body">{{ $r['plan'] }}</td>
<td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.by_plan') }}</h2>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$plansChart" class="h-56" :label="__('admin.by_plan')" /></div>
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.hosts') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.hosts_sub') }}</p>
</div>
@php $hd = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]', '[animation-delay:240ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@foreach ($rows as $i => $h)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $hd[$i] ?? '' }}">
<div class="flex items-center gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-text"><x-ui.icon name="server" class="size-5" /></span>
<div class="min-w-0">
<p class="font-mono font-semibold text-ink">{{ $h['name'] }}</p>
<p class="font-mono text-xs text-muted">{{ $h['ip'] }} · {{ $h['instances'] }} {{ __('admin.instances_label') }}</p>
</div>
<x-ui.badge :status="$h['status'] === 'warning' ? 'warning' : 'active'" class="ml-auto">{{ __('admin.status.'.($h['status'] === 'warning' ? 'warning' : 'active')) }}</x-ui.badge>
</div>
<div class="mt-4 space-y-3">
<div>
<div class="flex justify-between text-xs"><span class="text-muted">{{ __('admin.storage_used') }}</span><span class="font-mono text-body">{{ $h['used'] }}%</span></div>
<div class="mt-1 h-1.5 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $h['used'] >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $h['used'] }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs"><span class="text-muted">CPU</span><span class="font-mono text-body">{{ $h['cpu'] }}%</span></div>
<div class="mt-1 h-1.5 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $h['cpu'] >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $h['cpu'] }}%"></div>
</div>
</div>
</div>
</div>
@endforeach
</div>
</div>

View File

@ -0,0 +1,35 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.instances') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.instances_sub') }}</p>
</div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.subdomain') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.host') }}</th>
<th class="px-4 py-3 font-semibold">VMID</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.storage') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.version') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-mono text-body">{{ $r['sub'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['host'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['vmid'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['storage'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['version'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,80 @@
<div class="space-y-5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.overview_title') }}</h1>
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('admin.systems_ok') }}
</span>
<p class="w-full text-sm text-muted">{{ __('admin.overview_sub') }}</p>
</div>
{{-- KPI row --}}
@php $kd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($kpis as $i => $kpi)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $kd[$i] ?? '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p>
<p class="mt-2 flex items-baseline gap-2">
<span class="font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</span>
@isset($kpi['delta'])<span class="font-mono text-xs font-semibold text-success">{{ $kpi['delta'] }}</span>@endisset
</p>
@isset($kpi['sub'])<p class="mt-1 text-xs text-muted">{{ $kpi['sub'] }}</p>@endisset
</div>
@endforeach
</div>
{{-- Charts --}}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.fleet_growth') }}</h2>
<p class="text-sm text-muted">{{ __('admin.fleet_growth_sub') }}</p>
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$fleetChart" class="h-56" :label="__('admin.fleet_growth')" /></div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2>
<p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p>
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$hostChart" class="h-56" :label="__('admin.host_load')" /></div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_trend') }}</h2>
<div class="mt-3 h-52" wire:ignore><x-ui.chart :config="$revenueChart" class="h-52" :label="__('admin.mrr_trend')" /></div>
</div>
<div class="flex flex-col gap-4">
{{-- Active runs --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('admin.active_runs') }}</h2>
<a href="{{ route('admin.provisioning') }}" class="ml-auto text-xs font-semibold text-accent-text hover:underline">{{ __('admin.view_all') }} </a>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($runs as $r)
<li class="flex items-center gap-3 py-2.5 text-sm">
<span class="size-2 shrink-0 rounded-pill bg-info animate-pulse" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-ink">{{ $r['customer'] }}</p>
<p class="font-mono text-xs text-muted">{{ $r['step'] }}</p>
</div>
<x-ui.badge status="provisioning">{{ __('admin.state_running') }}</x-ui.badge>
</li>
@endforeach
</ul>
</div>
{{-- Alerts --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('admin.alerts') }}</h2>
<ul class="mt-2 space-y-2">
@foreach ($alerts as $a)
<li class="flex items-start gap-2.5 text-sm">
<x-ui.icon name="{{ $a['level'] === 'warning' ? 'alert-triangle' : 'bell' }}" class="mt-0.5 size-4 shrink-0 {{ $a['level'] === 'warning' ? 'text-warning' : 'text-info' }}" />
<span class="text-body">{{ $a['text'] }}</span>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,40 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.provisioning') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.provisioning_sub') }}</p>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_300px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('admin.col.customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.step') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.attempt') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('admin.col.status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($runs as $r)
@php $tone = ['running' => 'provisioning', 'done' => 'active', 'failed' => 'failed'][$r['state']] ?? 'info'; @endphp
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-medium text-ink">{{ $r['customer'] }}</td>
<td class="px-4 py-3"><span class="font-mono text-xs text-body">{{ $r['step'] }}</span> <span class="font-mono text-xs text-faint">{{ $r['n'] }}</span></td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['attempt'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$tone">{{ __('admin.state_'.$r['state']) }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.run_detail') }}</h2>
<p class="mb-3 font-mono text-xs text-muted">ordination-fux · customer</p>
<x-ui.progress-stepper :steps="$steps" />
</div>
</div>
</div>

View File

@ -0,0 +1,44 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('admin.nav.revenue') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('admin.revenue_sub') }}</p>
</div>
@php $rd = ['[animation-delay:40ms]', '[animation-delay:80ms]', '[animation-delay:120ms]', '[animation-delay:160ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($kpis as $i => $kpi)
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise {{ $rd[$i] ?? '' }}">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $kpi['label'] }}</p>
<p class="mt-2 flex items-baseline gap-2">
<span class="font-mono text-2xl font-semibold text-ink">{{ $kpi['value'] }}</span>
<span class="font-mono text-xs font-semibold text-success">{{ $kpi['delta'] }}</span>
</p>
</div>
@endforeach
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_trend') }}</h2>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$mrrChart" class="h-56" :label="__('admin.mrr_trend')" /></div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_by_plan') }}</h2>
<div class="mt-3 h-56" wire:ignore><x-ui.chart :config="$planChart" class="h-56" :label="__('admin.mrr_by_plan')" /></div>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:180ms]">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('admin.recent_payments') }}</h2></div>
<ul class="divide-y divide-line">
@foreach ($payments as $p)
<li class="flex items-center gap-3 px-5 py-3 text-sm">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-success"><x-ui.icon name="check" class="size-4" /></span>
<span class="min-w-0 flex-1 truncate font-medium text-ink">{{ $p['customer'] }}</span>
<span class="font-mono text-xs text-faint">{{ $p['when'] }}</span>
<span class="font-mono text-body">{{ $p['amount'] }}</span>
</li>
@endforeach
</ul>
</div>
</div>

View File

@ -1,5 +1,6 @@
<?php
use App\Livewire\Admin;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Livewire\Backups;
@ -29,3 +30,13 @@ Route::middleware('auth')->group(function () {
Route::get('/invoices', Invoices::class)->name('invoices');
Route::get('/support', Support::class)->name('support');
});
// Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13).
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/', Admin\Overview::class)->name('overview');
Route::get('/customers', Admin\Customers::class)->name('customers');
Route::get('/instances', Admin\Instances::class)->name('instances');
Route::get('/hosts', Admin\Hosts::class)->name('hosts');
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
});

View File

@ -0,0 +1,23 @@
<?php
use App\Models\User;
$adminRoutes = ['admin.overview', 'admin.customers', 'admin.instances', 'admin.hosts', 'admin.provisioning', 'admin.revenue'];
it('redirects guests from the admin console to login', function (string $route) {
$this->get(route($route))->assertRedirect('/login');
})->with($adminRoutes);
it('forbids non-admin users from the admin console', function (string $route) {
$user = User::factory()->create(['is_admin' => false]);
$this->actingAs($user)->get(route($route))->assertForbidden();
})->with($adminRoutes);
it('renders the admin console for admins', function (string $route) {
$admin = User::factory()->create(['is_admin' => true]);
$this->actingAs($admin)->get(route($route))
->assertOk()
->assertSee('CluPilot');
})->with($adminRoutes);