diff --git a/apps/web/src/components/widget/widget-auth-provider.tsx b/apps/web/src/components/widget/widget-auth-provider.tsx index 197885362..0ea381222 100644 --- a/apps/web/src/components/widget/widget-auth-provider.tsx +++ b/apps/web/src/components/widget/widget-auth-provider.tsx @@ -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' @@ -142,6 +145,31 @@ export function WidgetAuthProvider({ const p = (async (): Promise => { 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() + 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() @@ -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) setUser(result.user) if (result.votedPostIds) { queryClient.setQueryData( @@ -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]) @@ -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 }) diff --git a/apps/web/src/components/widget/widget-home-animated.tsx b/apps/web/src/components/widget/widget-home-animated.tsx index 7f9e73e64..00f4298b6 100644 --- a/apps/web/src/components/widget/widget-home-animated.tsx +++ b/apps/web/src/components/widget/widget-home-animated.tsx @@ -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( @@ -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) } diff --git a/apps/web/src/lib/client/widget-auth.ts b/apps/web/src/lib/client/widget-auth.ts index 870d30644..61dcf08c3 100644 --- a/apps/web/src/lib/client/widget-auth.ts +++ b/apps/web/src/lib/client/widget-auth.ts @@ -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 @@ -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() } /** @@ -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 } diff --git a/apps/web/src/locales/ar.json b/apps/web/src/locales/ar.json index 2d2c63d62..53a9bf5bd 100644 --- a/apps/web/src/locales/ar.json +++ b/apps/web/src/locales/ar.json @@ -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": "ابحث عن أفكار...", diff --git a/apps/web/src/locales/de.json b/apps/web/src/locales/de.json index 4aaa8b91e..d23befd66 100644 --- a/apps/web/src/locales/de.json +++ b/apps/web/src/locales/de.json @@ -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...", diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index bffa98869..b0b96de73 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -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.", "widget.home.similar.heading": "Similar ideas", "widget.home.popular.heading": "Popular ideas", "widget.home.popular.search.placeholder": "Search ideas...", diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 794ab7a7a..8f89cb983 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -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...", diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index 1ff609f24..c95ef11a3 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -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...", diff --git a/apps/web/src/locales/pt-br.json b/apps/web/src/locales/pt-br.json index 6223bf957..304e8abf8 100644 --- a/apps/web/src/locales/pt-br.json +++ b/apps/web/src/locales/pt-br.json @@ -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...", diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json index cea410d43..3f36197f7 100644 --- a/apps/web/src/locales/ru.json +++ b/apps/web/src/locales/ru.json @@ -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": "Поиск предложений...", diff --git a/apps/web/src/locales/zh-cn.json b/apps/web/src/locales/zh-cn.json index fc2b07da7..10c860711 100644 --- a/apps/web/src/locales/zh-cn.json +++ b/apps/web/src/locales/zh-cn.json @@ -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": "搜索想法…", diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json index 05208b7cb..6054ec68b 100644 --- a/apps/web/src/locales/zh-tw.json +++ b/apps/web/src/locales/zh-tw.json @@ -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": "搜尋想法...",