Skip to content

fix(widget): persist identified session tokens to survive page disruptions#332

Open
MrFreePress wants to merge 6 commits into
QuackbackIO:mainfrom
MrFreePress:fix/widget-identified-session-loss
Open

fix(widget): persist identified session tokens to survive page disruptions#332
MrFreePress wants to merge 6 commits into
QuackbackIO:mainfrom
MrFreePress:fix/widget-identified-session-loss

Conversation

@MrFreePress

Copy link
Copy Markdown

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 with allowAnonymous: 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(), and clearIdentifiedToken() 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 one
  • acquireSession(): tries readPersistedIdentifiedToken() → validates against /api/widget/session → restores user state BEFORE falling through to anonymous mint
  • handleSubmit() in widget-home-animated.tsx: before falling back to ensureSession() (anonymous), attempts to restore a persisted identified token and re-resolve board permissions

3. 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

…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.
@CLAassistant

CLAassistant commented Jul 19, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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}` },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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}` },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Identified widget session tokens lost on page disruption, causing 'Network error' on submit

2 participants