fix(teams): keep switched team when concurrent requests save the session

Closes #11733
This commit is contained in:
Andras Bacsai
2026-10-16 21:07:00 +02:00
parent 12a8fb83fd
commit 0607705d70
4 changed files with 118 additions and 0 deletions
+3
View File
@@ -110,3 +110,6 @@
## Keep browser form restore away from app state
- Firefox session restore writes saved input values back and fires a trusted `input` event. Add `autocomplete="off"` to inputs that only mirror client state (for example an Alpine-bound color picker), or their handlers run without a user action.
## Session writes are last-writer-wins
- Laravel saves the whole session payload at the end of every request, and pages run concurrent `wire:poll` requests. A value that one request changes can be overwritten by a request that started earlier. `KeepConcurrentTeamSwitch` protects `currentTeam`; protect any new session value that changes mid-session the same way, or keep it out of the session.
+2
View File
@@ -15,6 +15,7 @@ use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTeamMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\KeepConcurrentTeamSwitch;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\RequirePassword;
@@ -72,6 +73,7 @@ class Kernel extends HttpKernel
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
KeepConcurrentTeamSwitch::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Laravel writes the whole session payload at the end of every request. A request
* that started before a team switch (for example a wire:poll update) would write the
* previous team back into the session, and the next request would make it permanent.
* When this request did not change the team itself, keep the team that is stored now.
*/
class KeepConcurrentTeamSwitch
{
public function handle(Request $request, Closure $next): Response
{
$teamIdAtStart = data_get($request->session()->get('currentTeam'), 'id');
$response = $next($request);
$session = $request->session();
if (data_get($session->get('currentTeam'), 'id') !== $teamIdAtStart) {
return $response;
}
$storedSession = clone $session;
$storedSession->start();
$storedTeam = $storedSession->get('currentTeam');
if ($storedTeam && data_get($storedTeam, 'id') !== $teamIdAtStart) {
$session->put('currentTeam', $storedTeam);
}
return $response;
}
}
@@ -0,0 +1,76 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\RouteCollection;
use Illuminate\Support\Facades\Route;
uses(RefreshDatabase::class);
function registerTeamSwitchTestRoutesBeforeCatchAll(array $actions): void
{
$routes = new RouteCollection;
foreach ($actions as $uri => $action) {
$routes->add(Route::middleware(['web', 'auth'])->get($uri, $action));
}
foreach (app('router')->getRoutes() as $route) {
$routes->add($route);
}
app('router')->setRoutes($routes);
}
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->firstTeam = $this->user->teams()->first();
$this->secondTeam = Team::factory()->create();
$this->secondTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->thirdTeam = Team::factory()->create();
$this->thirdTeam->members()->attach($this->user->id, ['role' => 'owner']);
Team::query()->update(['show_boarding' => false]);
$this->user->forceFill(['current_team_id' => $this->firstTeam->id])->saveQuietly();
registerTeamSwitchTestRoutesBeforeCatchAll([
'_test/team-switch-during-request' => function () {
// The team switcher saves the session while this request still runs.
$switchRequestSession = clone session()->driver();
$switchRequestSession->start();
$switchRequestSession->put('currentTeam', $this->secondTeam);
$switchRequestSession->save();
$this->user->forceFill(['current_team_id' => $this->secondTeam->id])->saveQuietly();
return 'ok';
},
'_test/switch-to-second-team' => function () {
$otherRequestSession = clone session()->driver();
$otherRequestSession->start();
$otherRequestSession->put('currentTeam', $this->thirdTeam);
$otherRequestSession->save();
refreshSession($this->secondTeam);
return 'ok';
},
'_test/current-team' => fn () => 'team:'.currentTeam()->id,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->firstTeam]);
});
test('a request that was in flight during a team switch does not restore the previous team', function () {
$this->get('/_test/team-switch-during-request')->assertOk();
$this->get('/_test/current-team')->assertOk()->assertSee('team:'.$this->secondTeam->id);
expect($this->user->fresh()->current_team_id)->toBe($this->secondTeam->id);
});
test('a request that switches the team keeps the team it selected over a concurrent change', function () {
$this->get('/_test/switch-to-second-team')->assertOk();
$this->get('/_test/current-team')->assertOk()->assertSee('team:'.$this->secondTeam->id);
expect($this->user->fresh()->current_team_id)->toBe($this->secondTeam->id);
});