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