fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign

- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
  now SAY why nothing happened (operator accounts have no Customer) instead of
  returning silently — the actual cause of 'dead buttons', '0/0 seats' and
  'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
  grouped host picker with per-datacenter select-all + selection counter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 19:10:44 +02:00
parent 0b2d762b88
commit a321d963d7
21 changed files with 282 additions and 114 deletions

View File

@ -33,6 +33,18 @@ class Maintenance extends Component
/** @var array<int> */
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');

View File

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

View File

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

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Customer;
/**
* Resolves the customer behind the signed-in portal user.
*
* Operator accounts (admins) have no Customer, so customer-scoped actions must
* NOT silently no-op that reads as a dead button. requireCustomer() surfaces
* an explicit message instead and returns null so the caller can bail.
*/
trait ResolvesCustomer
{
protected 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();
}
/** 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;
}
}

View File

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

View File

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

View File

@ -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()
{

View File

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

View File

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

View File

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

View File

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

View File

@ -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)',

View File

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

View File

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

View File

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

View File

@ -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)',

View File

@ -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. --}}
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warning-border bg-warning-bg px-4 py-2 text-center text-sm text-ink">
<x-ui.icon name="alert-triangle" class="size-4 shrink-0 text-warning" />
<span class="font-medium">{{ __('dashboard.no_customer_title') }}</span>
<span class="text-muted">{{ __('dashboard.no_customer_hint') }}</span>
@if (auth()->user()->can('console.view'))
<a href="{{ route('admin.customers') }}" class="font-semibold text-accent-text hover:underline">{{ __('dashboard.no_customer_cta') }}</a>
@endif
</div>
@endif
@foreach ($maintenanceWindows as $mw)
@php $isActive = now()->greaterThanOrEqualTo($mw->starts_at) && now()->lessThanOrEqualTo($mw->ends_at); @endphp
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b {{ $isActive ? 'border-warning-border bg-warning-bg' : 'border-info-border bg-info-bg' }} px-4 py-2 text-center text-sm text-ink">

View File

@ -51,58 +51,78 @@
</div>
{{-- Create form --}}
<form wire:submit="publish" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2>
<x-ui.input name="title" wire:model="title" :label="__('maintenance.field_title')" />
<div>
<label class="text-sm font-medium text-body" for="publicDescription">{{ __('maintenance.field_public') }}</label>
<textarea id="publicDescription" wire:model="publicDescription" rows="2" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
</div>
<div>
<label class="text-sm font-medium text-body" for="internalNotes">{{ __('maintenance.field_internal') }}</label>
<textarea id="internalNotes" wire:model="internalNotes" rows="2" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body" for="startsAt">{{ __('maintenance.field_start') }}</label>
<input id="startsAt" type="datetime-local" wire:model="startsAt" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('startsAt')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="endsAt">{{ __('maintenance.field_end') }}</label>
<input id="endsAt" type="datetime-local" wire:model="endsAt" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('endsAt')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<form wire:submit="publish" class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="border-b border-line bg-surface-2 px-5 py-3.5">
<h2 class="font-semibold text-ink">{{ __('maintenance.new_title') }}</h2>
<p class="mt-0.5 text-xs text-muted">{{ __('maintenance.new_sub') }}</p>
</div>
<div>
<p class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</p>
@error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-1.5 max-h-48 space-y-3 overflow-y-auto rounded-md border border-line p-3">
@foreach ($datacenters as $dc)
@php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp
@if ($dcHosts->isNotEmpty())
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $dc->name }}</p>
<div class="mt-1 space-y-1">
@foreach ($dcHosts as $host)
<label class="flex items-center gap-2 text-sm text-body">
<input type="checkbox" wire:model="hostIds" value="{{ $host->id }}" class="rounded border-line-strong text-accent-active" />
<span class="font-mono">{{ $host->name }}</span>
</label>
@endforeach
</div>
</div>
@endif
<div class="space-y-5 p-5">
<x-ui.input name="title" wire:model="title" :label="__('maintenance.field_title')" placeholder="Netzwerk-Wartung Falkenstein" />
<div>
<label class="text-sm font-medium text-body" for="publicDescription">{{ __('maintenance.field_public') }}</label>
<textarea id="publicDescription" wire:model="publicDescription" rows="2" placeholder="{{ __('maintenance.field_public_ph') }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
<p class="mt-1 text-xs text-faint">{{ __('maintenance.field_public_hint') }}</p>
</div>
<div>
<label class="text-sm font-medium text-body" for="internalNotes">{{ __('maintenance.field_internal') }}</label>
<textarea id="internalNotes" wire:model="internalNotes" rows="2" placeholder="{{ __('maintenance.field_internal_ph') }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"></textarea>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
@foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key])
<div>
<label class="text-sm font-medium text-body" for="{{ $field }}">{{ __('maintenance.'.$key) }}</label>
<input id="{{ $field }}" type="datetime-local" wire:model="{{ $field }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 [color-scheme:dark]" />
@error($field)<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
@endforeach
</div>
{{-- Host picker, grouped by datacenter --}}
<div>
<div class="flex items-baseline justify-between">
<label class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</label>
<span class="font-mono text-xs {{ count($hostIds) > 0 ? 'text-accent-text' : 'text-faint' }}">{{ __('maintenance.selected', ['n' => count($hostIds)]) }}</span>
</div>
@error('hostIds')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
<div class="mt-1.5 max-h-56 divide-y divide-line overflow-y-auto rounded-md border border-line-strong">
@foreach ($datacenters as $dc)
@php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp
@if ($dcHosts->isNotEmpty())
<div class="p-3">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $dc->name }}</p>
<button type="button" wire:click="selectDatacenter('{{ $dc->code }}')"
class="text-xs font-semibold text-accent-text hover:underline">{{ __('maintenance.select_all') }}</button>
</div>
<div class="mt-2 space-y-1">
@foreach ($dcHosts as $host)
<label class="flex cursor-pointer items-center gap-2.5 rounded px-1.5 py-1 text-sm text-body hover:bg-surface-hover">
<input type="checkbox" wire:model.live="hostIds" value="{{ $host->id }}"
class="size-4 rounded border-line-strong bg-surface text-accent-active focus:ring-2 focus:ring-accent/30" />
<span class="font-mono">{{ $host->name }}</span>
<span class="ml-auto font-mono text-xs text-faint">{{ $host->public_ip }}</span>
</label>
@endforeach
</div>
</div>
@endif
@endforeach
</div>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2">
<x-ui.button variant="secondary" type="button" wire:click="saveDraft" wire:loading.attr="disabled" wire:target="saveDraft">{{ __('maintenance.save_draft') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button>
<div class="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3.5">
<p class="mr-auto max-w-[14rem] text-xs text-faint">{{ __('maintenance.mail_note') }}</p>
<x-ui.button variant="secondary" type="button" size="sm" wire:click="saveDraft" wire:loading.attr="disabled" wire:target="saveDraft">{{ __('maintenance.save_draft') }}</x-ui.button>
<x-ui.button variant="primary" type="submit" size="sm" wire:loading.attr="disabled" wire:target="publish">{{ __('maintenance.publish_notify') }}</x-ui.button>
</div>
<p class="text-xs text-faint">{{ __('maintenance.mail_note') }}</p>
</form>
</div>
</div>

View File

@ -12,11 +12,31 @@
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $instance['domain'] }}</a>
</div>
<x-ui.badge :status="$instance['status']" class="sm:ml-2">{{ __('cloud.status_'.$instance['status']) }}</x-ui.badge>
@if ($maintenance)
@php $mActive = now()->between($maintenance->starts_at, $maintenance->ends_at); @endphp
<span class="inline-flex items-center gap-1.5 rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-semibold text-warning">
<x-ui.icon name="alert-triangle" class="size-3.5" />
{{ $mActive ? __('cloud.maintenance_active') : __('cloud.maintenance_planned') }}
</span>
@endif
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer"
class="ml-auto inline-flex items-center gap-2 rounded-pill bg-accent-active px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-press">
<x-ui.icon name="external-link" class="size-4" />{{ __('cloud.open') }}
</a>
</div>
@if ($maintenance)
<div class="mt-4 flex items-start gap-2.5 rounded-lg border border-warning-border bg-warning-bg p-3">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-4 shrink-0 text-warning" />
<div class="min-w-0 text-sm">
<p class="font-semibold text-ink">{{ $maintenance->title }}</p>
<p class="text-body">{{ __('cloud.maintenance_window', ['start' => $maintenance->starts_at->isoFormat('LLL'), 'end' => $maintenance->ends_at->isoFormat('LT')]) }}</p>
@if ($maintenance->public_description)
<p class="mt-0.5 text-muted">{{ $maintenance->public_description }}</p>
@endif
</div>
</div>
@endif
<dl class="mt-4 grid grid-cols-1 gap-4 border-t border-line pt-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([
['cloud.plan', $instance['plan']],

View File

@ -7,6 +7,7 @@
<div class="text-right">
<p class="font-mono text-lg font-semibold text-ink">{{ $used }} / {{ $limit }}</p>
<p class="text-xs text-muted">{{ __('users.seats_used') }}</p>
<p class="text-xs text-faint">{{ __('users.seats_note') }}</p>
</div>
</div>

View File

@ -0,0 +1,63 @@
<?php
use App\Livewire\Billing;
use App\Livewire\ConfirmCancelPackage;
use App\Livewire\ConfirmCloseAccount;
use App\Livewire\Settings;
use App\Livewire\Users;
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
/**
* An operator account has no Customer. Portal actions must SAY so rather than
* silently doing nothing (which reads as a dead button).
*/
function operatorUser(): User
{
return User::factory()->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'));
});