diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index 5d0c1e688..bde72448d 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -54,6 +54,10 @@ public function handle(Team $team, User $user): ?Team DB::table('sessions')->where('user_id', $member->id)->delete(); }); + // The deleting owner is excluded from the loop above; clear their + // stored team too so the deleted id is not restored on next login. + $user->clearStoredTeamIfMatches($team->id); + $team->delete(); return $user->teams()->first(); diff --git a/app/Livewire/SelectTeam.php b/app/Livewire/SelectTeam.php index ba8b2a577..d0a932856 100644 --- a/app/Livewire/SelectTeam.php +++ b/app/Livewire/SelectTeam.php @@ -3,10 +3,14 @@ namespace App\Livewire; use App\Models\Team; +use Illuminate\Contracts\View\View; use Livewire\Component; class SelectTeam extends Component { + // mount()/selectTeam() intentionally have no return type: Livewire's + // redirect() returns a Redirector (not an Illuminate RedirectResponse), + // matching the convention in sibling components such as SwitchTeam. public function mount() { $user = auth()->user(); @@ -37,7 +41,7 @@ public function selectTeam(int $teamId) return redirect()->route('dashboard'); } - public function render() + public function render(): View { return view('livewire.select-team', [ 'teams' => auth()->user()->teams, diff --git a/app/Models/User.php b/app/Models/User.php index ab9a817bf..bb810b30f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -411,8 +411,16 @@ public function resolveStoredTeam(): ?Team */ public function clearStoredTeamIfMatches(int $teamId): void { + // Atomic conditional update: only null the column when the database value + // still points at this team, so a newer team selection made concurrently + // (in another request) is preserved rather than clobbered. + static::query() + ->whereKey($this->getKey()) + ->where('current_team_id', $teamId) + ->update(['current_team_id' => null]); + if ($this->current_team_id === $teamId) { - $this->forceFill(['current_team_id' => null])->saveQuietly(); + $this->current_team_id = null; } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 1c19fd1ca..e9d17a4e0 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -570,8 +570,11 @@ function refreshSession(?Team $team = null): void $team = Team::find($currentTeam->id); } if (! $team) { - // Fall back to any team the user still belongs to. - $team = User::query()->find(Auth::id())?->teams()->first(); + // Fall back to the user's resolvable team (stored choice, or their + // sole team). Returns null for a multi-team user with no valid stored + // choice, so an arbitrary first team is never silently persisted — + // the user is sent to the selection screen instead. + $team = User::query()->find(Auth::id())?->resolveStoredTeam(); } } diff --git a/tests/Feature/Team/ActiveTeamPersistenceTest.php b/tests/Feature/Team/ActiveTeamPersistenceTest.php index b481cce31..4c0329e36 100644 --- a/tests/Feature/Team/ActiveTeamPersistenceTest.php +++ b/tests/Feature/Team/ActiveTeamPersistenceTest.php @@ -161,6 +161,28 @@ function userWithTwoTeams(): array expect($member->fresh()->current_team_id)->toBeNull(); }); +it('clears the deleting owner stored team when they delete that team', function () { + [$owner, $personal, $shared] = userWithTwoTeams(); + $owner->update(['current_team_id' => $shared->id]); + + app(DeleteTeam::class)->handle($shared->fresh(), $owner); + + expect($owner->fresh()->current_team_id)->toBeNull(); +}); + +it('preserves a newer team selection when clearing a stale team', function () { + [$user, $personal, $second] = userWithTwoTeams(); + // In-memory model still points at the team being removed ($second)... + $user->update(['current_team_id' => $second->id]); + // ...but a concurrent request already switched the stored choice to $personal. + User::query()->whereKey($user->id)->update(['current_team_id' => $personal->id]); + + $user->clearStoredTeamIfMatches($second->id); + + // The atomic WHERE guard must not clobber the newer selection. + expect($user->fresh()->current_team_id)->toBe($personal->id); +}); + 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]);