fix(teams): clear stale current_team_id when membership ends

Reset the user's persisted current_team_id when they are removed from
a team, when their team is deleted, or when refreshSession finds no
team left, so a dangling reference is never restored on next login.
This commit is contained in:
Aditya Tripathi 2026-08-25 13:46:29 +00:00 committed by Andras Bacsai
parent 12498b4b86
commit 2b92fb86b5
5 changed files with 56 additions and 1 deletions

View file

@ -50,6 +50,7 @@ public function handle(Team $team, User $user): ?Team
->get()
->each(function (User $member) use ($team): void {
$member->teams()->detach($team);
$member->clearStoredTeamIfMatches($team->id);
DB::table('sessions')->where('user_id', $member->id)->delete();
});

View file

@ -89,6 +89,7 @@ public function remove()
DB::transaction(function () use ($teamId): void {
$this->member->teams()->detach($teamId);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->member->clearStoredTeamIfMatches($teamId);
});
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");

View file

@ -401,6 +401,21 @@ public function resolveStoredTeam(): ?Team
return null;
}
/**
* Reset the persisted active team when it points to the given team.
*
* Called when the user is removed from a team (or the team is deleted) so a
* stale current_team_id can never be trusted after the fact. Read paths
* already re-validate membership; this is defense-in-depth that clears the
* dangling value at the source event instead of relying on self-healing.
*/
public function clearStoredTeamIfMatches(int $teamId): void
{
if ($this->current_team_id === $teamId) {
$this->forceFill(['current_team_id' => null])->saveQuietly();
}
}
public function role(): ?string
{
if (data_get($this, 'pivot')) {

View file

@ -581,8 +581,13 @@ function refreshSession(?Team $team = null): void
if (! $team) {
// The user has no team left (e.g. just deleted their current team and
// belongs to no other): clear the stale session reference instead of
// dereferencing null.
// dereferencing null, and drop the persisted choice so it is not
// restored on next login.
session()->forget('currentTeam');
$user = Auth::user();
if ($user && ! is_null($user->current_team_id)) {
$user->forceFill(['current_team_id' => null])->saveQuietly();
}
return;
}

View file

@ -1,5 +1,6 @@
<?php
use App\Actions\Team\DeleteTeam;
use App\Livewire\SelectTeam;
use App\Models\InstanceSettings;
use App\Models\Team;
@ -128,6 +129,38 @@ function userWithTwoTeams(): array
expect($response->headers->get('Location'))->not->toBe(route('team.select'));
});
it('clears the stored team when the member is removed from it', function () {
[$user, , $second] = userWithTwoTeams();
$user->update(['current_team_id' => $second->id]);
// Simulate the removal event (Team\Member::remove detaches then clears).
$user->teams()->detach($second->id);
$user->clearStoredTeamIfMatches($second->id);
expect($user->fresh()->current_team_id)->toBeNull();
});
it('keeps the stored team when the member is removed from a different team', function () {
[$user, $personal, $second] = userWithTwoTeams();
$user->update(['current_team_id' => $second->id]);
$user->teams()->detach($personal->id);
$user->clearStoredTeamIfMatches($personal->id);
expect($user->fresh()->current_team_id)->toBe($second->id);
});
it('clears the stored team for members when their team is deleted', function () {
[$owner, , $shared] = userWithTwoTeams();
$member = User::factory()->create();
$member->teams()->attach($shared, ['role' => 'member']);
$member->update(['current_team_id' => $shared->id]);
app(DeleteTeam::class)->handle($shared->fresh(), $owner);
expect($member->fresh()->current_team_id)->toBeNull();
});
it('bounces users who already have an active team away from the select screen', function () {
[$user, , $second] = userWithTwoTeams();
$user->update(['current_team_id' => $second->id]);