diff --git a/app/Actions/Team/DeleteTeam.php b/app/Actions/Team/DeleteTeam.php index be880b7e78..904460d342 100644 --- a/app/Actions/Team/DeleteTeam.php +++ b/app/Actions/Team/DeleteTeam.php @@ -50,12 +50,22 @@ 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(); }); + // 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(); + // 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/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/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/app/Livewire/SelectTeam.php b/app/Livewire/SelectTeam.php new file mode 100644 index 0000000000..d0a9328562 --- /dev/null +++ b/app/Livewire/SelectTeam.php @@ -0,0 +1,50 @@ +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(): View + { + return view('livewire.select-team', [ + 'teams' => auth()->user()->teams, + ])->layout('layouts.simple'); + } +} 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 5b38473962..bb810b30fd 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,54 @@ 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; + } + + /** + * 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 + { + // 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->current_team_id = 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..5c9516b799 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(); } } @@ -581,8 +584,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; } @@ -593,6 +601,15 @@ function refreshSession(?Team $team = null): void return $team; }); session(['currentTeam' => $team]); + + // 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 && ! session('impersonating') && $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..a0cadd5fdb --- /dev/null +++ b/tests/Feature/Team/ActiveTeamPersistenceTest.php @@ -0,0 +1,242 @@ + 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('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('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('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]); + refreshSession($second); + + Livewire::actingAs($user) + ->test(SelectTeam::class) + ->assertRedirect(route('dashboard')); +});