fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown

- isOperator()/EnsureAdmin/broadcast fall back to is_admin so a legacy admin is
  never locked out by a stale permission cache
- published modal: centered max-w-lg card (dynamic modalWidth class was purged
  by Tailwind → full-width)
- datacenter edit moved to a modal (no row-height jump); location is now a
  country dropdown (config/countries.php) instead of free text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 18:09:17 +02:00
parent 87a81f5f40
commit b50f97568f
12 changed files with 206 additions and 120 deletions

View File

@ -13,7 +13,8 @@ class EnsureAdmin
*/
public function handle(Request $request, Closure $next): Response
{
abort_unless((bool) $request->user()?->can('console.view'), 403);
// console.view via RBAC, or the legacy is_admin flag as a safety net.
abort_unless((bool) $request->user()?->isOperator(), 403);
return $next($request);
}

View File

@ -16,54 +16,10 @@ class Datacenters extends Component
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('nullable|string|max:255')]
// Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')]
public string $location = '';
// Inline edit state (code is immutable once created — it is referenced by hosts).
public ?string $editingUuid = null;
public string $editName = '';
public string $editLocation = '';
public function edit(string $uuid): void
{
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $uuid)->first();
if ($dc === null) {
return;
}
$this->editingUuid = $uuid;
$this->editName = $dc->name;
$this->editLocation = (string) $dc->location;
}
public function cancelEdit(): void
{
$this->reset('editingUuid', 'editName', 'editLocation');
}
public function update(): void
{
$this->authorize('datacenters.manage');
if ($this->editingUuid === null) {
return;
}
$data = $this->validate([
'editName' => 'required|string|max:255',
'editLocation' => 'nullable|string|max:255',
]);
Datacenter::query()->where('uuid', $this->editingUuid)->update([
'name' => $data['editName'],
'location' => $data['editLocation'] ?: null,
]);
$this->cancelEdit();
$this->dispatch('notify', message: __('datacenters.updated'));
}
public function save(): void
{
$this->authorize('datacenters.manage');
@ -95,6 +51,7 @@ class Datacenters extends Component
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter's name and location in a modal (avoids the row-height jump
* of inline editing). The code is immutable it is referenced by hosts. Country
* is picked from a list so it can't be mistyped.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('nullable|string|max:2|in:'.self::COUNTRY_KEYS)]
public string $location = '';
private const COUNTRY_KEYS = 'DE,AT,CH,FI,SE,NO,DK,NL,BE,LU,FR,GB,IE,ES,PT,IT,PL,CZ,SK,HU,SI,HR,RO,BG,GR,EE,LV,LT,US,CA';
public function mount(string $uuid): void
{
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->location = (string) $dc->location;
}
public function save()
{
$this->authorize('datacenters.manage');
$data = $this->validate();
Datacenter::query()->where('uuid', $this->uuid)->update([
'name' => $data['name'],
'location' => $data['location'] ?: null,
]);
$this->dispatch('notify', message: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
]);
}
}

View File

