feat(web): session-expiry polish — atomic logout, expiry notices, proactive keepalive, idle warning - #3086
Open
ToddHebebrand wants to merge 7 commits into
Open
feat(web): session-expiry polish — atomic logout, expiry notices, proactive keepalive, idle warning#3086ToddHebebrand wants to merge 7 commits into
ToddHebebrand wants to merge 7 commits into
Conversation
…hWithAuth expiry paths Adds a single idempotent handleSessionExpired(reason) entry point in stores/auth.ts that sets sessionExpiredReason (unpersisted), calls logout(), and redirects via loginPathWithNext() + window.location.replace() unless already on /login. Both fetchWithAuth expiry paths (dead refresh cookie on bootstrap, and a 401 that survives refresh-and-retry) now funnel through it instead of hand-rolling their own logout/redirect, so later work (expiry overlay, login-page notice, idle logout) has one shape to hook into. loginPathWithNext is duplicated locally (not imported) since lib/authScope.ts imports useAuthStore from this module and importing it back would cycle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 gave the auth store a `sessionExpiredReason`; nothing rendered it. An expiring session therefore showed the user a gutted sidebar and blank widgets for the length of the redirect, then dropped them on a bare sign-in form with no explanation of what happened. AuthOverlay now renders a full-screen mask whenever `sessionExpiredReason` is set. The branch is deliberately placed BEFORE the `fadeState === 'hidden'` early return: by the time a session expires the overlay has long since faded out and returns null forever, so a check after that return would never fire. The mask is purely cosmetic — `handleSessionExpired()` already owns the `window.location.replace`, and the existing `!isAuthenticated → redirectToLogin()` effect is left untouched. LoginPage reads `?reason=` the same way it already reads the SSO `?error=` param and renders an informational notice (`session-expired`, `idle`; unknown codes render nothing). When an SSO error notice is also present it wins — its copy is more specific and more actionable — so the two never stack. Three new keys land in all seven locale catalogs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k 3) Threads the hard-vs-transient outcome refreshFetchOnce already computes through requestTokenRefresh/requestTokenRefreshShared instead of collapsing it to Tokens | null. Adds restoreAccessTokenFromCookieDetailed() returning 'restored' | 'auth-failed' | 'transient'; restoreAccessTokenFromCookie() becomes a thin boolean wrapper so its other call sites are untouched. AdminSessionManager's 5-minute heartbeat now reacts to the outcome instead of discarding it: 'restored' stamps lastRefreshAtRef, 'auth-failed' calls handleSessionExpired and stands the heartbeat down, 'transient' does nothing so an offline user isn't logged out and the next 30s tick retries.
The idle budget now ends with a warning modal: the 30s heartbeat raises it at
`idleTimeoutMs - min(2min, idleTimeoutMs / 2)` and a 1s tick drives the visible
m:ss countdown, so the logout lands on the second the countdown shows 0:00
rather than up to a heartbeat later.
While the modal is up, passive signals (mousemove, scroll, focus,
visibilitychange) no longer extend the session — a drifting mouse must not
answer the warning on the user's behalf. Deliberate input (mousedown, keydown,
touchstart) or the "Stay signed in" button dismisses it, marks activity and
refreshes the access token immediately.
On expiry the durable logout is unchanged in order: apiLogout() first (it needs
the Bearer/localStorage state), then handleSessionExpired('idle'), which lands
the user on /login?reason=idle with the inactivity notice. The bare
navigateTo('/login') is gone.
Copy lives in the auth namespace across all seven locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Deduplicate loginPathWithNext: canonical impl now lives in lib/authNext.ts (import-cycle-free), authScope.ts re-exports it so every existing settings-component importer and test mock keeps working unchanged, and stores/auth.ts imports it instead of carrying a byte-identical copy. - AdminSessionManager: reset idleLogoutInFlightRef in the !isAuthenticated cleanup alongside its sibling refs (hardening; currently unreachable since every eviction ends in a full-page location.replace). - IdleWarningDialog: autoFocus on the "Stay signed in" button and aria-describedby wiring to the countdown body paragraph.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
59f881b
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4e8f8096.breeze-9te.pages.dev |
| Branch Preview URL: | https://toddhebebrand-session-timeou.breeze-9te.pages.dev |
…race, log silent catches, comment + test gaps
- apiLogout now aborts its /auth/logout request after 8s (matching
refreshFetchOnce). A hung revoke previously stranded the idle-logout flow:
AdminSessionManager awaits apiLogout() before handleSessionExpired('idle'),
so the modal sat on "Signing you out…" forever and idleLogoutInFlightRef
permanently gated both the heartbeat and the countdown tick.
- AuthOverlay's `!isAuthenticated → redirectToLogin()` branch is now gated on
`!sessionExpiredReason`. handleSessionExpired flips isAuthenticated before its
hard window.location.replace('/login?next=…&reason=…'); the ungated soft nav
raced it and could land the user on a bare /login with no notice/deep link.
- console.warn added to the previously silent catches in apiLogout and
restoreAccessTokenFromCookieDetailed.
- Comment fixes: transient-eviction policy split (foreground fetch evicts,
background heartbeat waits), RefreshOutcome collapse claim, logout()'s
deliberate retention of sessionExpiredReason, IdleWarningDialog's false
"no shared Dialog primitive" claim + signingOut doc, dismissWarning rationale.
- Tests: apiLogout resolves on its own timeout when the request never settles;
warning-lead clamp on a 2-minute budget; hidden-tab keepalive skip with idle
eviction still firing; visibilitychange does not dismiss the warning. The
AuthOverlay "does not navigate" test now exercises the real post-logout state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collaborator
Author
|
Post-open review round (4 parallel reviewers: code quality, test coverage, comment accuracy, silent failures) surfaced two real defects the earlier per-task and whole-branch reviews missed; both fixed in 59f881b:
Also in that commit: |
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.
What
Polishes how the web app handles session death. Previously an expired session gutted the sidebar in place (44/56 nav items are permission-gated) while the redirect to
/login— if it happened at all — raced asynchronously in a different island, with no explanation to the user.Four changes, all in
apps/web:handleSessionExpired(reason)in the auth store: sets a non-persistedsessionExpiredReason, callslogout(), and immediatelywindow.location.replaces to/login?next=…&reason=…(idempotent across concurrent 401s; no-op if already on/login). BothfetchWithAuthgive-up paths now route through it — including the 401-refresh-failed path that previously just mutated state and returned the stale response.AuthOverlayrenders a full-screen "session expired" mask the instant the reason is set, so the gutted UI is never visible.?returnTo=, whichlogin.astronever read. Redirects now useloginPathWithNext()(?next=, validated bygetSafeNext), and the login page shows an informational notice explaining the sign-out (reason=session-expired/reason=idle;?error=SSO copy takes precedence when both resolve).restored/auth-failed/transient: hard auth failure logs out cleanly right away; transient network/5xx failures change nothing (an offline user is never evicted). Refresh internals (retry counts, backoff, Web Locks, rotation/reuse-detection handling) are untouched — the outcome the code already computed is just propagated.min(2 min, budget/2)before the org/partner-configurable idle deadline. Passive activity (mousemove/scroll/focus/visibilitychange) neither extends nor dismisses it; deliberate input (mousedown/keydown/touchstart) or the "Stay signed in" button dismisses it and refreshes the token. At 0:00 it performs the durableapiLogout()(server-side family revocation) and routes throughhandleSessionExpired('idle'), landing on the login page with the inactivity notice.Notes for review
?reason=on/logincarries exactlysession-expired|idlefrom these paths; the pre-existingreason=registration-disabledon the same param is consumed by a disjoint code path and unaffected.session-expired-overlay,login-session-expired-notice,idle-warning-stay,idle-warning-body.Testing
auth.test.ts,AdminSessionManager.test.tsx,AuthOverlay.test.tsx,LoginPage.test.tsx,authNext/authScopetests (114 tests in final run), plus i18n key-parity, literal-key, and no-silent-mutations contract suites.astro check/ eslint clean on touched files.autoFocus+aria-describedby) are fixed in the final commit.🤖 Generated with Claude Code