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
parent
df10448e5d
commit
3b288486cd
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue