From 93e848d3f9a8d21070464ee25d0a2db8f0ebeecf Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 21:46:22 +0200 Subject: [PATCH] Rollen und Rechte in der Konsole zuweisbar --- app/Livewire/Admin/Roles.php | 164 ++++++++++++++++++ app/Livewire/Admin/Settings.php | 13 +- app/Livewire/Admin/Vpn.php | 108 ++++++------ app/Models/Operator.php | 24 ++- app/Support/Navigation.php | 1 + lang/de/roles.php | 41 +++++ lang/en/roles.php | 41 +++++ .../views/livewire/admin/roles.blade.php | 87 ++++++++++ routes/admin.php | 4 + tests/Feature/Admin/RoleManagementTest.php | 123 +++++++++++++ 10 files changed, 545 insertions(+), 61 deletions(-) create mode 100644 app/Livewire/Admin/Roles.php create mode 100644 lang/de/roles.php create mode 100644 lang/en/roles.php create mode 100644 resources/views/livewire/admin/roles.blade.php create mode 100644 tests/Feature/Admin/RoleManagementTest.php diff --git a/app/Livewire/Admin/Roles.php b/app/Livewire/Admin/Roles.php new file mode 100644 index 0000000..ad24f95 --- /dev/null +++ b/app/Livewire/Admin/Roles.php @@ -0,0 +1,164 @@ +> Rollen-ID => angehakte Berechtigungen */ + public array $granted = []; + + public string $newRole = ''; + + public function mount(): void + { + abort_unless(Gate::allows('staff.manage'), 403); + + $this->loadGrants(); + } + + public function save(int $roleId): void + { + $this->authorize('staff.manage'); + + $role = $this->role($roleId); + + if ($role->name === 'Owner') { + $this->addError('granted.'.$roleId, __('roles.owner_locked')); + + return; + } + + // Nur bekannte Namen: die Liste kommt aus dem Browser. + $wanted = array_values(array_intersect( + $this->granted[$roleId] ?? [], + $this->allPermissions(), + )); + + $role->syncPermissions($wanted); + + // Spatie hält die Rechte in einem Zwischenspeicher. Ohne das hier + // gölte die Änderung erst, wenn der Zwischenspeicher von selbst + // abläuft — und die Seite zeigte währenddessen das Neue an, während + // das Alte wirkt. + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $this->loadGrants(); + $this->dispatch('notify', message: __('roles.saved', ['role' => $role->name])); + } + + public function create(): void + { + $this->authorize('staff.manage'); + + $this->validate(['newRole' => ['required', 'string', 'max:64', 'regex:/^[\pL][\pL\pN \-]*$/u']]); + + Role::findOrCreate(trim($this->newRole), self::GUARD); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $this->newRole = ''; + $this->loadGrants(); + $this->dispatch('notify', message: __('roles.created')); + } + + public function delete(int $roleId): void + { + $this->authorize('staff.manage'); + + $role = $this->role($roleId); + + if ($role->name === 'Owner') { + $this->addError('newRole', __('roles.owner_locked')); + + return; + } + + // Eine Rolle zu löschen, die jemand hält, nähme diesem Menschen + // wortlos jeden Zugang — er stünde beim nächsten Anmelden ohne Rolle + // da und käme in die Konsole nicht mehr hinein. + if ($role->users()->exists()) { + $this->addError('newRole', __('roles.in_use', ['role' => $role->name])); + + return; + } + + $role->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $this->loadGrants(); + $this->dispatch('notify', message: __('roles.deleted')); + } + + private function role(int $roleId): Role + { + return Role::query() + ->where('guard_name', self::GUARD) + ->findOr($roleId, fn () => abort(404)); + } + + /** @return array */ + private function allPermissions(): array + { + return Permission::query() + ->where('guard_name', self::GUARD) + ->orderBy('name') + ->pluck('name') + ->all(); + } + + private function loadGrants(): void + { + $this->granted = Role::query() + ->where('guard_name', self::GUARD) + ->with('permissions') + ->get() + ->mapWithKeys(fn (Role $role) => [$role->id => $role->permissions->pluck('name')->all()]) + ->all(); + } + + public function render() + { + return view('livewire.admin.roles', [ + 'roles' => Role::query() + ->where('guard_name', self::GUARD) + ->withCount('users') + ->orderBy('name') + ->get(), + 'permissions' => $this->allPermissions(), + // Wie viele Menschen an dieser Rolle hängen — damit niemand blind + // etwas wegnimmt, das gerade sechs Leute benutzen. + 'protectedRole' => 'Owner', + 'ownRoles' => Operator::find(auth('operator')->id())?->roles->pluck('name')->all() ?? [], + ]); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index ad97162..31c6cf7 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Attributes\Validate; @@ -108,7 +109,6 @@ class Settings extends Component #[Validate('required|email|max:255')] public string $staffEmail = ''; - #[Validate('required|in:Owner,Admin,Support,Billing,Read-only')] public string $staffRole = 'Support'; /** Shown once after inviting, since email delivery is still mocked. */ @@ -221,7 +221,10 @@ class Settings extends Component $data = $this->validate([ 'staffName' => 'required|string|max:255', 'staffEmail' => 'required|email|max:255|unique:operators,email', - 'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only', + // Aus der Datenbank, nicht aus einer Zeile hier: sonst bietet + // das Formular eine gerade angelegte Rolle nicht an — und + // `Developer` war genau so jahrelang unsichtbar. + 'staffRole' => ['required', Rule::in(Operator::operatorRoles())], ]); // Only an Owner may create another Owner. @@ -258,7 +261,7 @@ class Settings extends Component public function setStaffRole(int $id, string $role): void { $this->authorize('staff.manage'); - if (! in_array($role, Operator::OPERATOR_ROLES, true)) { + if (! in_array($role, Operator::operatorRoles(), true)) { return; } @@ -598,7 +601,7 @@ class Settings extends Component $operator = $this->currentOperator(); $staff = Operator::query() - ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) + ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles())) ->with('roles') ->orderBy('name') ->get() @@ -627,7 +630,7 @@ class Settings extends Component 'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []), 'viewerIp' => (string) request()->ip(), 'staff' => $staff, - 'roles' => Operator::OPERATOR_ROLES, + 'roles' => Operator::operatorRoles(), 'canManageStaff' => $operator?->can('staff.manage') ?? false, 'isOwner' => $operator?->hasRole('Owner') ?? false, // Only the tabs this operator can actually open. A tab that raises diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index 7484421..dcf87c2 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -14,8 +14,8 @@ use App\Services\Wireguard\QrCode; use App\Services\Wireguard\WireguardHub; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; @@ -112,61 +112,61 @@ class Vpn extends Component // shared lock is what keeps a host and an access off the same tunnel IP. try { // Filled inside the transaction; only the handoff needs it afterwards. - $plainConfig = null; + $plainConfig = null; - $peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) { - return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) { - // The owner row is locked for the whole insert, and revokeStaff() - // locks the same row: without that, a revocation could commit - // between the check and the insert, find no peer to remove, and - // leave the revoked colleague with a brand-new tunnel. - $owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first(); - if ($owner === null || ! $owner->isOperator()) { - return 'owner_must_be_operator'; - } - - // Inside the lock: checking before it would let two concurrent - // requests both pass and the loser hit the unique index as a - // 500. withTrashed, because a revoked peer keeps its key until - // the hub confirms removal. - $existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first(); - if ($existing !== null) { - return $existing->trashed() ? 'pending_removal' : 'duplicate_key'; - } - - // A host's key may not have been adopted into vpn_peers yet. - // Re-using it would make `wg set` rewrite that host's allowed-ip - // to the address allocated here — cutting the management tunnel - // to a live machine. - if (Host::query()->where('wg_pubkey', $publicKey)->exists()) { - return 'host_key'; - } - - $ip = $hub->allocateIp(); - - // Built and encrypted here so the stored config is part of the - // same insert. Doing it afterwards could leave a live access - // whose config was never stored — unrecoverable, and the - // operator would create a second one not knowing why. - $secret = null; - if ($keypair !== null) { - $plainConfig = $this->clientConfig($keypair, $ip); - if ($this->storeConfig) { - $secret = ConfigVault::encrypt($plainConfig); + $peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) { + return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) { + // The owner row is locked for the whole insert, and revokeStaff() + // locks the same row: without that, a revocation could commit + // between the check and the insert, find no peer to remove, and + // leave the revoked colleague with a brand-new tunnel. + $owner = Operator::query()->whereKey($this->ownerId)->lockForUpdate()->first(); + if ($owner === null || ! $owner->isOperator()) { + return 'owner_must_be_operator'; } - } - return VpnPeer::create([ - 'name' => trim($this->name), - 'kind' => VpnPeer::KIND_STAFF, - 'user_id' => $this->ownerId, - 'public_key' => $publicKey, - 'allowed_ip' => $ip, - 'config_secret' => $secret, - 'enabled' => true, - 'present' => false, - 'created_by' => Auth::guard('operator')->id(), - ]); + // Inside the lock: checking before it would let two concurrent + // requests both pass and the loser hit the unique index as a + // 500. withTrashed, because a revoked peer keeps its key until + // the hub confirms removal. + $existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first(); + if ($existing !== null) { + return $existing->trashed() ? 'pending_removal' : 'duplicate_key'; + } + + // A host's key may not have been adopted into vpn_peers yet. + // Re-using it would make `wg set` rewrite that host's allowed-ip + // to the address allocated here — cutting the management tunnel + // to a live machine. + if (Host::query()->where('wg_pubkey', $publicKey)->exists()) { + return 'host_key'; + } + + $ip = $hub->allocateIp(); + + // Built and encrypted here so the stored config is part of the + // same insert. Doing it afterwards could leave a live access + // whose config was never stored — unrecoverable, and the + // operator would create a second one not knowing why. + $secret = null; + if ($keypair !== null) { + $plainConfig = $this->clientConfig($keypair, $ip); + if ($this->storeConfig) { + $secret = ConfigVault::encrypt($plainConfig); + } + } + + return VpnPeer::create([ + 'name' => trim($this->name), + 'kind' => VpnPeer::KIND_STAFF, + 'user_id' => $this->ownerId, + 'public_key' => $publicKey, + 'allowed_ip' => $ip, + 'config_secret' => $secret, + 'enabled' => true, + 'present' => false, + 'created_by' => Auth::guard('operator')->id(), + ]); }); }); } catch (QueryException) { @@ -390,7 +390,7 @@ class Vpn extends Component 'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null, 'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(), 'operators' => Operator::query() - ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::OPERATOR_ROLES)) + ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles())) ->orderBy('name')->get(['id', 'name', 'email']), 'canManage' => Auth::guard('operator')->user()?->can('vpn.manage.all') ?? false, 'vaultAvailable' => ConfigVault::available(), diff --git a/app/Models/Operator.php b/app/Models/Operator.php index d21bed7..34a9563 100644 --- a/app/Models/Operator.php +++ b/app/Models/Operator.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; +use Spatie\Permission\Models\Role; use Spatie\Permission\Traits\HasRoles; /** @@ -27,7 +28,26 @@ class Operator extends Authenticatable protected string $guard_name = 'operator'; /** The six roles that grant access to the console. */ - public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer']; + /** + * Die Rollen dieser Installation — aus der Datenbank, nicht aus einer + * Liste im Code. + * + * Vorher stand sie hier als Konstante UND zweimal als + * `in:Owner,Admin,…`-Regel in Admin\Settings. `Developer` war in der + * Konstanten und in keiner der beiden Regeln: die Rolle existierte, und + * das Einladen-Formular bot sie nie an. Seit Admin\Roles Rollen anlegen + * kann, wäre jede feste Liste ohnehin schon beim ersten Anlegen falsch. + * + * @return array + */ + public static function operatorRoles(): array + { + return Role::query() + ->where('guard_name', 'operator') + ->orderBy('name') + ->pluck('name') + ->all(); + } protected $fillable = ['name', 'email', 'password', 'last_login_at', 'disabled_at']; @@ -46,7 +66,7 @@ class Operator extends Authenticatable /** True when this operator holds a role that reaches the console. */ public function isOperator(): bool { - return $this->hasAnyRole(self::OPERATOR_ROLES); + return $this->hasAnyRole(self::operatorRoles()); } /** A disabled operator keeps their record and loses their access. */ diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 39de0f3..cb59306 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -113,6 +113,7 @@ final class Navigation // RequireOperatorTwoFactor sends somebody who may not open // anything else yet, Settings included. ['admin.settings', 'settings', 'settings', null], + ['admin.roles', 'users', 'roles', 'staff.manage'], ]], ]; } diff --git a/lang/de/roles.php b/lang/de/roles.php new file mode 100644 index 0000000..78e33c5 --- /dev/null +++ b/lang/de/roles.php @@ -0,0 +1,41 @@ + 'Rollen und Rechte', + 'subtitle' => 'Was eine Rolle darf, steht hier — nicht in einer Migration. Wer die Rolle hält, bekommt es beim nächsten Aufruf.', + 'new_label' => 'Neue Rolle', + 'new_hint' => 'Zum Beispiel Buchhaltung. Rechte bekommt sie danach in ihrer eigenen Karte.', + 'create' => 'Rolle anlegen', + 'created' => 'Rolle angelegt.', + 'delete' => 'Löschen', + 'deleted' => 'Rolle gelöscht.', + 'saved' => 'Rechte für :role gespeichert.', + 'locked_badge' => 'gesperrt', + 'your_role' => 'Ihre Rolle', + 'owner_locked' => 'Owner lässt sich hier nicht ändern. Ein Klick, der dem Owner Rechte nimmt, sperrt Sie aus Ihrer eigenen Installation aus — und niemand könnte es zurücknehmen.', + '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', + + 'can' => [ + 'billing.manage' => 'Rechnungen, Gutschriften, Zahlungsprobleme', + 'console.view' => 'Die Konsole überhaupt betreten', + 'customers.grant_plan' => 'Ein Paket ohne Bezahlung zuweisen', + 'customers.impersonate' => 'Sich als Kunde anmelden', + 'customers.manage' => 'Kundendaten sehen und ändern', + 'datacenters.manage' => 'Rechenzentren verwalten', + 'dpa.manage' => 'Auftragsverarbeitungsverträge', + 'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen', + 'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen', + 'instances.restart' => 'Eine Kunden-Cloud neu starten', + 'mail.manage' => 'Postfächer, Vorlagen, Posteingang', + 'maintenance.manage' => 'Wartungsfenster und Störungen', + 'plans.manage' => 'Pakete und Preise', + 'provisioning.cancel' => 'Eine laufende Bereitstellung abbrechen', + 'provisioning.retry' => 'Eine Bereitstellung wiederholen', + 'secrets.manage' => 'Zugangsdaten und die Serverdatei', + 'site.manage' => 'Firmendaten, Rechnungsserien, Hostnamen', + 'staff.manage' => 'Mitarbeiter einladen UND Rollen bestimmen', + 'vpn.manage.all' => 'VPN-Zugänge aller Mitarbeiter', + 'vpn.view.all' => 'VPN-Zugänge aller Mitarbeiter sehen', + ], +]; diff --git a/lang/en/roles.php b/lang/en/roles.php new file mode 100644 index 0000000..9a01b1e --- /dev/null +++ b/lang/en/roles.php @@ -0,0 +1,41 @@ + 'Roles and capabilities', + 'subtitle' => 'What a role may do lives here, not in a migration. Whoever holds the role gets it on their next request.', + 'new_label' => 'New role', + 'new_hint' => 'For example Accounting. Capabilities follow in its own card.', + 'create' => 'Create role', + 'created' => 'Role created.', + 'delete' => 'Delete', + 'deleted' => 'Role deleted.', + 'saved' => 'Capabilities for :role saved.', + 'locked_badge' => 'locked', + 'your_role' => 'Your role', + 'owner_locked' => 'Owner cannot be changed here. A click that takes capabilities from Owner locks you out of your own installation, and nobody could undo it.', + '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', + + 'can' => [ + 'billing.manage' => 'Invoices, credit notes, payment problems', + 'console.view' => 'Enter the console at all', + 'customers.grant_plan' => 'Grant a plan without payment', + 'customers.impersonate' => 'Sign in as a customer', + 'customers.manage' => 'See and change customer data', + 'datacenters.manage' => 'Manage datacenters', + 'dpa.manage' => 'Data processing agreements', + 'hosts.manage' => 'Create, onboard and remove hosts', + 'instances.adminlogin' => 'Sign in to a customer cloud', + 'instances.restart' => 'Restart a customer cloud', + 'mail.manage' => 'Mailboxes, templates, inbox', + 'maintenance.manage' => 'Maintenance windows and incidents', + 'plans.manage' => 'Plans and prices', + 'provisioning.cancel' => 'Cancel a running provisioning run', + 'provisioning.retry' => 'Retry a provisioning run', + 'secrets.manage' => 'Credentials and the server file', + 'site.manage' => 'Company details, invoice series, hostnames', + 'staff.manage' => 'Invite staff AND define roles', + 'vpn.manage.all' => 'Everyone\'s VPN access', + 'vpn.view.all' => 'See everyone\'s VPN access', + ], +]; diff --git a/resources/views/livewire/admin/roles.blade.php b/resources/views/livewire/admin/roles.blade.php new file mode 100644 index 0000000..4bcff90 --- /dev/null +++ b/resources/views/livewire/admin/roles.blade.php @@ -0,0 +1,87 @@ +
+
+

{{ __('roles.title') }}

+

{{ __('roles.subtitle') }}

+
+ + {{-- 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. --}} +
+
+ +
+ + {{ __('roles.create') }} + +
+ + @foreach ($roles as $role) + @php $isProtected = $role->name === $protectedRole; @endphp +
+
+
+

+ {{ $role->name }} + @if ($isProtected) + + {{ __('roles.locked_badge') }} + + @endif + @if (in_array($role->name, $ownRoles, true)) + + {{ __('roles.your_role') }} + + @endif +

+

+ {{ trans_choice('roles.holders', $role->users_count, ['count' => $role->users_count]) }} +

+
+ + @unless ($isProtected) +
+ + {{ __('common.save') }} + + @if ($role->users_count === 0) + + {{ __('roles.delete') }} + + @endif +
+ @endunless +
+ + @error('granted.'.$role->id)

{{ $message }}

@enderror + + @if ($isProtected) +

{{ __('roles.owner_locked') }}

+ @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. --}} +
+ @foreach ($permissions as $permission) + + @endforeach +
+
+ @endforeach + + @error('newRole')

