diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index 76cb332..60e1cc5 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -33,6 +33,18 @@ class Maintenance extends Component /** @var array */ public array $hostIds = []; + /** Toggle every host of one datacenter (select-all / deselect-all). */ + public function selectDatacenter(string $code): void + { + $this->authorize('maintenance.manage'); + $ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all(); + $current = array_map('strval', $this->hostIds); + + $this->hostIds = empty(array_diff($ids, $current)) + ? array_values(array_diff($current, $ids)) // all selected → clear them + : array_values(array_unique([...$current, ...$ids])); + } + public function saveDraft(): void { $this->authorize('maintenance.manage'); diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 6be5376..78425dd 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -2,7 +2,7 @@ namespace App\Livewire; -use App\Models\Customer; +use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Order; use Livewire\Attributes\Layout; use Livewire\Component; @@ -10,13 +10,15 @@ use Livewire\Component; #[Layout('layouts.portal-app')] class Billing extends Component { + use ResolvesCustomer; + /** * Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is * mocked for now — the order is created with status 'pending'. */ public function purchase(string $type, ?string $key = null): void { - $customer = $this->customer(); + $customer = $this->requireCustomer(); if ($customer === null) { return; } @@ -62,18 +64,6 @@ class Billing extends Component $this->dispatch('notify', message: __('billing.purchased')); } - private function customer(): ?Customer - { - $user = auth()->user(); - if (! $user) { - return null; - } - - // Prefer the explicit link; fall back to email for legacy unlinked accounts. - return Customer::query()->where('user_id', $user->id)->first() - ?? Customer::query()->where('email', $user->email)->first(); - } - public function render() { $customer = $this->customer(); diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index 159a29d..1a23b70 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -2,6 +2,8 @@ namespace App\Livewire; +use App\Livewire\Concerns\ResolvesCustomer; +use App\Models\MaintenanceWindow; use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; @@ -9,11 +11,18 @@ use Livewire\Component; #[Layout('layouts.portal-app')] class Cloud extends Component { + use ResolvesCustomer; + public function render() { $locale = app()->getLocale(); $growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235]; + // Maintenance affecting THIS customer's instance host — shown as a badge + // on the instance itself, not just as a global banner. + $customer = $this->customer(); + $maintenance = $customer ? MaintenanceWindow::bannerFor($customer)->first() : null; + return view('livewire.cloud', [ 'instance' => [ 'name' => 'Cloud der Kanzlei Berger', @@ -52,6 +61,7 @@ class Cloud extends Component ], ], 'storageLabel' => Number::format(235, locale: $locale).' / '.Number::format(500, locale: $locale).' GB', + 'maintenance' => $maintenance, ]); } } diff --git a/app/Livewire/Concerns/ResolvesCustomer.php b/app/Livewire/Concerns/ResolvesCustomer.php new file mode 100644 index 0000000..f41de2e --- /dev/null +++ b/app/Livewire/Concerns/ResolvesCustomer.php @@ -0,0 +1,39 @@ +user(); + if (! $user) { + return null; + } + + // Prefer the explicit link; fall back to email for legacy unlinked accounts. + return Customer::query()->where('user_id', $user->id)->first() + ?? Customer::query()->where('email', $user->email)->first(); + } + + /** Same as customer(), but tells the user why nothing happened. */ + protected function requireCustomer(): ?Customer + { + $customer = $this->customer(); + + if ($customer === null) { + $this->dispatch('notify', message: __('dashboard.no_customer_action')); + } + + return $customer; + } +} diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php index 57a0fee..18f01af 100644 --- a/app/Livewire/ConfirmCancelPackage.php +++ b/app/Livewire/ConfirmCancelPackage.php @@ -2,7 +2,7 @@ namespace App\Livewire; -use App\Models\Customer; +use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Instance; use LivewireUI\Modal\ModalComponent; @@ -14,6 +14,8 @@ use LivewireUI\Modal\ModalComponent; */ class ConfirmCancelPackage extends ModalComponent { + use ResolvesCustomer; + public string $confirmName = ''; public function cancelPackage() @@ -24,7 +26,11 @@ class ConfirmCancelPackage extends ModalComponent $instance = $customer?->instances()->where('status', 'active')->latest('id')->first(); if ($instance === null) { - return $this->redirectRoute('settings', navigate: true); + // Explain rather than silently closing: no customer behind this login + // (e.g. an operator account) or no active package to cancel. + $this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package')); + + return null; } // Typed confirmation must match the instance subdomain. @@ -61,17 +67,6 @@ class ConfirmCancelPackage extends ModalComponent return $start->copy()->addMonthsNoOverflow($months); } - private function customer(): ?Customer - { - $user = auth()->user(); - if (! $user) { - return null; - } - - return Customer::query()->where('user_id', $user->id)->first() - ?? Customer::query()->where('email', $user->email)->first(); - } - public function render() { $instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first(); diff --git a/app/Livewire/ConfirmCloseAccount.php b/app/Livewire/ConfirmCloseAccount.php index 7100c13..abb6514 100644 --- a/app/Livewire/ConfirmCloseAccount.php +++ b/app/Livewire/ConfirmCloseAccount.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use LivewireUI\Modal\ModalComponent; @@ -12,13 +13,19 @@ use LivewireUI\Modal\ModalComponent; */ class ConfirmCloseAccount extends ModalComponent { + use ResolvesCustomer; + public string $confirmWord = ''; public function closeAccount() { $customer = $this->customer(); + // No customer behind this login (e.g. an operator account) — say so + // instead of leaving the button looking broken. if ($customer === null) { - return $this->redirectRoute('settings', navigate: true); + $this->addError('confirmWord', __('dashboard.no_customer_action')); + + return null; } if ($this->hasActivePackage($customer)) { @@ -47,17 +54,6 @@ class ConfirmCloseAccount extends ModalComponent ->exists(); } - private function customer(): ?Customer - { - $user = auth()->user(); - if (! $user) { - return null; - } - - return Customer::query()->where('user_id', $user->id)->first() - ?? Customer::query()->where('email', $user->email)->first(); - } - public function render() { $customer = $this->customer(); diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 7de9a26..059cad8 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -2,7 +2,7 @@ namespace App\Livewire; -use App\Models\Customer; +use App\Livewire\Concerns\ResolvesCustomer; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; @@ -11,7 +11,7 @@ use Livewire\WithFileUploads; class Settings extends Component { - use WithFileUploads; + use ResolvesCustomer, WithFileUploads; // Company / billing profile #[Validate('required|string|max:255')] @@ -63,7 +63,7 @@ class Settings extends Component public function saveProfile(): void { - $c = $this->customer(); + $c = $this->requireCustomer(); if ($c === null) { return; } @@ -90,7 +90,7 @@ class Settings extends Component public function saveBranding(): void { - $c = $this->customer(); + $c = $this->requireCustomer(); if ($c === null) { return; } @@ -128,7 +128,7 @@ class Settings extends Component public function removeLogo(): void { - $c = $this->customer(); + $c = $this->requireCustomer(); if ($c === null) { return; } @@ -140,17 +140,6 @@ class Settings extends Component $this->dispatch('notify', message: __('settings.branding_saved')); } - private function customer(): ?Customer - { - $user = auth()->user(); - if (! $user) { - return null; - } - - return Customer::query()->where('user_id', $user->id)->first() - ?? Customer::query()->where('email', $user->email)->first(); - } - #[Layout('layouts.portal-app')] public function render() { diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index ccbf91c..db7848d 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Models\Seat; use Illuminate\Database\UniqueConstraintViolationException; @@ -13,6 +14,8 @@ use Livewire\Component; #[Layout('layouts.portal-app')] class Users extends Component { + use ResolvesCustomer; + #[Validate('required|email|max:255')] public string $inviteEmail = ''; @@ -42,7 +45,7 @@ class Users extends Component public function invite(): void { - $customer = $this->customer(); + $customer = $this->requireCustomer(); if ($customer === null) { return; } @@ -93,7 +96,7 @@ class Users extends Component if (! in_array($role, Seat::ROLES, true)) { return; } - $customer = $this->customer(); + $customer = $this->requireCustomer(); if ($customer === null) { return; } @@ -120,7 +123,7 @@ class Users extends Component public function revoke(string $uuid): void { - $customer = $this->customer(); + $customer = $this->requireCustomer(); if ($customer === null) { return; } @@ -184,17 +187,6 @@ class Users extends Component return $customer?->seats()->where('uuid', $uuid)->first(); } - private function customer(): ?Customer - { - $user = auth()->user(); - if (! $user) { - return null; - } - - return Customer::query()->where('user_id', $user->id)->first() - ?? Customer::query()->where('email', $user->email)->first(); - } - public function render() { $customer = $this->customer(); diff --git a/lang/de/cloud.php b/lang/de/cloud.php index d48af69..c2b4686 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -14,6 +14,9 @@ return [ 'current' => 'aktuell', 'runtime' => 'Laufzeit', 'seats' => 'Benutzer', + 'maintenance_planned' => 'Wartung geplant', + 'maintenance_active' => 'Wartung läuft', + 'maintenance_window' => 'Zeitraum: :start bis :end.', 'performance' => 'Leistung', 'storage' => 'Speicher', 'storage_growth' => 'Speicherverlauf', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 920a497..397e907 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -21,6 +21,10 @@ return [ 'open_nav' => 'Navigation öffnen', 'close_nav' => 'Navigation schließen', 'logout' => 'Abmelden', + 'no_customer_title' => 'Kein Kundenkonto verknüpft.', + 'no_customer_hint' => 'Dieses Konto ist ein Betreiber-Zugang — Kundenfunktionen sind hier ohne Kundenbezug nicht möglich.', + 'no_customer_cta' => 'Zu Kunden → Verbinden', + 'no_customer_action' => 'Nicht möglich: Mit diesem Konto ist kein Kundenkonto verknüpft.', 'today' => 'Heute', 'yesterday' => 'Gestern', diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index ca8ae30..aa5bc90 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -19,6 +19,12 @@ return [ 'state_cancelled' => 'Storniert', 'new_title' => 'Neues Wartungsfenster', + 'new_sub' => 'Einmal anlegen, mehreren Hosts zuweisen.', + 'field_public_ph' => 'Kurzzeitige Nichtverfügbarkeit möglich.', + 'field_public_hint' => 'Dieser Text erscheint im Kundenportal und in der E-Mail.', + 'field_internal_ph' => 'Nur für das Team sichtbar.', + 'selected' => ':n ausgewählt', + 'select_all' => 'Alle', 'field_title' => 'Titel', 'field_public' => 'Beschreibung für Kunden', 'field_internal' => 'Interne Notizen', diff --git a/lang/de/users.php b/lang/de/users.php index e4ac8d5..04bbec8 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -4,6 +4,7 @@ return [ 'title' => 'Benutzer', 'subtitle' => 'Personen verwalten, die Ihre Cloud nutzen.', 'seats_used' => 'Plätze belegt', + 'seats_note' => 'Der Inhaber belegt Platz 1.', 'invite_email' => 'E-Mail', 'invite_name' => 'Name (optional)', diff --git a/lang/en/cloud.php b/lang/en/cloud.php index 9122fc4..644918a 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -14,6 +14,9 @@ return [ 'current' => 'up to date', 'runtime' => 'Runtime', 'seats' => 'Users', + 'maintenance_planned' => 'Maintenance planned', + 'maintenance_active' => 'Maintenance in progress', + 'maintenance_window' => 'Window: :start to :end.', 'performance' => 'Performance', 'storage' => 'Storage', 'storage_growth' => 'Storage over time', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 9d676f5..02caab1 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -21,6 +21,10 @@ return [ 'open_nav' => 'Open navigation', 'close_nav' => 'Close navigation', 'logout' => 'Sign out', + 'no_customer_title' => 'No customer account linked.', + 'no_customer_hint' => 'This is an operator account — customer features need a customer context.', + 'no_customer_cta' => 'Go to Customers → Connect', + 'no_customer_action' => 'Not possible: no customer account is linked to this login.', 'today' => 'Today', 'yesterday' => 'Yesterday', diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 694f3fb..421567f 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -19,6 +19,12 @@ return [ 'state_cancelled' => 'Cancelled', 'new_title' => 'New maintenance window', + 'new_sub' => 'Create once, assign to multiple hosts.', + 'field_public_ph' => 'Short unavailability possible.', + 'field_public_hint' => 'This text appears in the customer portal and the email.', + 'field_internal_ph' => 'Visible to your team only.', + 'selected' => ':n selected', + 'select_all' => 'All', 'field_title' => 'Title', 'field_public' => 'Customer-facing description', 'field_internal' => 'Internal notes', diff --git a/lang/en/users.php b/lang/en/users.php index 4c1f819..705b31a 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -4,6 +4,7 @@ return [ 'title' => 'Users', 'subtitle' => 'Manage the people who use your cloud.', 'seats_used' => 'seats used', + 'seats_note' => 'The owner occupies seat 1.', 'invite_email' => 'Email', 'invite_name' => 'Name (optional)', diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 792238c..bb16593 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -21,6 +21,19 @@ } } @endphp + @if (auth()->check() && ! ($portalCustomer ?? null)) + {{-- No customer behind this login (e.g. an operator account): say so, or + every customer action on these pages looks like a dead button. --}} +
+ + {{ __('dashboard.no_customer_title') }} + {{ __('dashboard.no_customer_hint') }} + @if (auth()->user()->can('console.view')) + {{ __('dashboard.no_customer_cta') }} + @endif +
+ @endif + @foreach ($maintenanceWindows as $mw) @php $isActive = now()->greaterThanOrEqualTo($mw->starts_at) && now()->lessThanOrEqualTo($mw->ends_at); @endphp
diff --git a/resources/views/livewire/admin/maintenance.blade.php b/resources/views/livewire/admin/maintenance.blade.php index 12c5604..1b290f2 100644 --- a/resources/views/livewire/admin/maintenance.blade.php +++ b/resources/views/livewire/admin/maintenance.blade.php @@ -51,58 +51,78 @@
{{-- Create form --}} -
-

{{ __('maintenance.new_title') }}

- -
- - -
-
- - -
-
-
- - - @error('startsAt')

{{ $message }}

@enderror -
-
- - - @error('endsAt')

{{ $message }}

@enderror -
+ +
+

{{ __('maintenance.new_title') }}

+

{{ __('maintenance.new_sub') }}

-
-

{{ __('maintenance.field_hosts') }}

- @error('hostIds')

{{ $message }}

@enderror -
- @foreach ($datacenters as $dc) - @php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp - @if ($dcHosts->isNotEmpty()) -
-

{{ $dc->name }}

-
- @foreach ($dcHosts as $host) - - @endforeach -
-
- @endif +
+ + +
+ + +

{{ __('maintenance.field_public_hint') }}

+
+ +
+ + +
+ +
+ @foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key]) +
+ + + @error($field)

