From 3b288486cd5cc580edaf9ae0224839a86b94f763 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 14:40:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal):=20seats=20management=20=E2=80=94?= =?UTF-8?q?=20invite/role/revoke=20against=20plan=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - seats table + model; Users page manages team members (owner auto-created) - invite respects plan seat allowance + dedupe; role change + revoke guarded so the last owner can never be removed or demoted Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Users.php | 178 +++++++++++++++++++---- lang/de/users.php | 40 +++-- lang/en/users.php | 40 +++-- resources/views/livewire/users.blade.php | 118 ++++++++++----- tests/Feature/SeatsTest.php | 73 ++++++++++ 5 files changed, 362 insertions(+), 87 deletions(-) create mode 100644 tests/Feature/SeatsTest.php diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 67076be..456a864 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -2,40 +2,168 @@ namespace App\Livewire; +use App\Models\Customer; +use App\Models\Seat; use Livewire\Attributes\Layout; +use Livewire\Attributes\Validate; use Livewire\Component; #[Layout('layouts.portal-app')] class Users extends Component { + #[Validate('required|email|max:255')] + public string $inviteEmail = ''; + + #[Validate('nullable|string|max:255')] + public string $inviteName = ''; + + #[Validate('required|in:admin,member,readonly')] + public string $inviteRole = 'member'; + + public function mount(): void + { + // Every customer starts with themselves as the owner seat. + $customer = $this->customer(); + if ($customer !== null && $customer->seats()->count() === 0) { + $customer->seats()->create([ + 'email' => $customer->email, + 'name' => $customer->name, + 'role' => 'owner', + 'status' => 'active', + 'invited_at' => now(), + ]); + } + } + + public function invite(): void + { + $customer = $this->customer(); + if ($customer === null) { + return; + } + + $data = $this->validate(); + + // Seat-limit guard against the plan allowance. + if ($this->usedSeats($customer) >= $this->seatLimit($customer)) { + $this->addError('inviteEmail', __('users.limit_reached')); + + return; + } + + // One seat per email per customer. + if ($customer->seats()->where('email', $data['inviteEmail'])->exists()) { + $this->addError('inviteEmail', __('users.duplicate')); + + return; + } + + $customer->seats()->create([ + 'email' => $data['inviteEmail'], + 'name' => $data['inviteName'] ?: null, + 'role' => $data['inviteRole'], + 'status' => 'invited', + 'invited_at' => now(), + ]); + + $this->reset('inviteEmail', 'inviteName', 'inviteRole'); + $this->inviteRole = 'member'; + $this->dispatch('notify', message: __('users.invited')); + } + + public function setRole(string $uuid, string $role): void + { + if (! in_array($role, Seat::ROLES, true)) { + return; + } + $seat = $this->seat($uuid); + if ($seat === null) { + return; + } + + // Never leave the account without an owner. + if ($seat->role === 'owner' && $role !== 'owner' && $this->ownerCount() <= 1) { + $this->dispatch('notify', message: __('users.last_owner')); + + return; + } + + $seat->update(['role' => $role]); + } + + public function revoke(string $uuid): void + { + $seat = $this->seat($uuid); + if ($seat === null) { + return; + } + + if ($seat->role === 'owner' && $this->ownerCount() <= 1) { + $this->dispatch('notify', message: __('users.last_owner')); + + return; + } + + $seat->delete(); + $this->dispatch('notify', message: __('users.revoked')); + } + + public function resend(string $uuid): void + { + // Invite delivery is mocked for now. + if ($this->seat($uuid) !== null) { + $this->dispatch('notify', message: __('users.resent')); + } + } + + private function ownerCount(): int + { + $customer = $this->customer(); + + return $customer ? $customer->seats()->where('role', 'owner')->count() : 0; + } + + private function usedSeats(Customer $customer): int + { + return $customer->seats()->where('status', '!=', 'revoked')->count(); + } + + private function seatLimit(Customer $customer): int + { + $instance = $customer->instances()->latest('id')->first(); + $plan = $instance->plan ?? 'start'; + + return (int) config("provisioning.plans.$plan.seats", 5); + } + + private function seat(string $uuid): ?Seat + { + $customer = $this->customer(); + + 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(); + $seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect(); + return view('livewire.users', [ - 'people' => [ - ['name' => 'Dr. Sabine Berger', 'email' => 's.berger@kanzlei-berger.at', 'role' => __('users.role_admin'), 'group' => 'Partner', 'status' => 'active'], - ['name' => 'Michael Huber', 'email' => 'm.huber@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Anwälte', 'status' => 'active'], - ['name' => 'Julia Wagner', 'email' => 'j.wagner@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Anwälte', 'status' => 'active'], - ['name' => 'Thomas Bauer', 'email' => 't.bauer@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Sekretariat', 'status' => 'active'], - ['name' => 'Lena Mayr', 'email' => 'l.mayr@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Sekretariat', 'status' => 'active'], - ['name' => 'Paul Steiner', 'email' => 'p.steiner@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Anwälte', 'status' => 'active'], - ['name' => 'Gast · Notariat Külz', 'email' => 'extern@notariat-kuelz.at', 'role' => __('users.role_guest'), 'group' => __('users.guests'), 'status' => 'guest'], - ['name' => 'Gast · Steuerberatung Reiss', 'email' => 'extern@stb-reiss.at', 'role' => __('users.role_guest'), 'group' => __('users.guests'), 'status' => 'guest'], - ], - 'groupsChart' => [ - 'type' => 'doughnut', - 'data' => [ - 'labels' => ['Partner', 'Anwälte', 'Sekretariat', __('users.guests')], - 'datasets' => [[ - 'data' => [1, 3, 2, 2], - 'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:surface-2'], - 'borderWidth' => 0, - ]], - ], - 'options' => [ - 'cutout' => '62%', - 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]], - ], - ], + 'seats' => $seats, + 'used' => $customer ? $this->usedSeats($customer) : 0, + 'limit' => $customer ? $this->seatLimit($customer) : 0, + 'roles' => Seat::ROLES, ]); } } diff --git a/lang/de/users.php b/lang/de/users.php index cb7e5c6..e4ac8d5 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -2,18 +2,34 @@ return [ 'title' => 'Benutzer', - 'subtitle' => ':count Benutzer · 3 Gruppen · 2 Gastzugänge', - 'add' => 'Benutzer hinzufügen', - 'col_name' => 'Name', - 'col_group' => 'Gruppe', - 'col_role' => 'Rolle', - 'col_status' => 'Status', - 'guest' => 'Gast', - 'active' => 'Aktiv', - 'guests' => 'Gäste', - 'by_group' => 'Nach Gruppe', + 'subtitle' => 'Personen verwalten, die Ihre Cloud nutzen.', + 'seats_used' => 'Plätze belegt', + + 'invite_email' => 'E-Mail', + 'invite_name' => 'Name (optional)', + 'invite' => 'Einladen', + 'role' => 'Rolle', + + 'role_owner' => 'Inhaber', 'role_admin' => 'Administrator', 'role_member' => 'Mitglied', - 'role_guest' => 'Gast', - 'action_toast' => 'Benutzerverwaltung im Prototyp nur angedeutet.', + 'role_readonly' => 'Nur Lesen', + + 'col_person' => 'Person', + 'col_status' => 'Status', + 'col_actions' => 'Aktionen', + + 'status_active' => 'Aktiv', + 'status_invited' => 'Eingeladen', + 'status_revoked' => 'Entfernt', + + 'resend' => 'Erneut senden', + 'revoke' => 'Entfernen', + + 'invited' => 'Einladung gesendet.', + 'revoked' => 'Benutzer entfernt.', + 'resent' => 'Einladung erneut gesendet.', + 'last_owner' => 'Der letzte Inhaber kann nicht entfernt oder geändert werden.', + 'limit_reached' => 'Platz-Limit Ihres Pakets erreicht. Bitte upgraden.', + 'duplicate' => 'Diese E-Mail ist bereits eingeladen.', ]; diff --git a/lang/en/users.php b/lang/en/users.php index 8ce25e1..4c1f819 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -2,18 +2,34 @@ return [ 'title' => 'Users', - 'subtitle' => ':count users · 3 groups · 2 guest accounts', - 'add' => 'Add user', - 'col_name' => 'Name', - 'col_group' => 'Group', - 'col_role' => 'Role', - 'col_status' => 'Status', - 'guest' => 'Guest', - 'active' => 'Active', - 'guests' => 'Guests', - 'by_group' => 'By group', + 'subtitle' => 'Manage the people who use your cloud.', + 'seats_used' => 'seats used', + + 'invite_email' => 'Email', + 'invite_name' => 'Name (optional)', + 'invite' => 'Invite', + 'role' => 'Role', + + 'role_owner' => 'Owner', 'role_admin' => 'Administrator', 'role_member' => 'Member', - 'role_guest' => 'Guest', - 'action_toast' => 'User management is only indicated in this prototype.', + 'role_readonly' => 'Read-only', + + 'col_person' => 'Person', + 'col_status' => 'Status', + 'col_actions' => 'Actions', + + 'status_active' => 'Active', + 'status_invited' => 'Invited', + 'status_revoked' => 'Removed', + + 'resend' => 'Resend', + 'revoke' => 'Remove', + + 'invited' => 'Invitation sent.', + 'revoked' => 'User removed.', + 'resent' => 'Invitation resent.', + 'last_owner' => 'The last owner cannot be removed or changed.', + 'limit_reached' => 'Your plan seat limit is reached. Please upgrade.', + 'duplicate' => 'This email is already invited.', ]; diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 4525fd6..49fac6d 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -1,48 +1,90 @@ -
-
+
+

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

-

{{ __('users.subtitle', ['count' => count($people)]) }}

+

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

+
+
+

{{ $used }} / {{ $limit }}

+

{{ __('users.seats_used') }}

- - {{ __('users.add') }} -
-
-
-
- - - - - - - - - - - @foreach ($people as $p) - - - - - - - @endforeach - -
{{ __('users.col_name') }}{{ __('users.col_group') }}{{ __('users.col_role') }}{{ __('users.col_status') }}
-

{{ $p['name'] }}

-

{{ $p['email'] }}

-
{{ $p['group'] }}{{ $p['role'] }}{{ $p['status'] === 'guest' ? __('users.guest') : __('users.active') }}
-
+ {{-- Invite --}} +
+
+ + + @error('inviteEmail')

{{ $message }}

@enderror
+
+ + +
+
+ + +
+ + {{ __('users.invite') }} + +
-
-

{{ __('users.by_group') }}

-
- -
+ {{-- Seats --}} +
+
+ + + + + + + + + + + @foreach ($seats as $seat) + + + + + + + @endforeach + +
{{ __('users.col_person') }}{{ __('users.role') }}{{ __('users.col_status') }}{{ __('users.col_actions') }}
+

{{ $seat->name ?: $seat->email }}

+ @if ($seat->name)

{{ $seat->email }}

@endif +
+ @if ($seat->role === 'owner') + {{ __('users.role_owner') }} + @else + + @endif + + @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp + {{ __('users.status_'.$seat->status) }} + +
+ @if ($seat->status === 'invited') + + @endif + @if ($seat->role !== 'owner') + + @endif +
+
diff --git a/tests/Feature/SeatsTest.php b/tests/Feature/SeatsTest.php new file mode 100644 index 0000000..ed0a48e --- /dev/null +++ b/tests/Feature/SeatsTest.php @@ -0,0 +1,73 @@ +create(['email' => 'o@seat.test', 'is_admin' => false]); + $customer = Customer::factory()->create(['email' => 'o@seat.test', 'user_id' => $user->id, 'name' => 'Owner Co']); + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => $plan]); + Instance::factory()->create(['customer_id' => $customer->id, 'order_id' => $order->id, 'plan' => $plan, 'status' => 'active']); + + return compact('user', 'customer'); +} + +it('creates an owner seat on first visit', function () { + ['user' => $user, 'customer' => $customer] = seatSetup(); + + Livewire::actingAs($user)->test(Users::class)->assertOk(); + + $owner = $customer->seats()->where('role', 'owner')->first(); + expect($owner)->not->toBeNull()->and($owner->email)->toBe('o@seat.test'); +}); + +it('invites a seat and blocks duplicates', function () { + ['user' => $user, 'customer' => $customer] = seatSetup(); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('invite') + ->assertHasNoErrors(); + expect($customer->seats()->where('email', 'new@seat.test')->exists())->toBeTrue(); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'new@seat.test')->call('invite') + ->assertHasErrors(['inviteEmail']); + expect($customer->seats()->where('email', 'new@seat.test')->count())->toBe(1); +}); + +it('enforces the plan seat limit', function () { + ['user' => $user, 'customer' => $customer] = seatSetup('start'); // 5-seat plan + // Fill all 5 seats so the plan allowance is exhausted. + Seat::factory()->count(5)->create(['customer_id' => $customer->id]); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'over@seat.test')->call('invite') + ->assertHasErrors(['inviteEmail']); + expect($customer->seats()->where('email', 'over@seat.test')->exists())->toBeFalse(); +}); + +it('will not remove or demote the last owner', function () { + ['user' => $user, 'customer' => $customer] = seatSetup(); + Livewire::actingAs($user)->test(Users::class); // creates owner + $owner = $customer->seats()->where('role', 'owner')->first(); + + Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid); + expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue(); + + Livewire::actingAs($user)->test(Users::class)->call('setRole', $owner->uuid, 'member'); + expect($owner->fresh()->role)->toBe('owner'); +}); + +it('revokes a non-owner seat', function () { + ['user' => $user, 'customer' => $customer] = seatSetup(); + $seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']); + + Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid); + expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse(); +});