{{ $message }}

@enderror +
diff --git a/routes/admin.php b/routes/admin.php index 0ac9cf2..37772ca 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -131,6 +131,10 @@ Route::get('/infrastructure', fn () => redirect()->route('admin.integrations', s // one list of what is still missing. Route::get('/readiness', Admin\Readiness::class)->name('readiness'); Route::get('/settings', Admin\Settings::class)->name('settings'); +// Rollen und ihre Rechte. Eigene Seite statt eines Abschnitts unter +// Einstellungen: eine Matrix aus sechs Rollen und einundzwanzig Rechten ist +// keine Karte mehr, und wer sie öffnet, tut genau eine Sache. +Route::get('/roles', Admin\Roles::class)->name('roles'); // Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without // exempting the rest of admin.settings — see that middleware for why. Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup'); diff --git a/tests/Feature/Admin/RoleManagementTest.php b/tests/Feature/Admin/RoleManagementTest.php new file mode 100644 index 0000000..35914ab --- /dev/null +++ b/tests/Feature/Admin/RoleManagementTest.php @@ -0,0 +1,123 @@ +test(Roles::class) + ->assertSee('Buchhaltung') + ->assertSee('Developer'); +}); + +it('lets the owner give a role a capability', function () { + $role = Role::findOrCreate('Buchhaltung', 'operator'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->set("granted.{$role->id}", ['console.view', 'billing.manage']) + ->call('save', $role->id) + ->assertHasNoErrors(); + + expect($role->fresh()->permissions->pluck('name')->sort()->values()->all()) + ->toBe(['billing.manage', 'console.view']); +}); + +it('takes a capability away again', function () { + $role = Role::findOrCreate('Buchhaltung', 'operator'); + $role->givePermissionTo('billing.manage', 'console.view'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->set("granted.{$role->id}", ['console.view']) + ->call('save', $role->id); + + expect($role->fresh()->permissions->pluck('name')->all())->toBe(['console.view']); +}); + +it('refuses to let Owner be stripped', function () { + // Sonst sperrt sich der Betreiber mit einem Klick selbst aus, und es gibt + // niemanden mehr, der es zurücknehmen könnte. Die einzige Rolle, die + // dieser Bildschirm nicht anfassen darf. + $owner = Role::where('name', 'Owner')->where('guard_name', 'operator')->firstOrFail(); + $vorher = $owner->permissions->pluck('name')->sort()->values()->all(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->set("granted.{$owner->id}", ['console.view']) + ->call('save', $owner->id) + ->assertHasErrors(); + + expect($owner->fresh()->permissions->pluck('name')->sort()->values()->all())->toBe($vorher); +}); + +it('creates a role', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->set('newRole', 'Buchhaltung') + ->call('create') + ->assertHasNoErrors(); + + expect(Role::where('name', 'Buchhaltung')->where('guard_name', 'operator')->exists())->toBeTrue(); +}); + +it('never creates a role on the wrong guard', function () { + // Genau der Fehler, der `dpa.manage` auf dem web-Guard hinterlassen hat: + // ein Eintrag, den kein Betreiber je sieht, weil Operator gegen + // `operator` auflöst. + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->set('newRole', 'Buchhaltung') + ->call('create'); + + expect(Role::where('name', 'Buchhaltung')->where('guard_name', 'web')->exists())->toBeFalse(); +}); + +it('will not delete a role somebody still holds', function () { + $role = Role::findOrCreate('Buchhaltung', 'operator'); + Operator::factory()->create()->assignRole($role); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->call('delete', $role->id) + ->assertHasErrors(); + + expect(Role::where('name', 'Buchhaltung')->exists())->toBeTrue(); +}); + +it('deletes a role nobody holds', function () { + $role = Role::findOrCreate('Buchhaltung', 'operator'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Roles::class) + ->call('delete', $role->id) + ->assertHasNoErrors(); + + expect(Role::where('name', 'Buchhaltung')->exists())->toBeFalse(); +}); + +it('is not reachable without staff.manage', function () { + // Wer bestimmen darf, was eine Rolle kann, bestimmt alles andere gleich + // mit. Dasselbe Recht wie das Einladen von Mitarbeitern. + Livewire::actingAs(operator('Admin'), 'operator') + ->test(Roles::class) + ->assertForbidden(); +});