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:
parent
b81381e85b
commit
12498b4b86
10 changed files with 300 additions and 7 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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())) {
|
||||
|
|
|
|||
46
app/Livewire/SelectTeam.php
Normal file
46
app/Livewire/SelectTeam.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
18
resources/views/livewire/select-team.blade.php
Normal file
18
resources/views/livewire/select-team.blade.php
Normal 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>
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
139
tests/Feature/Team/ActiveTeamPersistenceTest.php
Normal file
139
tests/Feature/Team/ActiveTeamPersistenceTest.php
Normal 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'));
|
||||
});
|
||||
Loading…
Reference in a new issue