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
parent
0b2d762b88
commit
a321d963d7
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -51,44 +51,63 @@
|
|||
</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]">
|
||||
<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>
|
||||
<x-ui.input name="title" wire:model="title" :label="__('maintenance.field_title')" />
|
||||
<p class="mt-0.5 text-xs text-muted">{{ __('maintenance.new_sub') }}</p>
|
||||
</div>
|
||||
|
||||
<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" 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>
|
||||
<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>
|
||||
<p class="text-sm font-medium text-body">{{ __('maintenance.field_hosts') }}</p>
|
||||
<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-48 space-y-3 overflow-y-auto rounded-md border border-line p-3">
|
||||
<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>
|
||||
<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>
|
||||
<div class="mt-1 space-y-1">
|
||||
<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 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" />
|
||||
<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>
|
||||
|
|
@ -97,12 +116,13 @@
|
|||
@endforeach
|
||||
</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>
|
||||
<p class="text-xs text-faint">{{ __('maintenance.mail_note') }}</p>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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']],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
Loading…
Reference in New Issue