feat(teams): persist active team and add team selection screen (#11503)

This commit is contained in:
Andras Bacsai
2026-09-08 14:58:58 +02:00
committed by GitHub
13 changed files with 457 additions and 13 deletions
+11 -1
View File
@@ -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}");
+8
View File
@@ -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';
+17 -2
View File
@@ -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())) {
+2 -2
View File
@@ -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);
+50
View File
@@ -0,0 +1,50 @@
<?php
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();
// 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');
}
}
+1
View File
@@ -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}");
+50
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,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')) {
+10 -5
View File
@@ -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;
}
+20 -3
View File
@@ -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)
{
@@ -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');
});
}
};
@@ -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>
+2
View File
@@ -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();
@@ -0,0 +1,242 @@
<?php
use App\Actions\Team\DeleteTeam;
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('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'));
});