Rollen: aufklappbare Karten, gruppierte Rechte, Knopf im Raster
parent
a64d41639b
commit
fb1ef3fc63
|
|
@ -154,6 +154,13 @@ class Roles extends Component
|
|||
->withCount('users')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
// Nach dem Präfix gruppiert — die Namen tragen ihre Ordnung längst
|
||||
// in sich (`billing.`, `customers.`, `provisioning.`), und
|
||||
// einundzwanzig Kästchen flach untereinander sind eine Wand, in
|
||||
// der niemand das eine findet, das er sucht.
|
||||
'groups' => collect($this->allPermissions())
|
||||
->groupBy(fn (string $name) => str_contains($name, '.') ? strtok($name, '.') : 'sonstiges')
|
||||
->sortKeys(),
|
||||
'permissions' => $this->allPermissions(),
|
||||
// Wie viele Menschen an dieser Rolle hängen — damit niemand blind
|
||||
// etwas wegnimmt, das gerade sechs Leute benutzen.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ return [
|
|||
'in_use' => 'Die Rolle :role wird noch gehalten. Wer sie hat, stünde beim nächsten Anmelden ohne Rolle da und käme nicht mehr in die Konsole.',
|
||||
'holders' => '{0} Niemand hat diese Rolle|{1} :count Person hat diese Rolle|[2,*] :count Personen haben diese Rolle',
|
||||
|
||||
'granted_count' => ':granted von :total Rechten',
|
||||
|
||||
'can' => [
|
||||
'billing.manage' => 'Rechnungen, Gutschriften, Zahlungsprobleme',
|
||||
'console.view' => 'Die Konsole überhaupt betreten',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ return [
|
|||
'in_use' => 'The role :role is still held. Whoever has it would be left with no role on their next sign-in and could not enter the console.',
|
||||
'holders' => '{0} Nobody holds this role|{1} :count person holds this role|[2,*] :count people hold this role',
|
||||
|
||||
'granted_count' => ':granted of :total capabilities',
|
||||
|
||||
'can' => [
|
||||
'billing.manage' => 'Invoices, credit notes, payment problems',
|
||||
'console.view' => 'Enter the console at all',
|
||||
|
|
|
|||
|
|
@ -4,84 +4,113 @@
|
|||
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('roles.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Neue Rolle. Ein Feld, ein Knopf — die Rechte bekommt sie danach in
|
||||
ihrer eigenen Karte, weil eine leere Rolle noch nichts kaputtmachen
|
||||
kann und die Matrix im selben Formular unlesbar würde. --}}
|
||||
<div class="flex flex-wrap items-end gap-3 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="min-w-56 flex-1">
|
||||
<x-ui.input name="newRole" wire:model="newRole"
|
||||
:label="__('roles.new_label')" :hint="__('roles.new_hint')" />
|
||||
</div>
|
||||
<x-ui.button variant="primary" wire:click="create" wire:loading.attr="disabled" wire:target="create">
|
||||
{{ __('roles.create') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
{{-- Neue Rolle.
|
||||
|
||||
@foreach ($roles as $role)
|
||||
@php $isProtected = $role->name === $protectedRole; @endphp
|
||||
<div wire:key="role-{{ $role->id }}"
|
||||
class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="flex flex-wrap items-center gap-2 font-semibold text-ink">
|
||||
{{ $role->name }}
|
||||
Vorher stand das Feld auf `flex-1` neben einem Knopf ohne `shrink-0`,
|
||||
und beide auf `items-end` — bei schmalem Fenster schob der Hinweistext
|
||||
unter dem Feld den Knopf aus der Karte heraus. Jetzt ein Raster mit
|
||||
zwei Spuren: das Feld nimmt, was übrig ist, der Knopf behält seine
|
||||
Breite, und der Hinweis steht UNTER der Reihe statt in ihr. --}}
|
||||
<form wire:submit="create" class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
|
||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-end">
|
||||
<x-ui.input name="newRole" wire:model="newRole" :label="__('roles.new_label')" />
|
||||
<x-ui.button type="submit" variant="primary" class="shrink-0"
|
||||
wire:loading.attr="disabled" wire:target="create">
|
||||
{{ __('roles.create') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted">{{ __('roles.new_hint') }}</p>
|
||||
@error('newRole')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</form>
|
||||
|
||||
{{-- Eine Karte je Rolle, zugeklappt.
|
||||
|
||||
Sechs Rollen mal einundzwanzig Rechte waren über hundert Kästchen
|
||||
untereinander — man scrollte an der Rolle vorbei, die man ändern
|
||||
wollte. Zugeklappt ist die Seite sechs Zeilen lang, und die Zeile sagt
|
||||
schon, was sie enthält: wie viele Rechte, wie viele Menschen. --}}
|
||||
<div class="space-y-3">
|
||||
@foreach ($roles as $role)
|
||||
@php
|
||||
$isProtected = $role->name === $protectedRole;
|
||||
// NICHT $granted nennen: das ist die Eigenschaft des
|
||||
// Bauteils, und in der zweiten Runde der Schleife wäre sie
|
||||
// eine Zahl statt der Liste.
|
||||
$grantedCount = count($granted[$role->id] ?? []);
|
||||
@endphp
|
||||
<div wire:key="role-{{ $role->id }}" x-data="{ open: false }"
|
||||
class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
|
||||
|
||||
{{-- Die Kopfzeile IST der Schalter. Ein eigener Pfeil-Knopf
|
||||
daneben wäre ein zweites Ziel für dieselbe Absicht. --}}
|
||||
<button type="button" x-on:click="open = ! open" :aria-expanded="open"
|
||||
class="flex w-full flex-wrap items-center justify-between gap-3 px-6 py-4 text-left transition-colors hover:bg-surface-hover">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{{-- chevron-down gedreht: der Satz kennt kein chevron-right, und
|
||||
eine zweite Ikone für dieselbe Sache wäre eine zu viel. --}}
|
||||
<x-ui.icon name="chevron-down" class="size-4 shrink-0 text-muted transition-transform"
|
||||
x-bind:class="open ? '' : '-rotate-90'" />
|
||||
<span class="font-semibold text-ink">{{ $role->name }}</span>
|
||||
@if ($isProtected)
|
||||
<span class="rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">
|
||||
{{ __('roles.locked_badge') }}
|
||||
</span>
|
||||
<span class="rounded-pill border border-warning-border bg-warning-bg px-2.5 py-0.5 text-xs font-medium text-warning">{{ __('roles.locked_badge') }}</span>
|
||||
@endif
|
||||
@if (in_array($role->name, $ownRoles, true))
|
||||
<span class="rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">
|
||||
{{ __('roles.your_role') }}
|
||||
</span>
|
||||
<span class="rounded-pill border border-info-border bg-info-bg px-2.5 py-0.5 text-xs font-medium text-info">{{ __('roles.your_role') }}</span>
|
||||
@endif
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ trans_choice('roles.holders', $role->users_count, ['count' => $role->users_count]) }}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@unless ($isProtected)
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-ui.button variant="primary" wire:click="save({{ $role->id }})"
|
||||
wire:loading.attr="disabled" wire:target="save({{ $role->id }})">
|
||||
{{ __('common.save') }}
|
||||
</x-ui.button>
|
||||
@if ($role->users_count === 0)
|
||||
<x-ui.button variant="ghost" wire:click="delete({{ $role->id }})"
|
||||
wire:loading.attr="disabled" wire:target="delete({{ $role->id }})">
|
||||
{{ __('roles.delete') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
<span class="flex shrink-0 items-center gap-3 text-xs text-muted">
|
||||
<span>{{ __('roles.granted_count', ['granted' => $grantedCount, 'total' => count($permissions)]) }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ trans_choice('roles.holders', $role->users_count, ['count' => $role->users_count]) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak class="border-t border-line px-6 py-5">
|
||||
@if ($isProtected)
|
||||
<x-ui.alert variant="warning" class="mb-4">{{ __('roles.owner_locked') }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
@error('granted.'.$role->id)<p class="mb-3 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
|
||||
<div class="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
@foreach ($groups as $prefix => $names)
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $prefix }}</h3>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($names as $permission)
|
||||
<label class="flex items-start gap-2 text-sm text-body">
|
||||
<input type="checkbox" value="{{ $permission }}"
|
||||
wire:model="granted.{{ $role->id }}"
|
||||
@disabled($isProtected)
|
||||
class="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent-active)] disabled:opacity-50">
|
||||
<span class="min-w-0">
|
||||
<span class="block">{{ __('roles.can.'.$permission) }}</span>
|
||||
<span class="block font-mono text-[11px] text-faint">{{ $permission }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
@unless ($isProtected)
|
||||
<div class="mt-6 flex flex-wrap gap-2 border-t border-line pt-5">
|
||||
<x-ui.button variant="primary" wire:click="save({{ $role->id }})"
|
||||
wire:loading.attr="disabled" wire:target="save({{ $role->id }})">
|
||||
{{ __('common.save') }}
|
||||
</x-ui.button>
|
||||
@if ($role->users_count === 0)
|
||||
<x-ui.button variant="ghost" wire:click="delete({{ $role->id }})"
|
||||
wire:loading.attr="disabled" wire:target="delete({{ $role->id }})">
|
||||
{{ __('roles.delete') }}
|
||||
</x-ui.button>
|
||||
@endif
|
||||
</div>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@error('granted.'.$role->id)<p class="text-xs text-danger">{{ $message }}</p>@enderror
|
||||
|
||||
@if ($isProtected)
|
||||
<p class="text-sm text-muted">{{ __('roles.owner_locked') }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Die Matrix. Auch für Owner sichtbar, nur nicht änderbar: was
|
||||
der Owner darf, ist die Antwort auf „was gibt es überhaupt",
|
||||
und die soll man nachlesen können. --}}
|
||||
<div class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($permissions as $permission)
|
||||
<label class="flex items-start gap-2 text-sm text-body">
|
||||
<input type="checkbox" value="{{ $permission }}"
|
||||
wire:model="granted.{{ $role->id }}"
|
||||
@disabled($isProtected)
|
||||
class="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[var(--accent-active)] disabled:opacity-50">
|
||||
<span class="min-w-0">
|
||||
<span class="font-mono text-xs">{{ $permission }}</span>
|
||||
<span class="block text-xs text-muted">{{ __('roles.can.'.$permission) }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@error('newRole')<p class="text-sm text-danger">{{ $message }}</p>@enderror
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -121,3 +121,28 @@ it('is not reachable without staff.manage', function () {
|
|||
->test(Roles::class)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('keeps counting capabilities correctly past the first role', function () {
|
||||
// Ein Fehler, den ich beim Umbau selbst gebaut habe: die Ansicht nannte
|
||||
// die Zählvariable `$granted` — genauso wie die Eigenschaft des Bauteils.
|
||||
// Ab der zweiten Rolle war sie eine Zahl statt der Liste, und die Seite
|
||||
// starb an `count(int)`. Mit einer Rolle wäre es nie aufgefallen.
|
||||
Role::findOrCreate('Buchhaltung', 'operator')->givePermissionTo('console.view');
|
||||
Role::findOrCreate('Werkstatt', 'operator');
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Roles::class)
|
||||
->assertOk()
|
||||
->assertSee('Buchhaltung')
|
||||
->assertSee('Werkstatt');
|
||||
});
|
||||
|
||||
it('groups the capabilities instead of listing twenty-one in a row', function () {
|
||||
$groups = Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(Roles::class)
|
||||
->viewData('groups');
|
||||
|
||||
expect($groups)->toHaveKey('billing')
|
||||
->and($groups)->toHaveKey('provisioning')
|
||||
->and(count($groups))->toBeLessThan(21);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue