feat(portal): seats management — invite/role/revoke against plan limit

- 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 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:40:18 +02:00
parent df10448e5d
commit 3b288486cd
5 changed files with 362 additions and 87 deletions

View File

@ -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,
]);
}
}

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -1,48 +1,90 @@
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('users.action_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise">
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4 animate-rise">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('users.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle', ['count' => count($people)]) }}</p>
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle') }}</p>
</div>
<div class="text-right">
<p class="font-mono text-lg font-semibold text-ink">{{ $used }} / {{ $limit }}</p>
<p class="text-xs text-muted">{{ __('users.seats_used') }}</p>
</div>
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.soon })">
<x-ui.icon name="plus" class="size-4" />{{ __('users.add') }}
</x-ui.button>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
{{-- Invite --}}
<form wire:submit="invite" class="grid grid-cols-1 gap-3 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
<div>
<label class="text-sm font-medium text-body" for="inviteEmail">{{ __('users.invite_email') }}</label>
<input id="inviteEmail" type="email" wire:model="inviteEmail" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
@error('inviteEmail')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="inviteName">{{ __('users.invite_name') }}</label>
<input id="inviteName" type="text" wire:model="inviteName" class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
</div>
<div>
<label class="text-sm font-medium text-body" for="inviteRole">{{ __('users.role') }}</label>
<select id="inviteRole" wire:model="inviteRole" class="mt-1.5 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="admin">{{ __('users.role_admin') }}</option>
<option value="member">{{ __('users.role_member') }}</option>
<option value="readonly">{{ __('users.role_readonly') }}</option>
</select>
</div>
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled" wire:target="invite">
<x-ui.icon name="plus" class="size-4" />{{ __('users.invite') }}
</x-ui.button>
</form>
{{-- Seats --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('users.col_name') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_group') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_role') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_person') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.role') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('users.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($people as $p)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
@foreach ($seats as $seat)
<tr wire:key="seat-{{ $seat->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $p['name'] }}</p>
<p class="font-mono text-xs text-muted">{{ $p['email'] }}</p>
<p class="font-medium text-ink">{{ $seat->name ?: $seat->email }}</p>
@if ($seat->name)<p class="text-xs text-muted">{{ $seat->email }}</p>@endif
</td>
<td class="px-4 py-3">
@if ($seat->role === 'owner')
<span class="inline-flex items-center gap-1.5 rounded-pill bg-accent-subtle px-2.5 py-0.5 text-xs font-semibold text-accent-text">{{ __('users.role_owner') }}</span>
@else
<select wire:change="setRole('{{ $seat->uuid }}', $event.target.value)" class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
@foreach (['admin', 'member', 'readonly'] as $r)
<option value="{{ $r }}" @selected($seat->role === $r)>{{ __('users.role_'.$r) }}</option>
@endforeach
</select>
@endif
</td>
<td class="px-4 py-3">
@php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-1.5">
@if ($seat->status === 'invited')
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
@endif
@if ($seat->role !== 'owner')
<button type="button" wire:click="revoke('{{ $seat->uuid }}')" aria-label="{{ __('users.revoke') }}"
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>
@endif
</div>
</td>
<td class="px-4 py-3 text-body">{{ $p['group'] }}</td>
<td class="px-4 py-3 text-body">{{ $p['role'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$p['status'] === 'guest' ? 'info' : 'active'">{{ $p['status'] === 'guest' ? __('users.guest') : __('users.active') }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('users.by_group') }}</h2>
<div class="mt-3 h-56" wire:ignore>
<x-ui.chart :config="$groupsChart" class="h-56" :label="__('users.by_group')" />
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,73 @@
<?php
use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Seat;
use App\Models\User;
use Livewire\Livewire;
function seatSetup(string $plan = 'team'): array
{
$user = User::factory()->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();
});