Repository navigation
fix(auth): sign out every tab when one tab signs out - #1936
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements cross-tab sign-out functionality, ensuring that signing out of the application in one tab automatically signs out all other open tabs. This is managed via a custom localStorage event (Studio:AppSignOut) and a new handler clearAuthStateAfterSignOutElsewhere that clears local tab state without overwriting shared storage. Comprehensive unit and integration tests have been added to validate this behavior. No review comments were provided, so there is no additional feedback.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||
dawsontoth
marked this pull request as ready for review
October 13, 2026 16:03
A tab left open on a cluster page while another tab signed out kept polling central manager on the ended session. Central manager answers a request with no session 403 "Not allowed" (checkUser -> notAllowed(), HarperFast/central-manager#938), which the 401 interceptor ignores and the global query error handler toasts as "ClientError: Not allowed". Nothing told the open tab about the sign-out: Studio's only cross-tab listeners were the API explorer's credential cleanup. signOutAllLocally, the local clear that runs once the shared session has ended (after /Logout/ succeeds, on a 401, after a password change), now rewrites `Studio:AppSignOut`. Every other tab holding the app sign-in follows that storage event with the same clear minus the shared localStorage, so its route guards send it to sign-in before its next poll. The sign-out sweep does not announce: it runs before /Logout/, which can fail, and a tab sent to sign-in that early could sign in again only for that /Logout/ to end the new session. The follower keys off a dedicated key rather than the marker record, because a tab also drops the app sign-in from that record when its check merely fails (a timeout or a 5xx on /User/current). And it writes no localStorage: its copy of the markers is a snapshot from its own page load, and writing it back could undo the other tab's next sign-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `Studio:AppSignOut` announcement carried no identity, so a tab followed any of them while it held an app sign-in. A 401 that ended user U's session in one tab, or U's sign-out reaching a tab after it had signed in as V, signed out a tab holding a live V session. The announcement now names the user whose session ended, and a tab follows only when that is its own app user. The sign-out sweep clears the user before `/Logout/`, so it remembers them for the announcement `signOutAllLocally` makes afterwards. A tab whose sign-in check has not answered yet leaves it to that check. Also sets the installer's flag only once the listener is registered, and drops comments that restated DESIGN.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tab whose app sign-in check was in flight when another tab announced the end of its user's session ignored the announcement, because it had no user to compare yet. If central manager had answered that check just before the `/Logout/`, the tab then signed in on the ended session and its next poll toasted the 403 this branch removes. The tab now remembers the announced user, and if its check comes back as them, asks central manager again. signOutAllLocally now announces from a `finally`, so a throwing step in its teardown, such as an explorer listener, cannot cost the other tabs the announcement after the ended user's identity was already taken. The mounted test removes each test's `storage` listeners afterwards, so none acts on a later test's events, and drops comments that only narrated the next step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth
force-pushed
the
claude/cross-tab-sign-out
branch
from
October 13, 2026 17:02
423d890 to
c25e21b
Compare
This was referenced Oct 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Tab B is open on a cluster page and tab A signs out. Before tab B is reloaded, it shows a "ClientError — Not allowed" toast. Dawson hit this while hand-testing the release candidate on stage, and RUM recorded it: tab A's
POST /Logout/at 13:21:46.4, then tab B'sGET /Cluster/<id>403 at 47.5.Tab B never learns of the sign-out. Studio had no cross-tab sign-out signal, so the tab keeps polling on the ended session. Central manager answers a request with no session 403, not 401 (
checkUser→notAllowed(), HarperFast/central-manager#938). The 401 interceptor ignores that, anderrorHandler(src/react-query/queryClient.ts:94-124) toasts it.This is not a regression from today's merges. Before them, no cross-tab sign-out listener existed.
Solution
When a tab's session ends, every other tab signed in as that user signs out too.
signOutAllLocallyruns once the shared session has ended: after/Logout/succeeds, on a 401, and after a password change. It now writesStudio:AppSignOut, a fresh value that names the ended user.installCrossTabSignOut(installed inmain.tsx) sees thatstorageevent in a tab signed in as that user, it runsclearAuthStateAfterSignOutElsewhere. That is the 401 handler's clear minus the sharedlocalStorage. The route guards then redirect to sign-in, and the cache clear cancels in-flight reads silently./Logout/. A tab whose sign-in check is still out remembers the named user, and asks central manager again if the check comes back as them.Dawson's decision: a password change signs out every tab. The design is recorded in DESIGN.md.
A 403 while signed in still toasts, and a CM 401 still goes to sign-in quietly. Both are pinned by tests.
Not covered:
Verification
toast.error('ClientError', { description: 'Not allowed', id: 'request-forbidden' }). I mutation-checked every guard.tsc -b, oxlint,dprint check.Root cause in detail
:46.413POST /Logout/:46.431/sign-in:47.509GET /Cluster/<id>returns 403:47.546console.error, a side effect of the toast:48.5/sign-inThe same shape happens again at 14:36 UTC. Tab B's request came about 4s after its page load, not on the 10s poll, so it was most likely the refocus refetch (
staleTimeis 1.9s).Central manager side:
src/lib/access.js:13-17:checkUserthrowsnotAllowed()when there is no user.src/resources/cluster/Cluster.js:getandpostcallcheckUserfirst.src/resources/Login.js:165+:Logout.postclears the session'suser.The 403 body is
{code:"ClientError", title:"Not allowed"}, anddescribeErrormaps it to the toast's title and description.Studio's only earlier
storagelisteners were the API explorer's credential cleanup (git log -S"'storage'").Decisions, in full
/Logout/. The first version also announced from the sign-out sweep, before/Logout/. Review showed two problems. A failed/Logout/would sign out tabs whose session is still live. And a tab sent to sign-in that early could be signed back in, only for the pending/Logout/to end the new session. What remains is a window of about half a round trip. A tab-B request that central manager handles between/Logout/and tab A'slogoutOnSuccesscan still toast. The stage trace (+1.1s) is well outside it./Logout/, so it remembers them for the announcement. The announcement is written from afinally.'*'slot belongs to the explorer.localStorage. Their stale markers could undo the other tab's next sign-in. Credentials only a follower knew about can outlive a failed/Logout/, as they already could from the sweep.Tests and mutation checks
crossTabSignOut.test.tsxmounts the realAppRouted, routes, store and query client, and wires the installers asmain.tsxdoes. A fake adapter plays central manager. The "other tab" is a second module graph sharinglocalStorage, and its writes are replayed asstorageevents. Cases:/Logout/fails: stays signed in.appSignOutElsewhere.test.tscovers:signOutHandlers.test.tscovers the handler: it leaveslocalStoragealone and still clears when a step throws.Mutations. I reverted each guard and confirmed a test goes red:
/Logout//Logout/case and 1 unit testfinallysessionStorageclearGates
Node 24.21.0, with real exit codes:
c25e21b81,vitest run --maxWorkers=4exited 0: 527 files, 5,126 passed, 1 expected fail, 11 skipped.tsc -b,oxlint --format stylish .anddprint checkexited 0.--no-verify.editorApi,JobsTabandNewApplication. Each passed alone, and the hook passed on retry.Browser steps for Dawson
addEventListener('storage', (e) => console.log('storage', e.key, e.newValue)).storage Studio:AppSignOut {"user":"<id>",…}after tab A's/Logout/completes, and goes to sign-in without being focused. There is no toast and noGET /Cluster/<id>403.GET /Cluster/<id>returns 403 and the toast appears.Cross-model review
Reviewers were codex
gpt-5.6-sol(graded), gemini, a Cursor lens and Harper domain adjudication. Every round was independent.Round 1 (
e8b3db654). One major: the announcement went out before/Logout/. I moved it.Round 2 (
423d89006). Minor: the announcement carried no identity. I named the ended user and added a test for each trigger. I also moved the install flag after registration and trimmed comments.Closing full round (
9b9cdc738). Converged at minor.finally.signOutAllLocallyinterleaves memory clears with storage writes.Final round on the rebased head
c25e21b81is in progress. The footer carries the closing round forward, and I will update it when that round finishes.🤖 Generated with Claude Code
Related PRs: none found
Review-Coverage: authored=claude; ran=gemini,codex,cursor-muse,cursor-composer; adjudicated=domain; blocked=cursor-grok(failed); declined=cursor-kimi,muse; rounds=3; full=2 @ c25e21b
Review-Attention: deep ~30m (sensitive: clearAuthStateLocally.ts, authStore.ts; decisions: do-less-alternative, cross-tab-signal-vs-403-reprobe, storage-key-vs-broadcastchannel; raised: degraded review) @ c25e21b