From 0798f84c2cbd2343100e1e79b51a7a74c155558c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:01 +0200 Subject: [PATCH 1/4] fix(auth): protect the root user and owners from deletion by admins An admin of the root team could delete the root user (id 0) and the owners of the root team from the user list. - Never delete the root user here - Refuse a user whose root-team role is higher than the caller's - Refuse the caller's own account; the profile has its own flow --- app/Livewire/Team/AdminView.php | 37 +++++++ tests/Feature/AdminViewUserDeletionTest.php | 102 ++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tests/Feature/AdminViewUserDeletionTest.php diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c98e972fd6..85f5c1c683 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -2,6 +2,7 @@ namespace App\Livewire\Team; +use App\Enums\Role; use App\Models\User; use Livewire\Component; use Livewire\WithPagination; @@ -79,6 +80,10 @@ class AdminView extends Component return $this->dispatch('error', 'User not found'); } + if ($error = $this->deletionError(auth()->user(), $user)) { + return $this->dispatch('error', $error); + } + try { $user->delete(); auditLog('ui.user.deleted', [ @@ -96,6 +101,38 @@ class AdminView extends Component } } + /** + * Instance admins may delete other users, but never the root user, their + * own account (use the profile instead), or a user whose role in the root + * team is higher than their own. + */ + private function deletionError(User $actor, User $target): ?string + { + if ($target->id === 0) { + return 'The root user cannot be deleted.'; + } + + if ($target->id === $actor->id) { + return 'Delete your own account from your profile.'; + } + + $actorRole = $this->rootTeamRole($actor); + $targetRole = $this->rootTeamRole($target); + + if (! $actorRole || ($targetRole && $targetRole->gt($actorRole))) { + return 'You cannot delete a user with a higher role in the root team.'; + } + + return null; + } + + private function rootTeamRole(User $user): ?Role + { + $role = $user->teams()->where('teams.id', 0)->first()?->pivot?->role; + + return $role ? Role::tryFrom($role) : null; + } + public function render() { abort_unless(isInstanceAdmin(), 403); diff --git a/tests/Feature/AdminViewUserDeletionTest.php b/tests/Feature/AdminViewUserDeletionTest.php new file mode 100644 index 0000000000..4852a96f34 --- /dev/null +++ b/tests/Feature/AdminViewUserDeletionTest.php @@ -0,0 +1,102 @@ + 0]); + + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->rootUser = User::factory()->create(['id' => 0]); + + $this->rootAdmin = User::factory()->create(); + $this->rootTeam->members()->attach($this->rootAdmin->id, ['role' => 'admin']); + + $this->rootOwner = User::factory()->create(); + $this->rootTeam->members()->attach($this->rootOwner->id, ['role' => 'owner']); +}); + +function actAsRootTeamMemberForAdminViewDeletion(User $user): void +{ + test()->actingAs($user); + session(['currentTeam' => Team::find(0)]); +} + +test('a root team admin cannot delete the root user', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', 0, 'password') + ->assertDispatched('error', 'The root user cannot be deleted.'); + + expect(User::find(0))->not->toBeNull(); +}); + +test('a root team admin cannot delete a root team owner', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootOwner->id, 'password') + ->assertDispatched('error', 'You cannot delete a user with a higher role in the root team.'); + + expect(User::find($this->rootOwner->id))->not->toBeNull(); +}); + +test('a root team owner cannot delete the root user', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootOwner); + + Livewire::test(AdminView::class) + ->call('delete', 0, 'password') + ->assertDispatched('error', 'The root user cannot be deleted.'); + + expect(User::find(0))->not->toBeNull(); +}); + +test('an instance admin cannot delete their own account from the admin view', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootAdmin->id, 'password') + ->assertDispatched('error', 'Delete your own account from your profile.'); + + expect(User::find($this->rootAdmin->id))->not->toBeNull(); +}); + +test('a root team owner can delete a root team admin and a member', function () { + $member = User::factory()->create(); + $this->rootTeam->members()->attach($member->id, ['role' => 'member']); + + actAsRootTeamMemberForAdminViewDeletion($this->rootOwner); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootAdmin->id, 'password') + ->assertReturned(true) + ->call('delete', $member->id, 'password') + ->assertReturned(true); + + expect(User::find($this->rootAdmin->id))->toBeNull() + ->and(User::find($member->id))->toBeNull(); +}); + +test('a root team admin can delete another root team admin and a user outside the root team', function () { + $otherAdmin = User::factory()->create(); + $this->rootTeam->members()->attach($otherAdmin->id, ['role' => 'admin']); + $outsider = User::factory()->create(); + + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $otherAdmin->id, 'password') + ->assertReturned(true) + ->call('delete', $outsider->id, 'password') + ->assertReturned(true); + + expect(User::find($otherAdmin->id))->toBeNull() + ->and(User::find($outsider->id))->toBeNull(); +}); From 58fb36544a55cdaf761951a2036a178d9d6c09f1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:01 +0200 Subject: [PATCH 2/4] fix(ui): lock the server-built domain state of applications and services The domain rows of applications and previews, and the DNS validation flags of application and service domains, were not locked. A user could change them from the browser to skip the DNS check or store fake DNS results. --- app/Livewire/Project/Application/Domains.php | 3 + .../Project/Application/PreviewDomains.php | 3 + app/Livewire/Project/Service/Domains.php | 1 + .../LockedDomainRowsAndDockerImagesTest.php | 58 ++++++++++++++++++- tests/Feature/ServiceDomainsTest.php | 1 - 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 1f7f782551..a0b54b7bb3 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -77,9 +77,11 @@ class Domains extends Component public ?string $editingGeneratedHost = null; /** @var array */ + #[Locked] public array $domainRows = []; /** When set, the next addSuggestedDomain call for this index skips the DNS block. */ + #[Locked] public ?int $forceAddSuggestedIndex = null; /** @var array */ @@ -122,6 +124,7 @@ class Domains extends Component public bool $isCheckingDns = false; + #[Locked] public bool $dnsValidationEnabled = true; /** Resolved or literal IP users should point DNS at. */ diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index b873d4a9ed..c3a287f8b9 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -11,6 +11,7 @@ use App\Support\DomainUrlParts; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; +use Livewire\Attributes\Locked; use Livewire\Component; class PreviewDomains extends Component @@ -19,6 +20,8 @@ class PreviewDomains extends Component public ApplicationPreview $preview; + /** @var array> */ + #[Locked] public array $domainRows = []; public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => '']; diff --git a/app/Livewire/Project/Service/Domains.php b/app/Livewire/Project/Service/Domains.php index 8747b0f619..7452a13f1a 100644 --- a/app/Livewire/Project/Service/Domains.php +++ b/app/Livewire/Project/Service/Domains.php @@ -110,6 +110,7 @@ class Domains extends Component public bool $isCheckingDns = false; + #[Locked] public bool $dnsValidationEnabled = true; #[Locked] diff --git a/tests/Feature/LockedDomainRowsAndDockerImagesTest.php b/tests/Feature/LockedDomainRowsAndDockerImagesTest.php index 72f31cf87e..cb4a072cea 100644 --- a/tests/Feature/LockedDomainRowsAndDockerImagesTest.php +++ b/tests/Feature/LockedDomainRowsAndDockerImagesTest.php @@ -1,7 +1,11 @@ server->settings()->update(['is_reachable' => true, 'is_usable' => true]); }); -it('rejects a client update of the service domain rows', function () { +it('rejects a client update of the service domain state', function (string $property, mixed $value) { $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( ['server_id' => $this->server->id, 'network' => 'coolify'], ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] @@ -59,8 +63,11 @@ it('rejects a client update of the service domain rows', function () { Livewire::test(Domains::class, ['service' => $service->fresh(['applications', 'server'])]) ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->all() === ['https://web.example.com']) - ->set('domainRows', [['url' => 'https://attacker.example.com']]); -})->throws(CannotUpdateLockedPropertyException::class); + ->set($property, $value); +})->with([ + 'domain rows' => ['domainRows', [['url' => 'https://attacker.example.com']]], + 'dns validation' => ['dnsValidationEnabled', false], +])->throws(CannotUpdateLockedPropertyException::class); it('rejects a client update of the docker image list', function () { $imageId = 'sha256:'.str_repeat('a', 64); @@ -76,3 +83,48 @@ it('rejects a client update of the docker image list', function () { ->assertSet('images.0.containers', ['web']) ->set('images.0.containers', []); })->throws(CannotUpdateLockedPropertyException::class); + +function createLockedDomainRowsApplication(Server $server, Team $team): Application +{ + $destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate( + ['server_id' => $server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + )); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + ]); +} + +it('rejects a client update of the application domain rows', function (string $property, mixed $value) { + $application = createLockedDomainRowsApplication($this->server, $this->team); + + Livewire::test(ApplicationDomains::class, ['application' => $application->fresh()]) + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->where('is_suggested', false)->pluck('url')->all() === ['https://app.example.com']) + ->set($property, $value); +})->with([ + 'domain rows' => ['domainRows', [['url' => 'https://app.example.com', 'service' => null, 'dns_status' => 'ok', 'dns_message' => 'faked', 'expected_ip' => null, 'needs_force_add' => true]]], + 'one row flag' => ['domainRows.0.needs_force_add', true], + 'forced suggestion index' => ['forceAddSuggestedIndex', 0], + 'DNS validation flag' => ['dnsValidationEnabled', false], +])->throws(CannotUpdateLockedPropertyException::class); + +it('rejects a client update of the preview domain rows', function () { + $application = createLockedDomainRowsApplication($this->server, $this->team); + $preview = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/7', + 'fqdn' => 'https://pr-7.example.com', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->all() === ['https://pr-7.example.com']) + ->set('domainRows.0.dns_status', 'ok'); +})->throws(CannotUpdateLockedPropertyException::class); diff --git a/tests/Feature/ServiceDomainsTest.php b/tests/Feature/ServiceDomainsTest.php index d4fc15041c..4d25eb6a77 100644 --- a/tests/Feature/ServiceDomainsTest.php +++ b/tests/Feature/ServiceDomainsTest.php @@ -1003,7 +1003,6 @@ it('shows an inherited internal port badge from the coolify service env port', f Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]) ->assertSet('domainRows.0.internal_port', 3000) ->assertSet('domainRows.0.has_port_override', false) - ->set('dnsValidationEnabled', false) ->call('startEdit', 0) ->assertSet('editingDomainParts.port', '3000') ->call('updateDomain') From 248eca7d510bfd22f1c8cb27c53a6f035f05e9b3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:01 +0200 Subject: [PATCH 3/4] fix(auth): do not accept team invitations on login A password login added the user to the first team that invited their email and switched to it, without asking. The user now accepts the invitation on the invitation page. The invitation link login flow works again, because login no longer consumes the invitation. --- app/Providers/FortifyServiceProvider.php | 34 ++++------ .../Auth/LoginKeepsPendingInvitationTest.php | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 tests/Feature/Auth/LoginKeepsPendingInvitationTest.php diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 70e2ed6fd6..9bdc5d7371 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -6,7 +6,6 @@ use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Models\OauthSetting; -use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -82,29 +81,18 @@ class FortifyServiceProvider extends ServiceProvider $user->updated_at = now(); $user->save(); - // Check if user has a pending invitation they haven't accepted yet - $invitation = TeamInvitation::whereEmail($email)->first(); - if ($invitation && $invitation->isValid()) { - // User is logging in for the first time after being invited - // Attach them to the invited team if not already attached - if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) { - $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); - } - $team = $invitation->team; - $invitation->delete(); - session(['currentTeam' => $team]); - } else { - // Restore the last active team; only fall back when unambiguous. - $team = $user->resolveStoredTeam(); - if (! $team && $user->teams->isEmpty()) { - $team = $user->recreate_personal_team(); - } - if ($team) { - session(['currentTeam' => $team]); - } - // Otherwise (multiple teams, no stored choice) leave the session - // team unset so the user is sent to the team-selection screen. + // Pending team invitations are not accepted here; the user accepts + // them explicitly on the invitation page (team.invitation.show). + // Restore the last active team; only fall back when unambiguous. + $team = $user->resolveStoredTeam(); + if (! $team && $user->teams->isEmpty()) { + $team = $user->recreate_personal_team(); } + if ($team) { + session(['currentTeam' => $team]); + } + // Otherwise (multiple teams, no stored choice) leave the session + // team unset so the user is sent to the team-selection screen. return $user; } diff --git a/tests/Feature/Auth/LoginKeepsPendingInvitationTest.php b/tests/Feature/Auth/LoginKeepsPendingInvitationTest.php new file mode 100644 index 0000000000..d1b6a4de3e --- /dev/null +++ b/tests/Feature/Auth/LoginKeepsPendingInvitationTest.php @@ -0,0 +1,65 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->invitee = User::factory()->create(['email' => 'invitee@example.com']); + $this->personalTeam = $this->invitee->teams->first(); + $this->personalTeam->update(['show_boarding' => false]); + + $this->invitingTeam = Team::factory()->create(['show_boarding' => false]); + $this->invitation = TeamInvitation::create([ + 'team_id' => $this->invitingTeam->id, + 'uuid' => 'pending-login-invitation', + 'email' => $this->invitee->email, + 'role' => 'admin', + 'link' => route('team.invitation.show', ['uuid' => 'pending-login-invitation']), + 'via' => 'email', + ]); +}); + +it('does not accept a pending invitation on password login', function () { + $this->post('/login', ['email' => $this->invitee->email, 'password' => 'password']) + ->assertRedirect(); + + $this->assertAuthenticatedAs($this->invitee); + expect($this->invitee->teams()->where('team_id', $this->invitingTeam->id)->exists())->toBeFalse() + ->and(data_get(session('currentTeam'), 'id'))->toBe($this->personalTeam->id); + $this->assertDatabaseHas('team_invitations', ['id' => $this->invitation->id]); +}); + +it('lets the user accept the invitation explicitly after login', function () { + $this->post('/login', ['email' => $this->invitee->email, 'password' => 'password']); + + $this->get(route('team.invitation.show', $this->invitation->uuid)) + ->assertSuccessful() + ->assertSee($this->invitingTeam->name); + + $this->post(route('team.invitation.accept', $this->invitation->uuid)) + ->assertRedirect(route('team.index')); + + expect($this->invitee->teams()->where('team_id', $this->invitingTeam->id)->first()?->pivot->role)->toBe('admin') + ->and(data_get(session('currentTeam'), 'id'))->toBe($this->invitingTeam->id); + $this->assertDatabaseMissing('team_invitations', ['id' => $this->invitation->id]); +}); + +it('returns to the invitation page after logging in from the invitation link', function () { + $this->get(route('team.invitation.show', $this->invitation->uuid)) + ->assertRedirect(route('login')); + + $this->post('/login', ['email' => $this->invitee->email, 'password' => 'password']) + ->assertRedirect(route('team.invitation.show', $this->invitation->uuid)); + + $this->get(route('team.invitation.show', $this->invitation->uuid)) + ->assertSuccessful() + ->assertSee($this->invitingTeam->name); +}); From 93e7fa7f345a9f74164bc9048f885d48cb2920bd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:01 +0200 Subject: [PATCH 4/4] feat(dashboard): show pending team invitations Login no longer accepts invitations, so the dashboard lists the unexpired invitations for the user's email with a link to the invitation page, where the user accepts them. --- app/Livewire/Dashboard.php | 31 +++++- resources/views/livewire/dashboard.blade.php | 17 +++ .../DashboardPendingInvitationsTest.php | 105 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/DashboardPendingInvitationsTest.php diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 31ac6cf8d7..2fc2553512 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; +use App\Models\TeamInvitation; use Illuminate\Support\Collection; use Livewire\Component; @@ -40,6 +41,34 @@ class Dashboard extends Component public function render() { - return view('livewire.dashboard'); + return view('livewire.dashboard', [ + 'pendingInvitations' => $this->pendingInvitations(), + ]); + } + + /** + * Unexpired invitations for the user's email to teams the user has not joined yet. + * + * Uses hasExpired() instead of isValid() so rendering never deletes invitations or users. + * + * @return Collection + */ + private function pendingInvitations(): Collection + { + $user = auth()->user(); + + if (! $user) { + return collect(); + } + + return TeamInvitation::query() + ->where('email', strtolower($user->email)) + ->whereHas('team') + ->whereNotIn('team_id', $user->teams()->select('teams.id')) + ->with('team:id,name') + ->oldest() + ->get() + ->reject(fn (TeamInvitation $invitation) => $invitation->hasExpired()) + ->values(); } } diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 4f887d7180..472b3e9961 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -15,6 +15,23 @@ @endphp
+ @if ($pendingInvitations->isNotEmpty()) +
+ @foreach ($pendingInvitations as $invitation) + +
+ Team {{ $invitation->team->name }} + invited you as {{ ucfirst($invitation->role) }}. + + Review invitation + +
+
+ @endforeach +
+ @endif + @if ($hasTrafficAnalytics) diff --git a/tests/Feature/DashboardPendingInvitationsTest.php b/tests/Feature/DashboardPendingInvitationsTest.php new file mode 100644 index 0000000000..21850794ee --- /dev/null +++ b/tests/Feature/DashboardPendingInvitationsTest.php @@ -0,0 +1,105 @@ +uuid(); + + return TeamInvitation::create(array_merge([ + 'team_id' => $team->id, + 'uuid' => $uuid, + 'email' => $email, + 'role' => 'admin', + 'link' => url('/invitations/'.$uuid), + 'via' => 'link', + ], $attributes)); +} + +beforeEach(function () { + Queue::fake(); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(['email' => 'invited@example.com']); + $this->ownTeam = Team::factory()->create(); + $this->user->teams()->attach($this->ownTeam, ['role' => 'owner']); + $this->invitingTeam = Team::factory()->create(['name' => 'Acme Platform']); + + Team::query()->update(['show_boarding' => false]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->ownTeam]); +}); + +it('shows a pending invitation for the user email with a review link', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'Invited@Example.com'); + + Livewire::test(Dashboard::class) + ->assertSee('Acme Platform') + ->assertSee('invited you as Admin.') + ->assertSee('Review invitation') + ->assertSee(route('team.invitation.show', $invitation->uuid), false); +}); + +it('does not show invitations for another email', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'someone-else@example.com'); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation') + ->assertDontSee(route('team.invitation.show', $invitation->uuid), false); +}); + +it('does not show expired invitations and keeps them in place', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'invited@example.com'); + $invitation->forceFill([ + 'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1), + ])->save(); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation'); + + expect(TeamInvitation::query()->whereKey($invitation->id)->exists())->toBeTrue() + ->and(User::query()->whereKey($this->user->id)->exists())->toBeTrue(); +}); + +it('does not show invitations to a team the user already belongs to', function () { + $this->user->teams()->attach($this->invitingTeam, ['role' => 'member']); + createDashboardTestInvitation($this->invitingTeam, 'invited@example.com'); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation'); +}); + +it('no longer shows the invitation after it is accepted', function () { + $invitation = createDashboardTestInvitation($this->invitingTeam, 'invited@example.com'); + + $this->get(route('dashboard')) + ->assertOk() + ->assertSee(route('team.invitation.show', $invitation->uuid), false); + + $this->post(route('team.invitation.accept', $invitation->uuid)) + ->assertRedirect(route('team.index')); + + expect($this->user->teams()->whereKey($this->invitingTeam->id)->exists())->toBeTrue(); + + $this->actingAs($this->user->fresh()); + session(['currentTeam' => $this->invitingTeam->fresh()]); + + Livewire::test(Dashboard::class) + ->assertDontSee('Acme Platform') + ->assertDontSee('Review invitation') + ->assertDontSee(route('team.invitation.show', $invitation->uuid), false); +});