fix(widget): persist identified session tokens to survive page disruptions#332
fix(widget): persist identified session tokens to survive page disruptions#332MrFreePress wants to merge 6 commits into
Conversation
…tions Previously, identified widget session tokens were stored only in JS memory and lost on tab switches, focus loss, or browser memory pressure. When the widget then tried to submit feedback, it fell back to an anonymous session, which fails on workspaces with allowAnonymous=false. Three fixes: 1. Persist identified tokens to localStorage alongside anonymous tokens (widget-auth.ts) so they survive page-level disruptions. 2. Restore persisted identified tokens in acquireSession() before minting an anonymous session (widget-auth-provider.tsx), and retry the restore in handleSubmit() before the anonymous fallback (widget-home-animated.tsx). 3. Surface a clearer error message when a submission is rejected due to an expired/missing session, instead of a generic 'Network error' (en.json + widget-home-animated.tsx). Fixes the 'Network error. Please try again.' symptom reported when identified users lose their session between page load and form submission.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9d7acd6590
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (persistedIdent) { | ||
| try { | ||
| const res = await fetch('/api/widget/session', { | ||
| headers: { Authorization: *** ${persistedIdent}` }, |
There was a problem hiding this comment.
Use a valid Bearer header expression
As committed, the new restore request initializes Authorization with *** ${persistedIdent} rather than a string/template literal, so this file cannot be parsed or built; the same malformed expression is also added in the submit recovery path. Both restore fetches need to send a valid Bearer ... header.
Useful? React with 👍 / 👎.
| // page interactions (e.g. the user navigates away and returns to the | ||
| // widget tab) — the server validates it as a Bearer on next use. | ||
| clearPersistedToken() | ||
| persistIdentifiedToken(result.sessionToken) |
There was a problem hiding this comment.
Clear persisted identity when host requests anonymous
Persisting the identified token here makes it survive a reload, but the existing handleAnonymousIdentify() path only sets user to null and does not clear this new localStorage entry; the SDK sends {anonymous:true} by default when init has no identity (packages/widget/src/core/sdk.ts:211-214) or when identify() is called with no args. A previously identified visitor who later loads the host app logged out can therefore have the old token restored by acquireSession()/submit and write as the prior user instead of anonymously, so the identified token should be cleared whenever the host explicitly identifies anonymously.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already addressed — handleAnonymousIdentify() now calls clearWidgetToken() (line 390 of the same commit) which clears the in-memory Bearer token AND both persisted tokens, and also resets sessionReadyRef.current = false to prevent in-flight restores.
| // 0. Try a persisted identified token first — it may have survived a | ||
| // page interaction (tab switch, focus loss, browser memory pressure) | ||
| // that cleared the in-memory token but not localStorage. | ||
| const persistedIdent = readPersistedIdentifiedToken() |
There was a problem hiding this comment.
Start mount restore when only an identity token exists
This identified-token restore only runs from acquireSession(), but the mount restore effect below still returns unless readPersistedToken() finds an anonymous token. In the main new case where SSO persisted only an identified token, the provider never restores it on mount, so sessionVersion stays on the anonymous baseline and permission-gated submit/vote UI can remain disabled or route to login before the submit fallback can repair it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already addressed — the mount useEffect at line 330 now checks both readPersistedToken() AND readPersistedIdentifiedToken() before returning early. This was fixed in commit 8bf8570.
…entified on mount Addresses Codex review findings on PR QuackbackIO#332: P1: handleAnonymousIdentify() now calls clearIdentifiedToken() so a previously-identified visitor who loads the host app logged out cannot have their old token restored and write as the prior user. P2: Mount restore useEffect now also checks readPersistedIdentifiedToken() so sessionVersion and permission-gated UI (submit button, vote) are correct immediately on mount when only an identified token was persisted, not just on first write.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 234f65cedb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (persistedIdent) { | ||
| try { | ||
| const res = await fetch('/api/widget/session', { | ||
| headers: { Authorization: `${persistedIdent}` }, |
There was a problem hiding this comment.
Prefix restored tokens with Bearer
When an identified token exists only in localStorage, this validation request sends Authorization: <token>; getWidgetSession only accepts headers starting with Bearer (apps/web/src/lib/server/functions/widget-auth.ts:47), so the mount/ensure restore gets a 401 and falls through to an anonymous session, leaving identified-only workspaces broken. Fresh evidence: the current diff still sends the raw token in the provider restore path even though the submit fallback now uses Bearer ${identToken}.
Useful? React with 👍 / 👎.
| await import('@/lib/client/widget-auth') | ||
| setWidgetToken(identToken) | ||
| persistIdentifiedToken(identToken) | ||
| setUser(body.data.user) |
There was a problem hiding this comment.
Restore user state through the provider
In the successful persisted-identity submit path, setUser is not in scope here (the auth context exposes user but no setter), so a valid /api/widget/session response throws before the restored identity is applied and the catch proceeds to ensureSession(). This affects users whose in-memory token was lost and makes the new recovery path fall back to anonymous submission instead of reusing their identified token.
Useful? React with 👍 / 👎.
| "widget.home.form.errorEmail": "Could not verify your email. Please try again.", | ||
| "widget.home.form.errorSession": "Could not create session. Please try again.", | ||
| "widget.home.form.errorNetwork": "Network error. Please try again.", | ||
| "widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.", |
There was a problem hiding this comment.
Add the reauth message to all locales
Adding a new English message id without entries in the supported non-English catalogs violates the locale parity test (apps/web/src/locales/__tests__/locale-parity.test.ts) and makes ar/de/es/fr/pt-br/ru/zh-cn/zh-tw widgets show the English fallback for this session-expired error. I checked the locale catalogs, and each supported non-English file is missing widget.home.form.errorReauthRequired.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already addressed — widget.home.form.errorReauthRequired was added to all 8 non-English locale catalogs (ar, de, es, fr, pt-br, ru, zh-cn, zh-tw) in commit 8bf8570.
…e entries Addresses Codex review round 2 on PR QuackbackIO#332: - Add missing 'Bearer ' prefix in acquireSession() identified-token restore path so getWidgetSession() can resolve the token (was sending raw token). - Remove setUser() call from widget-home-animated.tsx submit recovery path — setUser is a WidgetAuthProvider useState, not accessible from the widget-home component. The token store + board permission re-check is sufficient; the auth context picks up the restored identity on next render. - Add widget.home.form.errorReauthRequired entry to ar, de, es, fr, pt-br, ru, zh-cn, zh-tw locale catalogs to satisfy locale-parity test.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8bf85706ad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Clear any persisted identified token so a previously-identified | ||
| // visitor who now loads the host app logged out cannot have their old | ||
| // token restored and write as the prior user. | ||
| clearIdentifiedToken() |
There was a problem hiding this comment.
Clear the active token when honoring anonymous identify
Fresh evidence: this handler now clears the persisted identified token, but it still leaves the in-memory Bearer/session state (and any mount restore already in flight) intact. When the SDK sends its default {anonymous:true} identity after quackback:ready, a persisted identified-token restore may already have called or may still call storeToken(persistedIdent), so later getWidgetAuthHeaders() continues to submit/vote as the previous user even though the host explicitly requested anonymous. Clear/reset the active token/session and prevent the in-flight restore from re-adopting it in this path.
Useful? React with 👍 / 👎.
When the host sends {anonymous:true}, handleAnonymousIdentify() now also
calls clearWidgetToken() (clears in-memory Bearer token + both persisted
tokens) and resets sessionReadyRef. This prevents a race where an
in-flight or completed identified-token restore leaves the previous user's
identity active for subsequent writes, even after the host explicitly
requested anonymous mode.
Problem
When a widget visitor is identified via SSO (e.g. Clerk,
identifyVerification: true), their session token is stored only in JS memory. Any page-level disruption (tab switch, focus loss, browser memory pressure) clears the in-memory token. On form submission, the widget falls back to an anonymous session, which fails on workspaces withallowAnonymous: false— showing a generic "Network error. Please try again." to the user.Fixes: #331
Changes
1. Persist identified tokens to localStorage (
widget-auth.ts)Mirrors the existing anonymous token persistence pattern. Adds
persistIdentifiedToken(),readPersistedIdentifiedToken(), andclearIdentifiedToken()functions with the same 7-day TTL and localStorage namespace pattern as anonymous tokens.2. Restore identified tokens on session acquisition (
widget-auth-provider.tsx)applyIdentifyResult(): now persists the identified token alongside clearing the anonymous oneacquireSession(): triesreadPersistedIdentifiedToken()→ validates against/api/widget/session→ restores user state BEFORE falling through to anonymous minthandleSubmit()inwidget-home-animated.tsx: before falling back toensureSession()(anonymous), attempts to restore a persisted identified token and re-resolve board permissions3. Surface actionable error message (
en.json+widget-home-animated.tsx)Instead of the generic "Network error. Please try again.", the catch block now checks for
"Anonymous interaction is not enabled"and"Authentication required"and shows "Your session has expired. Please sign in again to submit feedback."Files changed
apps/web/src/lib/client/widget-auth.ts— +75 lines (new identified token storage functions)apps/web/src/components/widget/widget-auth-provider.tsx— +34 lines (persist + restore identified tokens)apps/web/src/components/widget/widget-home-animated.tsx— +75 lines (retry identified restore + better error)apps/web/src/locales/en.json— +1 line (new locale key)Related