From 12498b4b8648d27c4251f8010082620d9c63eada Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Tue, 25 Aug 2026 12:45:47 +0000 Subject: [PATCH] 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 4038fe63e..109f8915a 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -38,6 +38,14 @@ public function callback(string $provider) } 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 dbf261f4d..6babdb69a 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -18,9 +18,24 @@ public function handle(Request $request, Closure $next): Response } 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 000000000..ba8b2a577 --- /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 5b3847396..47f6f2fd4 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 @@ public function currentTeam(): ?Team }); } + /** + * 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 ce16e617d..60d2545a0 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -90,14 +90,19 @@ public function boot(): void } $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 fbfbc9b56..169132cb7 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 000000000..01f53d422 --- /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 000000000..9eb23c19c --- /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 cdf816179..30a9634d4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -51,6 +51,7 @@ 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'])->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 000000000..4dc1570cd --- /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')); +});