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.
This commit is contained in:
Aditya Tripathi 2026-08-25 12:45:47 +00:00 committed by Andras Bacsai
parent b81381e85b
commit 12498b4b86
10 changed files with 300 additions and 7 deletions

View file

@ -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';

View file

@ -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())) {

View file

@ -0,0 +1,46 @@
<?php
namespace App\Livewire;
use App\Models\Team;
use Livewire\Component;
class SelectTeam extends Component
{
public function mount()
{
$user = auth()->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');
}
}

View file

@ -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')) {

View file

@ -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;
}

View file

@ -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)
{

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Last active team, restored on login. Nullable: no persisted choice yet.
// Not a foreign key because team ids include the 0 sentinel and teams can
// be deleted out from under a user; validity is checked against the user's
// team membership at read time.
$table->unsignedBigInteger('current_team_id')->nullable()->after('id');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('current_team_id');
});
}
};

View file

@ -0,0 +1,18 @@
<x-auth.shell title="Select a team"
description="Choose the team you want to work in. Your choice is remembered for next time.">
<div class="flex flex-col gap-2">
@foreach ($teams as $team)
<button type="button" wire:click="selectTeam({{ $team->id }})"
class="group flex items-center gap-3 rounded-lg border border-neutral-200 px-3 py-2.5 text-left transition-colors hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.08] dark:hover:border-white/[0.16] dark:hover:bg-white/[0.04]">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-[13px] font-semibold text-neutral-600 dark:bg-white/[0.06] dark:text-fg">
{{ strtoupper(mb_substr($team->name, 0, 1)) }}
</span>
<span class="min-w-0 flex-1 truncate text-[13px] font-semibold text-black dark:text-fg">
{{ $team->name }}
</span>
<x-reicon name="arrow-right" class="size-4 shrink-0 text-neutral-400 dark:text-fg-faint" />
</button>
@endforeach
</div>
</x-auth.shell>

View file

@ -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();

View file

@ -0,0 +1,139 @@
<?php
use App\Livewire\SelectTeam;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => 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'));
});