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.
This commit is contained in:
Andras Bacsai
2026-10-05 23:42:01 +02:00
parent 58fb36544a
commit 248eca7d51
2 changed files with 76 additions and 23 deletions
+11 -23
View File
@@ -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;
}
@@ -0,0 +1,65 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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);
});