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
parent
87a81f5f40
commit
b50f97568f
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">​</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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue