diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 42cff36..29ae099 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -121,6 +121,38 @@ class Users extends Component } } + /** + * Pause a seat without destroying it. + * + * The action an owner actually needs when someone leaves: access stops now, + * and the record of who held it survives — which is the half a deletion + * throws away, on a product sold on being able to show who had access to + * what. + */ + public function suspend(string $uuid): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $seat = $customer->seats()->where('uuid', $uuid)->first(); + + if ($seat === null || $seat->role === 'owner') { + // The owner cannot lock themselves out of their own cloud. + $this->dispatch('notify', message: __('users.owner_locked')); + + return; + } + + $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']); + + $this->dispatch('notify', message: __( + $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated', + )); + } + public function revoke(string $uuid): void { $customer = $this->requireCustomer(); @@ -196,7 +228,7 @@ class Users extends Component // The owner can be neither re-invited nor revoked, so a table holding // only the owner has nothing to act on and should not carry a column // of empty cells. - $hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner' || $seat->status === 'invited'); + $hasActions = $seats->contains(fn ($seat) => $seat->role !== 'owner'); return view('livewire.users', [ 'hasActions' => $hasActions, diff --git a/database/seeders/DemoCustomerSeeder.php b/database/seeders/DemoCustomerSeeder.php new file mode 100644 index 0000000..83f045a --- /dev/null +++ b/database/seeders/DemoCustomerSeeder.php @@ -0,0 +1,197 @@ +first()?->delete()" + * + * Safe to run twice: every row is matched on a natural key first. + */ +class DemoCustomerSeeder extends Seeder +{ + private const EMAIL = 'demo@clupilot.com'; + + public function run(): void + { + $user = User::firstOrCreate( + ['email' => self::EMAIL], + ['name' => 'Kanzlei Muster', 'password' => Hash::make(config('app.demo_password', 'Muster!2026'))], + ); + + $customer = Customer::firstOrCreate( + ['user_id' => $user->id], + ['name' => 'Kanzlei Muster', 'email' => self::EMAIL, 'contact_name' => 'Dr. M. Muster', 'status' => 'active'], + ); + + $datacenter = Datacenter::firstOrCreate( + ['code' => 'fsn'], + ['name' => 'Falkenstein', 'location' => 'DE', 'active' => true], + ); + + $host = Host::query()->where('datacenter', $datacenter->code)->first() + ?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'pve-fsn-01', 'status' => 'active']); + + $order = Order::firstOrCreate( + ['customer_id' => $customer->id, 'type' => 'plan'], + [ + 'plan' => 'team', + 'amount_cents' => 17900, + 'currency' => 'EUR', + 'datacenter' => $datacenter->code, + 'status' => 'paid', + ], + ); + + $instance = Instance::firstOrCreate( + ['customer_id' => $customer->id], + [ + 'order_id' => $order->id, + 'host_id' => $host->id, + 'vmid' => 9001, + 'plan' => 'team', + 'quota_gb' => 500, + 'disk_gb' => 500, + 'ram_mb' => 8192, + 'cores' => 4, + 'traffic_addons' => 0, + 'guest_ip' => '10.20.0.31', + 'subdomain' => 'kanzlei-muster', + 'status' => 'active', + 'cert_ok' => true, + 'route_written' => true, + ], + ); + + Subscription::firstOrCreate( + ['customer_id' => $customer->id], + [ + 'order_id' => $order->id, + 'instance_id' => $instance->id, + 'plan' => 'team', + 'tier' => 2, + 'term' => 'monthly', + 'price_cents' => 17900, + 'currency' => 'EUR', + 'quota_gb' => 500, + 'traffic_gb' => 3072, + 'seats' => 25, + 'ram_mb' => 8192, + 'cores' => 4, + 'disk_gb' => 500, + 'performance' => 'enhanced', + 'status' => 'active', + 'started_at' => now()->subMonths(4), + 'current_period_start' => now()->startOfMonth(), + 'current_period_end' => now()->addMonth()->startOfMonth(), + ], + ); + + $this->seats($customer); + $this->backups($instance); + $this->traffic($instance); + $this->metrics($instance); + + $this->command?->info('Demo customer ready: '.self::EMAIL); + } + + private function seats(Customer $customer): void + { + $people = [ + ['Dr. M. Muster', 'demo@clupilot.com', 'owner', 'active'], + ['S. Berger', 'berger@kanzlei-muster.test', 'admin', 'active'], + ['A. Wimmer', 'wimmer@kanzlei-muster.test', 'member', 'active'], + ['T. Klein', 'klein@kanzlei-muster.test', 'member', 'active'], + ['P. Hofer', 'hofer@kanzlei-muster.test', 'member', 'invited'], + ['Steuerberatung Ost', 'extern@partner.test', 'readonly', 'active'], + ]; + + foreach ($people as [$name, $email, $role, $status]) { + Seat::firstOrCreate( + ['customer_id' => $customer->id, 'email' => $email], + ['name' => $name, 'role' => $role, 'status' => $status, 'invited_at' => now()->subDays(30)], + ); + } + } + + private function backups(Instance $instance): void + { + Backup::firstOrCreate( + ['instance_id' => $instance->id, 'schedule' => 'täglich 03:00'], + ['external_job_id' => 'demo-backup', 'status' => 'ok', 'last_ok_at' => now()->setTime(3, 12)], + ); + } + + private function traffic(Instance $instance): void + { + InstanceTraffic::firstOrCreate( + ['instance_id' => $instance->id, 'period' => InstanceTraffic::currentPeriod()], + [ + 'rx_bytes' => (int) (340 * 1024 ** 3), + 'tx_bytes' => (int) (72 * 1024 ** 3), + 'last_netin' => 0, + 'last_netout' => 0, + 'sampled_at' => now(), + ], + ); + } + + /** + * Thirty days of samples: a disk that fills slowly, transfer that varies, + * and one day where two checks out of 288 failed. + * + * Deliberately not a smooth curve — a straight line is the first thing that + * gives a demo away, and a chart that never wobbles teaches nobody what a + * real one looks like. + */ + private function metrics(Instance $instance): void + { + $total = (int) (500 * 1024 ** 3); + $used = (int) (198 * 1024 ** 3); + $wobble = [1.0, 0.6, 1.4, 0.9, 1.7, 0.4, 0.2, 1.1, 1.5, 0.8]; + + foreach (range(29, 0) as $offset) { + $day = Carbon::today()->subDays($offset); + $i = 29 - $offset; + $used += (int) ($wobble[$i % 10] * 0.42 * 1024 ** 3); + + InstanceMetric::updateOrCreate( + ['instance_id' => $instance->id, 'day' => $day->toDateString()], + [ + 'disk_used_bytes' => $used, + 'disk_total_bytes' => $total, + 'rx_bytes' => (int) ((9 + $wobble[$i % 10] * 4) * 1024 ** 3), + 'tx_bytes' => (int) ((2 + $wobble[($i + 3) % 10]) * 1024 ** 3), + // 288 checks a day is one every five minutes. + 'checks_total' => 288, + 'checks_ok' => $offset === 12 ? 286 : 288, + ], + ); + } + } +} diff --git a/lang/de/users.php b/lang/de/users.php index d4a1bde..425dbff 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -31,6 +31,12 @@ return [ 'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.', 'col_actions' => 'Aktionen', + 'suspend' => 'Sperren', + 'reactivate' => 'Entsperren', + 'suspended' => 'Zugang gesperrt.', + 'reactivated' => 'Zugang wieder freigegeben.', + 'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren.', + 'status_suspended' => 'Gesperrt', 'status_active' => 'Aktiv', 'status_invited' => 'Eingeladen', 'status_revoked' => 'Entfernt', diff --git a/lang/en/users.php b/lang/en/users.php index ad1105c..2a94415 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -31,6 +31,12 @@ return [ 'owner_no_actions' => 'The owner cannot be removed.', 'col_actions' => 'Actions', + 'suspend' => 'Suspend', + 'reactivate' => 'Reactivate', + 'suspended' => 'Access suspended.', + 'reactivated' => 'Access restored.', + 'owner_locked' => 'The owner cannot lock themselves out.', + 'status_suspended' => 'Suspended', 'status_active' => 'Active', 'status_invited' => 'Invited', 'status_revoked' => 'Removed', diff --git a/resources/css/app.css b/resources/css/app.css index 8109c48..c670c8b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -91,9 +91,9 @@ * a browser skip the declaration, it computes to `unset` — which for a * stroke-dashoffset silently draws a full ring at every value. */ -.ring { transform: rotate(-90deg); } -.ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; } -.ring .value { +.metric-ring { transform: rotate(-90deg); } +.metric-ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; } +.metric-ring .value { fill: none; stroke: var(--accent); stroke-width: 7; @@ -117,7 +117,7 @@ @keyframes spark-fade { to { opacity: .09; } } @media (prefers-reduced-motion: reduce) { - .ring .value { stroke-dashoffset: var(--o, 0) !important; } + .metric-ring .value { stroke-dashoffset: var(--o, 0) !important; } .spark .line { stroke-dashoffset: 0 !important; } .spark .area { opacity: .09 !important; } } diff --git a/resources/css/portal-tokens.css b/resources/css/portal-tokens.css index 021acfb..a20dafa 100644 --- a/resources/css/portal-tokens.css +++ b/resources/css/portal-tokens.css @@ -103,3 +103,17 @@ --focus-ring: 0 0 0 3px var(--accent-ring); } + +/* + * The small-caps label: the template calls it "the typographic signature, + * carried across all three surfaces". It is written once here so the size + * cannot drift apart between the panel, the console and the site — which is + * exactly how it ended up at 11px in one place and 11.5px in another. + */ +.lbl { + font-family: var(--font-mono); + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted); +} diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php index 00fd757..16fe336 100644 --- a/resources/views/components/shell/nav.blade.php +++ b/resources/views/components/shell/nav.blade.php @@ -38,7 +38,7 @@ @foreach ($groups as $group) @if (! empty($group['label'])) -

{{ $group['label'] }}

+

{{ $group['label'] }}

@endif