@ -22,10 +22,14 @@ class User extends Authenticatable
/** The five operator roles that grant access to the admin console. */
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only'];
/** True when this user is a CluPilot operator (has any admin role). */
/**
* True when this user is a CluPilot operator. Prefers RBAC roles, but keeps
* the legacy is_admin flag as a fallback so an operator can never be locked
* out of the console by a stale permission cache or a missing role.
*/
public function isOperator(): bool
{
return $this->hasAnyRole(self::OPERATOR_ROLES);
return $this->is_admin || $this->hasAnyRole(self::OPERATOR_ROLES);
}
/**

36
config/countries.php Normal file
View File

@ -0,0 +1,36 @@
<?php
// ISO 3166-1 alpha-2 → name, for the datacenter location picker (avoids typos).
// Curated to the regions CluPilot realistically hosts in; extend as needed.
return [
'DE' => 'Deutschland',
'AT' => 'Österreich',
'CH' => 'Schweiz',
'FI' => 'Finnland',
'SE' => 'Schweden',
'NO' => 'Norwegen',
'DK' => 'Dänemark',
'NL' => 'Niederlande',
'BE' => 'Belgien',
'LU' => 'Luxemburg',
'FR' => 'Frankreich',
'GB' => 'Vereinigtes Königreich',
'IE' => 'Irland',
'ES' => 'Spanien',
'PT' => 'Portugal',
'IT' => 'Italien',
'PL' => 'Polen',
'CZ' => 'Tschechien',
'SK' => 'Slowakei',
'HU' => 'Ungarn',
'SI' => 'Slowenien',
'HR' => 'Kroatien',
'RO' => 'Rumänien',
'BG' => 'Bulgarien',
'GR' => 'Griechenland',
'EE' => 'Estland',
'LV' => 'Lettland',
'LT' => 'Litauen',
'US' => 'USA',
'CA' => 'Kanada',
];

View File

@ -18,6 +18,8 @@ return [
'updated' => 'Rechenzentrum aktualisiert.',
'actions' => 'Aktionen',
'edit' => 'Bearbeiten',
'edit_title' => 'Rechenzentrum bearbeiten',
'no_country' => 'Kein Land',
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'delete' => 'Löschen',

View File

@ -18,6 +18,8 @@ return [
'updated' => 'Datacenter updated.',
'actions' => 'Actions',
'edit' => 'Edit',
'edit_title' => 'Edit datacenter',
'no_country' => 'No country',
'save' => 'Save',
'cancel' => 'Cancel',
'delete' => 'Delete',

View File

@ -24,49 +24,32 @@
@foreach ($datacenters as $dc)
<tr wire:key="dc-{{ $dc->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td>
@if ($editingUuid === $dc->uuid)
<td class="px-4 py-2" colspan="3">
<div class="flex flex-wrap items-center gap-2">
<input type="text" wire:model="editName" aria-label="{{ __('datacenters.name') }}"
class="min-w-32 flex-1 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-ink" />
<input type="text" wire:model="editLocation" aria-label="{{ __('datacenters.location') }}" placeholder="{{ __('datacenters.location') }}"
class="w-24 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-ink" />
</div>
@error('editName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</td>
<td class="px-4 py-2 text-right">
<div class="inline-flex gap-1.5">
<button type="button" wire:click="update" class="rounded-md bg-accent-active px-2.5 py-1.5 text-xs font-semibold text-on-accent hover:bg-accent-press">{{ __('datacenters.save') }}</button>
<button type="button" wire:click="cancelEdit" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('datacenters.cancel') }}</button>
</div>
</td>
@else
<td class="px-4 py-3 text-body">{{ $dc->name }}
@if ($dc->location)<span class="text-xs text-faint">· {{ $dc->location }}</span>@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
<td class="px-4 py-3">
<button type="button" wire:click="toggle('{{ $dc->uuid }}')"
class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
{{ $dc->active ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $dc->active ? __('datacenters.active') : __('datacenters.inactive') }}
<td class="px-4 py-3 text-body">{{ $dc->name }}
@if ($dc->location)<span class="text-xs text-faint">· {{ $countries[$dc->location] ?? $dc->location }}</span>@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
<td class="px-4 py-3">
<button type="button" wire:click="toggle('{{ $dc->uuid }}')"
class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
{{ $dc->active ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ $dc->active ? __('datacenters.active') : __('datacenters.inactive') }}
</button>
</td>
<td class="px-4 py-3 text-right">
<div class="inline-flex gap-1">
<button type="button" aria-label="{{ __('datacenters.edit') }}"
x-on:click="$dispatch('openModal', { component: 'admin.edit-datacenter', arguments: { uuid: '{{ $dc->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="pen" class="size-4" />
</button>
</td>
<td class="px-4 py-3 text-right">
<div class="inline-flex gap-1">
<button type="button" wire:click="edit('{{ $dc->uuid }}')" aria-label="{{ __('datacenters.edit') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="pen" class="size-4" />
</button>
<button type="button" aria-label="{{ __('datacenters.delete') }}"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-datacenter', arguments: { uuid: '{{ $dc->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />
</button>
</div>
</td>
@endif
<button type="button" aria-label="{{ __('datacenters.delete') }}"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-datacenter', arguments: { uuid: '{{ $dc->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
@ -79,7 +62,15 @@
<h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2>
<x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" />
<x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" />
<x-ui.input name="location" wire:model="location" :label="__('datacenters.location')" :hint="__('datacenters.location_hint')" />
<div>
<label class="text-sm font-medium text-body" for="dc-add-location">{{ __('datacenters.location') }}</label>
<select id="dc-add-location" wire:model="location" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('datacenters.no_country') }}</option>
@foreach ($countries as $cc => $cname)
<option value="{{ $cc }}">{{ $cname }} ({{ $cc }})</option>
@endforeach
</select>
</div>
<x-ui.button variant="primary" type="submit" class="w-full">
<x-ui.icon name="plus" class="size-4" />{{ __('datacenters.add') }}
</x-ui.button>

View File

@ -0,0 +1,29 @@
<div class="rounded-xl bg-surface p-6">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold text-ink">{{ __('datacenters.edit_title') }}</h3>
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $code }}</span>
</div>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="dc-name">{{ __('datacenters.name') }}</label>
<input id="dc-name" type="text" wire:model="name" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('name')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="dc-location">{{ __('datacenters.location') }}</label>
<select id="dc-location" wire:model="location" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('datacenters.no_country') }}</option>
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }} ({{ $code }})</option>
@endforeach
</select>
@error('location')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('datacenters.save') }}</x-ui.button>
</div>
</div>

View File

@ -14,33 +14,32 @@
class="fixed inset-0 z-[70] overflow-y-auto"
style="display: none;"
>
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">
<div
x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-all transform"
>
<div class="absolute inset-0 bg-black/60"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
{{-- Backdrop --}}
<div
x-show="show"
x-on:click="closeModalOnClickAway()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 bg-black/60 transition-opacity"
></div>
{{-- Centered panel with a fixed, contextual max width (max-w-lg is
compiled statically; the package's dynamic modalWidth class would be
purged by Tailwind and collapse the modal to full width). --}}
<div class="flex min-h-full items-center justify-center p-4 sm:p-6">
<div
x-show="show && showActiveComponent"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-bind:class="modalWidth"
class="relative inline-block w-full align-bottom bg-surface rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:w-full"
x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="relative w-full max-w-lg transform rounded-xl bg-surface text-left shadow-xl transition-all"
id="modal-container"
x-trap.noscroll.inert="show && showActiveComponent"
aria-modal="true"

View File

@ -7,7 +7,7 @@ Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
});
// Operator console live provisioning feed — operators only.
Broadcast::channel('admin.runs', fn ($user) => (bool) $user->can('console.view'));
Broadcast::channel('admin.runs', fn ($user) => $user->isOperator());
// A customer's own provisioning feed — bridged from the auth user by email.
Broadcast::channel('customer.{customerId}.run', function ($user, $customerId) {

View File

@ -38,21 +38,28 @@ it('rejects a duplicate datacenter code', function () {
->assertHasErrors(['code']);
});
it('edits a datacenter name and location', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Old', 'location' => 'XX']);
it('edits a datacenter name and country via the modal', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Old', 'location' => 'AT']);
Livewire::actingAs(admin())
->test(Datacenters::class)
->call('edit', $dc->uuid)
->set('editName', 'Nürnberg')
->set('editLocation', 'DE')
->call('update')
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('name', 'Nürnberg')
->set('location', 'DE')
->call('save')
->assertHasNoErrors();
$dc->refresh();
expect($dc->name)->toBe('Nürnberg')->and($dc->location)->toBe('DE')->and($dc->code)->toBe('nbg');
});
it('rejects an invalid country code on edit', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg', 'location' => 'DE']);
Livewire::actingAs(admin())
->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid])
->set('location', 'ZZ')->call('save')->assertHasErrors(['location']);
});
it('deletes an unused datacenter', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg']);