From 12498b4b8648d27c4251f8010082620d9c63eada Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 25 Aug 2026 12:45:47 +0000 Subject: [PATCH 1/4] feat(teams): persist active team and add team selection screen Add current_team_id to users so the last active team is restored on login instead of always defaulting to the personal team. When a user belongs to multiple teams and has no valid stored choice, redirect them to a new team.select screen (SelectTeam Livewire component) to pick one, rather than silently choosing the first team. Update Fortify and OAuth login flows to use the new resolveStoredTeam() logic. --- app/Http/Controllers/OauthController.php | 8 + .../Middleware/DecideWhatToDoWithUser.php | 19 ++- app/Livewire/SelectTeam.php | 46 ++++++ app/Models/User.php | 27 ++++ app/Providers/FortifyServiceProvider.php | 15 +- bootstrap/helpers/shared.php | 7 + ...006_add_current_team_id_to_users_table.php | 26 ++++ .../views/livewire/select-team.blade.php | 18 +++ routes/web.php | 2 + .../Team/ActiveTeamPersistenceTest.php | 139 ++++++++++++++++++ 10 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 app/Livewire/SelectTeam.php create mode 100644 database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php create mode 100644 resources/views/livewire/select-team.blade.php create mode 100644 tests/Feature/Team/ActiveTeamPersistenceTest.php diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 4038fe63e2..109f8915a1 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -38,6 +38,14 @@ class OauthController extends Controller } Auth::login($user); + $team = $user->resolveStoredTeam(); + if (! $team && $user->teams()->count() === 0) { + $team = $user->recreate_personal_team(); + } + if ($team) { + session(['currentTeam' => $user->currentTeam = $team]); + } + return redirect('/'); } catch (\Exception $e) { $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; diff --git a/app/Http/Middleware/DecideWhatToDoWithUser.php b/app/Http/Middleware/DecideWhatToDoWithUser.php index dbf261f4db..6babdb69a0 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -18,9 +18,24 @@ class DecideWhatToDoWithUser } if (auth()?->user()?->currentTeam()) { refreshSession(auth()->user()->currentTeam()); + // A team is already active; the selection screen no longer applies. + if ($request->routeIs('team.select')) { + return redirect()->route('dashboard'); + } } elseif (auth()?->user()?->teams?->count() > 0) { - // User's session team is invalid (e.g., removed from team), switch to first available team - refreshSession(auth()->user()->teams->first()); + // No active team in the session (fresh login or invalidated selection). + // Restore the last active team, or the sole team of a single-team user. + $resolvedTeam = auth()->user()->resolveStoredTeam(); + if ($resolvedTeam) { + refreshSession($resolvedTeam); + } elseif ($request->routeIs('team.select') || $request->routeIs('*livewire.update')) { + // Ambiguous choice: let the user pick a team on the selection screen. + // Livewire's update endpoint must pass through too, otherwise the + // selection action's AJAX call is redirected to HTML and never runs. + return $next($request); + } else { + return redirect()->route('team.select'); + } } if (! auth()->user() || ! isCloud()) { if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) { diff --git a/app/Livewire/SelectTeam.php b/app/Livewire/SelectTeam.php new file mode 100644 index 0000000000..ba8b2a577f --- /dev/null +++ b/app/Livewire/SelectTeam.php @@ -0,0 +1,46 @@ +user(); + + // A team is already active, or the user has at most one team: nothing to pick. + if ($user->currentTeam() || $user->teams->count() <= 1) { + $resolved = $user->resolveStoredTeam(); + if ($resolved) { + refreshSession($resolved); + } + + return redirect()->route('dashboard'); + } + } + + public function selectTeam(int $teamId) + { + $user = auth()->user(); + if (! $user->teams->contains('id', $teamId)) { + return; + } + $team = Team::find($teamId); + if (! $team) { + return; + } + refreshSession($team); + + return redirect()->route('dashboard'); + } + + public function render() + { + return view('livewire.select-team', [ + 'teams' => auth()->user()->teams, + ])->layout('layouts.simple'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 5b38473962..47f6f2fd40 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -48,6 +48,7 @@ class User extends Authenticatable implements SendsEmail 'name', 'email', 'password', + 'current_team_id', 'force_password_reset', 'marketing_emails', 'pending_email', @@ -66,6 +67,7 @@ class User extends Authenticatable implements SendsEmail ]; protected $casts = [ + 'current_team_id' => 'integer', 'email_verified_at' => 'datetime', 'force_password_reset' => 'boolean', 'show_boarding' => 'boolean', @@ -374,6 +376,31 @@ class User extends Authenticatable implements SendsEmail }); } + /** + * Resolve the team to activate when the session has no current team + * (fresh login or an invalidated session). + * + * Returns the user's last active team when they still belong to it, or the + * sole team of a single-team user. Returns null when the choice is ambiguous + * (more than one team and no valid stored preference) — the caller must then + * prompt the user to pick a team instead of defaulting silently. + */ + public function resolveStoredTeam(): ?Team + { + if (! is_null($this->current_team_id)) { + $storedTeam = $this->teams->firstWhere('id', $this->current_team_id); + if ($storedTeam) { + return $storedTeam; + } + } + + if ($this->teams->count() === 1) { + return $this->teams->first(); + } + + return null; + } + public function role(): ?string { if (data_get($this, 'pivot')) { diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index ce16e617d7..60d2545a05 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -90,14 +90,19 @@ class FortifyServiceProvider extends ServiceProvider } $user->currentTeam = $invitation->team; $invitation->delete(); + session(['currentTeam' => $user->currentTeam]); } else { - // Normal login - use personal team - $user->currentTeam = $user->teams->firstWhere('personal_team', true); - if (! $user->currentTeam) { - $user->currentTeam = $user->recreate_personal_team(); + // 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' => $user->currentTeam = $team]); + } + // Otherwise (multiple teams, no stored choice) leave the session + // team unset so the user is sent to the team-selection screen. } - session(['currentTeam' => $user->currentTeam]); return $user; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index fbfbc9b566..169132cb70 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -593,6 +593,13 @@ function refreshSession(?Team $team = null): void return $team; }); session(['currentTeam' => $team]); + + // Persist the active team so it can be restored after logout/login. + $user = Auth::user(); + if ($user && $user->current_team_id !== $team->id) { + $user->current_team_id = $team->id; + $user->saveQuietly(); + } } function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null) { diff --git a/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php b/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php new file mode 100644 index 0000000000..01f53d4221 --- /dev/null +++ b/database/migrations/2026_08_24_131006_add_current_team_id_to_users_table.php @@ -0,0 +1,26 @@ +unsignedBigInteger('current_team_id')->nullable()->after('id'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('current_team_id'); + }); + } +}; diff --git a/resources/views/livewire/select-team.blade.php b/resources/views/livewire/select-team.blade.php new file mode 100644 index 0000000000..9eb23c19c5 --- /dev/null +++ b/resources/views/livewire/select-team.blade.php @@ -0,0 +1,18 @@ + +
+ @foreach ($teams as $team) + + @endforeach +
+
diff --git a/routes/web.php b/routes/web.php index cdf8161797..30a9634d4d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -51,6 +51,7 @@ use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenS use App\Livewire\Security\CloudTokens; use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex; use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow; +use App\Livewire\SelectTeam; use App\Livewire\Server\Advanced as ServerAdvanced; use App\Livewire\Server\CaCertificate\Show as CaCertificateShow; use App\Livewire\Server\Charts as ServerCharts; @@ -398,6 +399,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::middleware(['auth'])->group(function () { + Route::get('/select-team', SelectTeam::class)->name('team.select'); Route::get('/sources', function () { $sources = currentTeam()->sources(); diff --git a/tests/Feature/Team/ActiveTeamPersistenceTest.php b/tests/Feature/Team/ActiveTeamPersistenceTest.php new file mode 100644 index 0000000000..4dc1570cd1 --- /dev/null +++ b/tests/Feature/Team/ActiveTeamPersistenceTest.php @@ -0,0 +1,139 @@ + InstanceSettings::firstOrCreate(['id' => 0])); +}); + +/** + * Create a user that belongs to two teams (their auto-created personal team + * plus a second team). Boarding is disabled so the middleware does not bounce + * the request to the onboarding screen. + */ +function userWithTwoTeams(): array +{ + $user = User::factory()->create(); + $personal = $user->teams->first(); + $personal->update(['show_boarding' => false]); + + $second = Team::factory()->create(['show_boarding' => false]); + $user->teams()->attach($second, ['role' => 'owner']); + $user->refresh(); + + return [$user, $personal, $second]; +} + +it('resolves the stored team when the user still belongs to it', function () { + [$user, , $second] = userWithTwoTeams(); + $user->update(['current_team_id' => $second->id]); + + expect($user->resolveStoredTeam()?->id)->toBe($second->id); +}); + +it('resolves the only team for single-team users without a stored choice', function () { + $user = User::factory()->create(); + $user->teams->first()->update(['show_boarding' => false]); + + expect($user->resolveStoredTeam()?->id)->toBe($user->teams->first()->id); +}); + +it('returns null for multi-team users without a valid stored choice', function () { + [$user] = userWithTwoTeams(); + + expect($user->resolveStoredTeam())->toBeNull(); +}); + +it('ignores a stored team the user no longer belongs to', function () { + [$user, , $second] = userWithTwoTeams(); + $user->update(['current_team_id' => 99999]); + + expect($user->resolveStoredTeam())->toBeNull(); + // still ambiguous (2 teams), so must pick again + $user->update(['current_team_id' => $second->id]); + $user->refresh(); + expect($user->resolveStoredTeam()?->id)->toBe($second->id); +}); + +it('persists current_team_id when the active team changes via refreshSession', function () { + [$user, , $second] = userWithTwoTeams(); + $this->actingAs($user); + + refreshSession($second); + + expect($user->fresh()->current_team_id)->toBe($second->id) + ->and(data_get(session('currentTeam'), 'id'))->toBe($second->id); +}); + +it('redirects a multi-team user with no stored team to the select screen', function () { + [$user] = userWithTwoTeams(); + + $this->actingAs($user) + ->get('/') + ->assertRedirect(route('team.select')); +}); + +it('restores the stored team for a returning multi-team user', function () { + [$user, , $second] = userWithTwoTeams(); + $user->update(['current_team_id' => $second->id]); + + $this->actingAs($user)->get('/'); + + expect(data_get(session('currentTeam'), 'id'))->toBe($second->id); +}); + +it('does not send a single-team user to the select screen', function () { + $user = User::factory()->create(); + $only = $user->teams->first(); + $only->update(['show_boarding' => false]); + + // A single-team user who lands on the select screen is bounced straight to + // the dashboard with their team activated, never shown a choice. + $this->actingAs($user) + ->get(route('team.select')) + ->assertRedirect(route('dashboard')); + + expect(data_get(session('currentTeam'), 'id'))->toBe($only->id); +}); + +it('persists the choice and activates the team when selected on the screen', function () { + [$user, , $second] = userWithTwoTeams(); + + Livewire::actingAs($user) + ->test(SelectTeam::class) + ->call('selectTeam', $second->id) + ->assertRedirect(route('dashboard')); + + expect($user->fresh()->current_team_id)->toBe($second->id) + ->and(data_get(session('currentTeam'), 'id'))->toBe($second->id); +}); + +it('lets the livewire update endpoint through for an ambiguous user', function () { + [$user] = userWithTwoTeams(); + + // The selection action runs as a Livewire AJAX POST to /livewire/update. + // The team gate must not hijack that request with a redirect to the + // selection screen, or the click silently does nothing (HTML != JSON). + $response = $this->actingAs($user) + ->withHeaders(['X-Livewire' => 'true']) + ->post('/livewire/update', []); + + expect($response->headers->get('Location'))->not->toBe(route('team.select')); +}); + +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]); + refreshSession($second); + + Livewire::actingAs($user) + ->test(SelectTeam::class) + ->assertRedirect(route('dashboard')); +}); From 2b92fb86b5c7b88a3c202f16ff068a0f3cb773b7 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 25 Aug 2026 13:46:29 +0000 Subject: [PATCH 2/4] 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. --- app/Actions/Team/DeleteTeam.php | 1 + app/Livewire/Team/Member.php | 1 + app/Models/User.php | 15 +++++++++ bootstrap/helpers/shared.php | 7 +++- .../Team/ActiveTeamPersistenceTest.php | 33 +++++++++++++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index be880b7e78..5d0c1e6885 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -50,6 +50,7 @@ class DeleteTeam ->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(); }); diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 38c932c39d..ab3f7938a1 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -89,6 +89,7 @@ class Member extends Component 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}"); diff --git a/app/Models/User.php b/app/Models/User.php index 47f6f2fd40..ab9a817bfe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -401,6 +401,21 @@ class User extends Authenticatable implements SendsEmail 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')) { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 169132cb70..1c19fd1ca6 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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; } diff --git a/tests/Feature/Team/ActiveTeamPersistenceTest.php b/tests/Feature/Team/ActiveTeamPersistenceTest.php index 4dc1570cd1..b481cce314 100644 --- a/tests/Feature/Team/ActiveTeamPersistenceTest.php +++ b/tests/Feature/Team/ActiveTeamPersistenceTest.php @@ -1,5 +1,6 @@ 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]); From f511921895dc0994262312f4704f31ade9c0b491 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 25 Aug 2026 17:25:32 +0000 Subject: [PATCH 3/4] fix(teams): guard current_team_id clear against concurrent writes Make clearStoredTeamIfMatches perform an atomic conditional UPDATE so a concurrent team switch isn't clobbered, and call it for the deleting owner in DeleteTeam so their stored team id doesn't point at a deleted team. refreshSession now falls back to resolveStoredTeam() instead of an arbitrary first team. Add a return type to SelectTeam::render() and tests covering owner deletion and concurrent-selection preservation. --- app/Actions/Team/DeleteTeam.php | 4 ++++ app/Livewire/SelectTeam.php | 6 ++++- app/Models/User.php | 10 ++++++++- bootstrap/helpers/shared.php | 7 ++++-- .../Team/ActiveTeamPersistenceTest.php | 22 +++++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index 5d0c1e6885..bde72448d8 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -54,6 +54,10 @@ class DeleteTeam 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 ba8b2a577f..d0a9328562 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 @@ class SelectTeam extends Component 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 ab9a817bfe..bb810b30fd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -411,8 +411,16 @@ class User extends Authenticatable implements SendsEmail */ 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 1c19fd1ca6..e9d17a4e08 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 b481cce314..4c0329e368 100644 --- a/tests/Feature/Team/ActiveTeamPersistenceTest.php +++ b/tests/Feature/Team/ActiveTeamPersistenceTest.php @@ -161,6 +161,28 @@ it('clears the stored team for members when their team is deleted', function () 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]); From e52390ec03e2c0f2c6d9896cd43d5d33b30e4ca6 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 8 Sep 2026 03:53:35 +0000 Subject: [PATCH 4/4] fix(team): resolve stored team on deletion and impersonation Use resolveStoredTeam() instead of teams()->first() when picking the next active team after a team deletion or when an admin switches into a user's account, so a valid stored preference wins over an arbitrary first team. DeleteTeam now returns null when the deletion leaves the owner with multiple teams, deferring to the selection screen instead of guessing. refreshSession also stops persisting current_team_id while impersonating, so viewing another user's account no longer overwrites their real last-active team. --- app/Actions/Team/DeleteTeam.php | 7 ++- app/Livewire/Admin/Index.php | 4 +- bootstrap/helpers/shared.php | 6 ++- .../Team/ActiveTeamPersistenceTest.php | 48 +++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index bde72448d8..904460d342 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -60,7 +60,12 @@ class DeleteTeam $team->delete(); - return $user->teams()->first(); + // Resolve the next active team the same way login does: the user's + // stored choice when still valid, or their sole remaining team. + // Returns null for a multi-team user whose active team was just + // deleted, so refreshSession sends them to the selection screen + // instead of silently dropping them into an arbitrary first team. + return User::query()->find($user->id)?->resolveStoredTeam(); }); Cache::forget("user:{$user->id}:team:{$team->id}"); diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php index 226d2e3329..f54f40ffd0 100644 --- a/app/Livewire/Admin/Index.php +++ b/app/Livewire/Admin/Index.php @@ -33,7 +33,7 @@ class Index extends Component if (session('impersonating')) { session()->forget('impersonating'); $user = User::find(0); - $team_to_switch_to = $user->teams->first(); + $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first(); Auth::login($user); refreshSession($team_to_switch_to); @@ -69,7 +69,7 @@ class Index extends Component if (! $user) { abort(404); } - $team_to_switch_to = $user->teams->first(); + $team_to_switch_to = $user->resolveStoredTeam() ?? $user->teams->first(); Auth::login($user); refreshSession($team_to_switch_to); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e9d17a4e08..5c9516b799 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -602,9 +602,11 @@ function refreshSession(?Team $team = null): void }); session(['currentTeam' => $team]); - // Persist the active team so it can be restored after logout/login. + // Persist the active team so it can be restored after logout/login — but + // never while an admin is impersonating, so viewing another user's account + // does not overwrite that user's real last-active team. $user = Auth::user(); - if ($user && $user->current_team_id !== $team->id) { + if ($user && ! session('impersonating') && $user->current_team_id !== $team->id) { $user->current_team_id = $team->id; $user->saveQuietly(); } diff --git a/tests/Feature/Team/ActiveTeamPersistenceTest.php b/tests/Feature/Team/ActiveTeamPersistenceTest.php index 4c0329e368..a0cadd5fdb 100644 --- a/tests/Feature/Team/ActiveTeamPersistenceTest.php +++ b/tests/Feature/Team/ActiveTeamPersistenceTest.php @@ -183,6 +183,54 @@ it('preserves a newer team selection when clearing a stale team', function () { expect($user->fresh()->current_team_id)->toBe($personal->id); }); +it('returns the sole remaining team when the deleting owner has one team left', function () { + [$owner, $personal, $shared] = userWithTwoTeams(); + $owner->update(['current_team_id' => $shared->id]); + + $next = app(DeleteTeam::class)->handle($shared->fresh(), $owner); + + expect($next?->id)->toBe($personal->id); +}); + +it('returns null (picker) when the deleting owner still has multiple teams left', function () { + [$owner, , $shared] = userWithTwoTeams(); + $third = Team::factory()->create(['show_boarding' => false]); + $owner->teams()->attach($third, ['role' => 'owner']); + $owner->update(['current_team_id' => $shared->id]); + + // Deleting the active team leaves personal + third: ambiguous, so no team is + // chosen silently and refreshSession(null) routes to the selection screen. + $next = app(DeleteTeam::class)->handle($shared->fresh(), $owner->fresh()); + + expect($next)->toBeNull(); +}); + +it('keeps the active team when the deleted team was not the active one', function () { + [$owner, $personal, $shared] = userWithTwoTeams(); + $third = Team::factory()->create(['show_boarding' => false]); + $owner->teams()->attach($third, ['role' => 'owner']); + $owner->update(['current_team_id' => $personal->id]); + + // Deleting a non-active team must not move the owner off their active team. + $next = app(DeleteTeam::class)->handle($shared->fresh(), $owner->fresh()); + + expect($next?->id)->toBe($personal->id); +}); + +it('does not persist current_team_id while impersonating', function () { + [$user, , $second] = userWithTwoTeams(); + $user->update(['current_team_id' => $second->id]); + $this->actingAs($user); + session(['impersonating' => true]); + + // Viewing a user's account switches the session team but must never + // overwrite that user's stored last-active team. + refreshSession($user->teams->first()); + + expect(data_get(session('currentTeam'), 'id'))->toBe($user->teams->first()->id) + ->and($user->fresh()->current_team_id)->toBe($second->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]);