Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
52 changes: 44 additions & 8 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: `Bearer ${persistedIdent}` },
})
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 Expand Up @@ -287,17 +317,17 @@ export function WidgetAuthProvider({
}
}, [portalSessionToken, portalUser, storeToken])

// Restore a persisted anonymous session on mount so a returning visitor's
// conversation is visible immediately, without waiting for a write. Skipped
// when a portal session is present (it takes precedence) or one is already
// ready. Restore-only: never mints — the first write lazily mints if nothing
// valid was restored.
// Restore a persisted session (anonymous or identified) on mount so a
// returning visitor's conversation is visible immediately, without waiting
// for a write. Skipped when a portal session is present (it takes
// precedence) or one is already ready. Restore-only: never mints — the
// first write lazily mints if nothing valid was restored.
const restoreAttemptedRef = useRef(false)
useEffect(() => {
if (restoreAttemptedRef.current) return
if (portalSessionToken || sessionReadyRef.current) return
restoreAttemptedRef.current = true
if (!readPersistedToken()) return
if (!readPersistedToken() && !readPersistedIdentifiedToken()) return
void acquireSession(false)
}, [portalSessionToken, acquireSession])

Expand Down Expand Up @@ -354,6 +384,12 @@ export function WidgetAuthProvider({
async function handleAnonymousIdentify() {
// Don't eagerly create anonymous session — it will be created lazily
// on first write action (vote, comment, post) via ensureSessionThen.
// Clear both the in-memory token and any persisted identified token so
// a previously-identified visitor who now loads the host app logged
// out cannot have their old identity restored by an in-flight or
// completed token restore and continue writing as the prior user.
clearWidgetToken()
sessionReadyRef.current = false
setUser(null)
sendToHost({ type: 'quackback:identify-result', success: true, user: null })
sendToHost({ type: 'quackback:auth-change', user: null })
Expand Down
74 changes: 67 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,54 @@ 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: `Bearer ${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)
// 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 +561,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/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "تعذّر التحقق من بريدك الإلكتروني. يرجى المحاولة مرة أخرى.",
"widget.home.form.errorSession": "تعذّر إنشاء الجلسة. يرجى المحاولة مرة أخرى.",
"widget.home.form.errorNetwork": "خطأ في الشبكة. يرجى المحاولة مرة أخرى.",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "أفكار مشابهة",
"widget.home.popular.heading": "أفكار رائجة",
"widget.home.popular.search.placeholder": "ابحث عن أفكار...",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "Ihre E-Mail-Adresse konnte nicht überprüft werden. Bitte versuchen Sie es erneut.",
"widget.home.form.errorSession": "Sitzung konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"widget.home.form.errorNetwork": "Netzwerkfehler. Bitte versuchen Sie es erneut.",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "Ähnliche Ideen",
"widget.home.popular.heading": "Beliebte Ideen",
"widget.home.popular.search.placeholder": "Ideen suchen...",
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
1 change: 1 addition & 0 deletions apps/web/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "No se pudo verificar tu correo electrónico. Inténtalo de nuevo.",
"widget.home.form.errorSession": "No se pudo crear la sesión. Inténtalo de nuevo.",
"widget.home.form.errorNetwork": "Error de red. Inténtalo de nuevo.",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "Ideas similares",
"widget.home.popular.heading": "Ideas populares",
"widget.home.popular.search.placeholder": "Buscar ideas...",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "Impossible de vérifier votre adresse e-mail. Veuillez réessayer.",
"widget.home.form.errorSession": "Impossible de créer la session. Veuillez réessayer.",
"widget.home.form.errorNetwork": "Erreur réseau. Veuillez réessayer.",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "Idées similaires",
"widget.home.popular.heading": "Idées populaires",
"widget.home.popular.search.placeholder": "Rechercher des idées...",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/pt-br.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "Não foi possível verificar seu email. Tente novamente.",
"widget.home.form.errorSession": "Não foi possível criar a sessão. Tente novamente.",
"widget.home.form.errorNetwork": "Erro de rede. Tente novamente.",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "Ideias semelhantes",
"widget.home.popular.heading": "Ideias populares",
"widget.home.popular.search.placeholder": "Pesquisar ideias...",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "Не удалось проверить email. Попробуйте еще раз.",
"widget.home.form.errorSession": "Не удалось продолжить. Попробуйте еще раз.",
"widget.home.form.errorNetwork": "Ошибка сети. Попробуйте снова.",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "Похожие предложения",
"widget.home.popular.heading": "Популярные предложения",
"widget.home.popular.search.placeholder": "Поиск предложений...",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "无法验证你的邮箱。请重试。",
"widget.home.form.errorSession": "无法创建会话。请重试。",
"widget.home.form.errorNetwork": "网络错误。请重试。",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "相似想法",
"widget.home.popular.heading": "热门想法",
"widget.home.popular.search.placeholder": "搜索想法…",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"widget.home.form.errorEmail": "無法驗證你的電子郵件。請再試一次。",
"widget.home.form.errorSession": "無法建立工作階段。請再試一次。",
"widget.home.form.errorNetwork": "網路錯誤。請再試一次。",
"widget.home.form.errorReauthRequired": "Your session has expired. Please sign in again to submit feedback.",
"widget.home.similar.heading": "相似的想法",
"widget.home.popular.heading": "熱門想法",
"widget.home.popular.search.placeholder": "搜尋想法...",
Expand Down