mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 07:50:35 -05:00
feat(teams): persist active team and add team selection screen (#11503)
This commit is contained in:
@@ -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}");
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user