{{ $message }}

@enderror +
@endforeach
+ + {{-- Host picker, grouped by datacenter --}} +
+
+ + {{ __('maintenance.selected', ['n' => count($hostIds)]) }} +
+ @error('hostIds')

{{ $message }}

@enderror +
+ @foreach ($datacenters as $dc) + @php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp + @if ($dcHosts->isNotEmpty()) +
+
+

{{ $dc->name }}

+ +
+
+ @foreach ($dcHosts as $host) + + @endforeach +
+
+ @endif + @endforeach +
+
-
- {{ __('maintenance.save_draft') }} - {{ __('maintenance.publish_notify') }} +
+

{{ __('maintenance.mail_note') }}

+ {{ __('maintenance.save_draft') }} + {{ __('maintenance.publish_notify') }}
-

{{ __('maintenance.mail_note') }}

diff --git a/resources/views/livewire/cloud.blade.php b/resources/views/livewire/cloud.blade.php index 32c5aeb..70741b5 100644 --- a/resources/views/livewire/cloud.blade.php +++ b/resources/views/livewire/cloud.blade.php @@ -12,11 +12,31 @@ {{ $instance['domain'] }}
{{ __('cloud.status_'.$instance['status']) }} + @if ($maintenance) + @php $mActive = now()->between($maintenance->starts_at, $maintenance->ends_at); @endphp + + + {{ $mActive ? __('cloud.maintenance_active') : __('cloud.maintenance_planned') }} + + @endif {{ __('cloud.open') }}
+ @if ($maintenance) +
+ +
+

