Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions apps/web/src/components/widget/widget-auth-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
persistAnonymousToken,
readPersistedToken,
clearPersistedToken,
persistIdentifiedToken,
readPersistedIdentifiedToken,
clearIdentifiedToken,
} from '@/lib/client/widget-auth'
import { sendToHost } from '@/lib/client/widget-bridge'
import { widgetQueryKeys } from '@/lib/client/hooks/use-widget-vote'
Expand Down Expand Up @@ -142,6 +145,31 @@ export function WidgetAuthProvider({

const p = (async (): Promise<boolean> => {
try {
// 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.

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

})
if (res.ok) {
const body = await res.json()
if (body.data?.user) {
storeToken(persistedIdent)
persistIdentifiedToken(persistedIdent)
setUser(body.data.user)
return true
}
// Identified token is stale or demoted — drop it.
clearIdentifiedToken()
}
} catch {
// Server unreachable — fall through.
}
}

// 1. Prefer a persisted anonymous token — validate it server-side
// before adopting (it may have expired or been merged away).
const persisted = readPersistedToken()
Expand Down Expand Up @@ -214,9 +242,11 @@ export function WidgetAuthProvider({
(result: { sessionToken: string; user: WidgetUser; votedPostIds?: string[] }) => {
storeToken(result.sessionToken)
// Any anonymous session was merged into this identified user server-side,
// so drop its persisted token. Identified tokens are never persisted —
// they're re-established via SDK identify / portal passthrough on load.
// so drop its persisted token. Persist the identified token so it survives
// 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.

setUser(result.user)
if (result.votedPostIds) {
queryClient.setQueryData(
Expand Down
75 changes: 68 additions & 7 deletions apps/web/src/components/widget/widget-home-animated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,55 @@ export function WidgetHomeAnimated({
setIsSubmitting(false)
return
} else if (!isIdentified) {
// Before falling back to an anonymous session (which will fail
// submission on workspaces that require identified users), try to
// restore a previously-persisted identified token. This recovers
// from tab switches, focus loss, or browser memory pressure that
// cleared the in-memory token but left localStorage intact.
const { readPersistedIdentifiedToken } = await import(
'@/lib/client/widget-auth'
)
const identToken = readPersistedIdentifiedToken()
if (identToken) {
try {
const res = await fetch('/api/widget/session', {
headers: { Authorization: *** ${identToken}` },
})
if (res.ok) {
const body = await res.json()
if (body.data?.user) {
const { setWidgetToken, persistIdentifiedToken } =
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 👍 / 👎.

// Re-resolve board permissions with the restored identity
const [{ getWidgetAuthHeaders }, { fetchBoardCapabilitiesFn }] =
await Promise.all([
import('@/lib/client/widget-auth'),
import('@/lib/server/functions/portal'),
])
const freshPermissions = await fetchBoardCapabilitiesFn({
headers: getWidgetAuthHeaders(),
})
if (!freshPermissions?.[selectedBoardId]?.canSubmit) {
setError(
intl.formatMessage({
id: 'widget.home.form.noAccess',
defaultMessage:
"You don't have access to post on this board",
})
)
setIsSubmitting(false)
return
}
}
}
} catch {
// Server unreachable — fall through to ensureSession.
}
}

const ok = await ensureSession()
if (!ok) {
setError(
Expand Down Expand Up @@ -513,13 +562,25 @@ export function WidgetHomeAnimated({
})

collapseForm()
} catch {
setError(
intl.formatMessage({
id: 'widget.home.form.errorNetwork',
defaultMessage: 'Network error. Please try again.',
})
)
} catch (err) {
const msg = err instanceof Error ? err.message : ''
if (msg.includes('Anonymous interaction is not enabled') ||
msg.includes('Authentication required')) {
setError(
intl.formatMessage({
id: 'widget.home.form.errorReauthRequired',
defaultMessage:
'Your session has expired. Please sign in again to submit feedback.',
})
)
} else {
setError(
intl.formatMessage({
id: 'widget.home.form.errorNetwork',
defaultMessage: 'Network error. Please try again.',
})
)
}
} finally {
setIsSubmitting(false)
}
Expand Down
75 changes: 74 additions & 1 deletion apps/web/src/lib/client/widget-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
// first-party even when embedded cross-site), so multiple tenants on one host
// page can't collide.
const ANON_TOKEN_KEY_PREFIX = 'quackback:anon-token:'
const IDENT_TOKEN_KEY_PREFIX = 'quackback:ident-token:'
// Mirror Better Auth's 7-day session TTL so an expired token is dropped
// client-side instead of triggering a guaranteed-401 validation round-trip.
const ANON_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
const IDENT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000

function anonTokenStore(): Storage | null {
if (typeof window === 'undefined' || !window.localStorage) return null
Expand All @@ -40,10 +42,11 @@ export function getWidgetToken(): string | null {
return _widgetToken
}

/** Clears the in-memory token AND any persisted anonymous copy. */
/** Clears the in-memory token AND any persisted anonymous AND identified copies. */
export function clearWidgetToken(): void {
_widgetToken = null
clearPersistedToken()
clearIdentifiedToken()
}

/**
Expand Down Expand Up @@ -114,6 +117,76 @@ export function clearPersistedToken(): void {
}
}

/**
* Persist an IDENTIFIED session token to the widget iframe's first-party
* localStorage so it survives page interactions that clear JS memory.
* Stored with a 7-day expiry hint mirroring the server session TTL.
* Only ever call this for tokens that resolve to a non-anonymous user.
*/
export function persistIdentifiedToken(token: string): void {
const store = anonTokenStore()
if (!store) return
try {
store.setItem(
identTokenKey(),
JSON.stringify({ token, expiresAt: Date.now() + IDENT_TOKEN_TTL_MS })
)
} catch {
// Storage disabled or full — fall back to in-memory only.
}
}

/**
* Read a previously persisted identified token. Returns null (and drops
* the entry) when missing, malformed, or past its expiry hint.
*/
export function readPersistedIdentifiedToken(): string | null {
const store = anonTokenStore()
if (!store) return null
const key = identTokenKey()
let raw: string | null
try {
raw = store.getItem(key)
} catch {
return null
}
if (!raw) return null
try {
const parsed = JSON.parse(raw) as { token?: unknown; expiresAt?: unknown }
if (
typeof parsed.token !== 'string' ||
typeof parsed.expiresAt !== 'number' ||
parsed.expiresAt <= Date.now()
) {
store.removeItem(key)
return null
}
return parsed.token
} catch {
try {
store.removeItem(key)
} catch {
// ignore
}
return null
}
}

/** Remove only the persisted identified token, leaving in-memory intact. */
export function clearIdentifiedToken(): void {
const store = anonTokenStore()
if (!store) return
try {
store.removeItem(identTokenKey())
} catch {
// ignore
}
}

function identTokenKey(): string {
return `${IDENT_TOKEN_KEY_PREFIX}${window.location.origin}`
}

export function hasWidgetToken(): boolean {
return _widgetToken !== null
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"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.

"widget.home.similar.heading": "Similar ideas",
"widget.home.popular.heading": "Popular ideas",
"widget.home.popular.search.placeholder": "Search ideas...",
Expand Down