{{ $maintenance->title }}

+

{{ __('cloud.maintenance_window', ['start' => $maintenance->starts_at->isoFormat('LLL'), 'end' => $maintenance->ends_at->isoFormat('LT')]) }}

+ @if ($maintenance->public_description) +

{{ $maintenance->public_description }}

+ @endif +
+
+ @endif +
@foreach ([ ['cloud.plan', $instance['plan']], diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 49fac6d..0ccd533 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -7,6 +7,7 @@

{{ $used }} / {{ $limit }}

{{ __('users.seats_used') }}

+

{{ __('users.seats_note') }}

diff --git a/tests/Feature/OperatorInPortalTest.php b/tests/Feature/OperatorInPortalTest.php new file mode 100644 index 0000000..485de73 --- /dev/null +++ b/tests/Feature/OperatorInPortalTest.php @@ -0,0 +1,63 @@ +operator('Owner')->create(); +} + +it('warns instead of silently ignoring a purchase without a customer', function () { + Livewire::actingAs(operatorUser())->test(Billing::class) + ->call('purchase', 'storage') + ->assertDispatched('notify'); + + expect(Order::query()->count())->toBe(0); +}); + +it('warns instead of silently ignoring a profile save without a customer', function () { + Livewire::actingAs(operatorUser())->test(Settings::class) + ->set('companyName', 'Nope GmbH') + ->call('saveProfile') + ->assertDispatched('notify'); +}); + +it('warns instead of silently ignoring a seat invite without a customer', function () { + Livewire::actingAs(operatorUser())->test(Users::class) + ->set('inviteEmail', 'x@no-customer.test') + ->call('invite') + ->assertDispatched('notify'); + + expect(\App\Models\Seat::query()->count())->toBe(0); +}); + +it('shows an error in the cancel-package modal without a customer', function () { + Livewire::actingAs(operatorUser())->test(ConfirmCancelPackage::class) + ->set('confirmName', 'whatever') + ->call('cancelPackage') + ->assertHasErrors(['confirmName']); +}); + +it('shows an error in the close-account modal without a customer', function () { + Livewire::actingAs(operatorUser())->test(ConfirmCloseAccount::class) + ->set('confirmWord', __('settings.close_keyword')) + ->call('closeAccount') + ->assertHasErrors(['confirmWord']); +}); + +it('renders the portal notice for an operator account', function () { + $this->actingAs(operatorUser())->get(route('dashboard')) + ->assertOk() + ->assertSee(__('dashboard.no_customer_title')); +});