From 79e5561f4a62d02f5f66235a2a939e8c559e8630 Mon Sep 17 00:00:00 2001 From: VelvetAbyss Date: Mon, 9 Nov 2026 16:51:31 +0800 Subject: [PATCH 1/2] fix(sync): recover sessions and pause expired authentication safely --- apps/web/e2e/auth-sync.spec.ts | 46 +++++++++ apps/web/focus-go-api/auth/betterAuth.js | 3 +- .../focus-go-api/auth/sessionRenewal.test.js | 33 +++++++ .../test/fixtures/sync-auth-browser-server.js | 2 + .../routes/SettingsRoute.theme-pack.test.tsx | 25 +++++ apps/web/src/app/routes/SettingsRoute.tsx | 12 ++- apps/web/src/config/authClient.ts | 4 +- apps/web/src/data/sync/auth.test.ts | 93 +++++++++++++++++++ apps/web/src/data/sync/auth.ts | 73 +++++++++++++++ apps/web/src/data/sync/client.test.ts | 2 +- apps/web/src/data/sync/client.ts | 5 +- apps/web/src/data/sync/rxdb.test.ts | 11 +++ apps/web/src/data/sync/rxdb.ts | 20 ++-- apps/web/src/data/sync/service.test.tsx | 33 ++++++- apps/web/src/data/sync/service.tsx | 59 +++++++++--- apps/web/src/data/sync/stream.test.ts | 20 ++++ apps/web/src/data/sync/stream.ts | 4 +- apps/web/src/features/symbolic/sync.test.ts | 25 ++++- apps/web/src/features/symbolic/sync.ts | 9 +- apps/web/src/shared/i18n/messages/en.ts | 3 + apps/web/src/shared/i18n/messages/zh.ts | 3 + apps/web/src/shared/i18n/types.ts | 3 + 22 files changed, 452 insertions(+), 36 deletions(-) create mode 100644 apps/web/focus-go-api/auth/sessionRenewal.test.js create mode 100644 apps/web/src/data/sync/auth.test.ts create mode 100644 apps/web/src/data/sync/auth.ts diff --git a/apps/web/e2e/auth-sync.spec.ts b/apps/web/e2e/auth-sync.spec.ts index 20f96ce1..130b37f9 100644 --- a/apps/web/e2e/auth-sync.spec.ts +++ b/apps/web/e2e/auth-sync.spec.ts @@ -589,3 +589,49 @@ test('data safety: CRM customers, deals and follow-up tasks sync between two sig await contextB.close() } }) + +test('data safety: expired session pauses once and same-account sign-in uploads preserved edits', async ({ browser }) => { + test.setTimeout(150_000) + const credentials = { email: `expiry-${crypto.randomUUID()}@example.test`, password: 'test-only-password-1234' } + const cookie = await authRequest('sign-up/email', { ...credentials, name: 'Session Recovery Test' }) + const context = await browser.newContext({ serviceWorkers: 'block' }) + try { + await context.addCookies([cookie]) + await context.addInitScript(() => localStorage.setItem('workbench.ui.language', 'en')) + const page = await context.newPage() + await page.goto('/workspace/settings?section=data') + await expect(page.getByTestId('sync-safety-status')).toContainText('Cloud confirmed', { timeout: 45000 }) + await page.getByRole('switch', { name: 'Toggle cloud sync' }).uncheck() + const task = await page.evaluate(async () => { + const { createDexieDatabaseService } = await import('/src/data/services/DexieDatabaseService.ts') + return createDexieDatabaseService().tasks.add({ title: 'Preserve this edit across session expiry', description: 'Not uploaded yet', status: 'todo', priority: null, tags: [], subtasks: [] }) + }) + // Revoke the isolated server session without invoking client logout or data clearing. + await page.evaluate(async () => { await (await import('/src/config/authClient.ts')).authClient.signOut() }) + let syncRequests = 0 + page.on('request', request => { if (request.url().includes('/sync/rxdb/')) syncRequests++ }) + await page.getByRole('switch', { name: 'Toggle cloud sync' }).check() + await expect(page.getByRole('button', { name: 'Sign in again', exact: true })).toBeVisible({ timeout: 45000 }) + await expect(page.getByText('Your session has expired. Sync is paused and local changes are preserved. Sign in to the same account to resume.', { exact: true })).toBeVisible() + expect(await page.evaluate(async id => (await (await import('/src/data/db/index.ts')).db.tasks.get(id))?.title, task.id)).toBe(task.title) + await expect.poll(() => page.evaluate(async () => (await import('/src/data/db/index.ts')).db.pendingSyncChanges.count())).toBeGreaterThan(0) + const stoppedAt = syncRequests + await page.evaluate(() => { window.dispatchEvent(new Event('online')); window.dispatchEvent(new Event('focus')) }) + await page.waitForTimeout(1500) + expect(syncRequests).toBe(stoppedAt) + await page.getByRole('button', { name: 'Sign in again', exact: true }).click() + const login = page.locator('.login-modal') + await login.locator('input[autocomplete="username"]').fill(credentials.email) + await login.locator('input[autocomplete="current-password"]').fill(credentials.password) + await login.locator('button[type="submit"]').click() + await expect(login).not.toBeVisible() + await expect(page.getByTestId('sync-safety-status')).toContainText('Cloud confirmed', { timeout: 45000 }) + expect(await page.evaluate(async id => (await (await import('/src/data/db/index.ts')).db.tasks.get(id))?.title, task.id)).toBe(task.title) + const cloudHasTask = await page.evaluate(async id => { + const { syncApi } = await import('/src/data/sync/client.ts') + const result = await syncApi.rxdbPull({ entityType: 'tasks', checkpoint: null, limit: 100 }) + return result.documents.some(row => row.id === id) + }, task.id) + expect(cloudHasTask).toBe(true) + } finally { await context.close() } +}) diff --git a/apps/web/focus-go-api/auth/betterAuth.js b/apps/web/focus-go-api/auth/betterAuth.js index d07333b4..f4baef86 100644 --- a/apps/web/focus-go-api/auth/betterAuth.js +++ b/apps/web/focus-go-api/auth/betterAuth.js @@ -1,5 +1,5 @@ import { betterAuth } from 'better-auth' -import { username } from 'better-auth/plugins' +import { bearer, username } from 'better-auth/plugins' import dotenv from 'dotenv' import { dirname, join } from 'path' import { fileURLToPath } from 'url' @@ -62,6 +62,7 @@ export const auth = betterAuth({ }, socialProviders: googleProvider, plugins: [ + bearer(), username({ minUsernameLength: 3, maxUsernameLength: 30, diff --git a/apps/web/focus-go-api/auth/sessionRenewal.test.js b/apps/web/focus-go-api/auth/sessionRenewal.test.js new file mode 100644 index 00000000..b8366b02 --- /dev/null +++ b/apps/web/focus-go-api/auth/sessionRenewal.test.js @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import Database from 'better-sqlite3' +import { betterAuth } from 'better-auth' +import { bearer } from 'better-auth/plugins' +import { getMigrations } from 'better-auth/db/migration' + +test('bearer session renewal extends active sessions but never revives expired or revoked sessions', async () => { + const database = new Database(':memory:') + const options = { + database, + secret: 'isolated-session-renewal-test-secret-32chars', + baseURL: 'http://localhost:3000', + emailAndPassword: { enabled: true }, + plugins: [bearer()], + session: { expiresIn: 3600, updateAge: 60 }, + } + try { + await (await getMigrations(options)).runMigrations() + const auth = betterAuth(options) + const signup = await auth.api.signUpEmail({ body: { email: 'renewal@example.test', password: 'test-password-12345', name: 'Test' } }) + const token = signup.token + const soon = new Date(Date.now() + 30_000) + database.prepare('UPDATE session SET expiresAt = ? WHERE token = ?').run(soon.toISOString(), token) + const headers = new Headers({ authorization: `Bearer ${token}` }) + const renewed = await auth.api.getSession({ headers }) + assert.equal(renewed.session.token, token) + assert.ok(new Date(renewed.session.expiresAt).getTime() > Date.now() + 3_000_000) + database.prepare('UPDATE session SET expiresAt = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), token) + assert.equal(await auth.api.getSession({ headers }), null) + assert.equal(await auth.api.getSession({ headers: new Headers({ authorization: 'Bearer revoked-token' }) }), null) + } finally { database.close() } +}) diff --git a/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js b/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js index c850b9b7..c476e032 100644 --- a/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js +++ b/apps/web/focus-go-api/test/fixtures/sync-auth-browser-server.js @@ -2,6 +2,7 @@ import express from 'express' import cors from 'cors' import Database from 'better-sqlite3' import { betterAuth } from 'better-auth' +import { bearer } from 'better-auth/plugins' import { toNodeHandler } from 'better-auth/node' import { createSyncRouter } from '../../routes/sync.js' import { SYNC_TABLES } from '../../sync/config.js' @@ -71,6 +72,7 @@ app.use('/sync', createSyncRouter({ database, authMiddleware: requireTestAccount const server = app.listen(0, '127.0.0.1', () => { const port = server.address().port auth = betterAuth({ + plugins: [bearer()], database, secret: 'focusgo-browser-sync-test-secret-32chars', baseURL: `http://127.0.0.1:${port}/api/auth`, diff --git a/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx b/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx index 2160f6f7..17dcb098 100644 --- a/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx +++ b/apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx @@ -8,6 +8,11 @@ import SettingsRoute from './SettingsRoute' import { THEME_BEFORE_MODE_TOGGLE_EVENT, clearThemePackPreview, getThemePalette } from '../../shared/theme/themePack' import { applyTheme, writeStoredThemePreference } from '../../shared/theme/theme' +const syncMocks = vi.hoisted(() => ({ expired: false, sync: vi.fn() })) +vi.mock('../../data/sync/auth', () => ({ useSyncAuthRequired: () => syncMocks.expired })) +vi.mock('../../data/sync/service', () => ({ useSyncActions: () => ({ enabled: true, setEnabled: vi.fn(), syncNow: syncMocks.sync }), useSyncStatus: () => syncMocks.expired ? ({ status: 'error', lastError: 'internal error repeated', lastSyncedAt: null }) : null })) +vi.mock('../layout/LoginModal', () => ({ default: () =>
})) + const mockDashboardGet = vi.fn() const mockDashboardUpsert = vi.fn() @@ -23,6 +28,8 @@ vi.mock('../../data/repositories/dashboardRepo', () => ({ }, })) +vi.mock('../../data/sync/conflicts', () => ({ listSyncConflicts: async () => [], markSyncConflictReviewed: vi.fn(), restoreSyncConflictAsCopy: vi.fn() })) + vi.mock('../../data/db', () => ({ db: { delete: vi.fn(), @@ -98,6 +105,7 @@ const chooseThemePack = async (optionText: string) => { describe('SettingsRoute theme pack preview', () => { beforeEach(() => { + syncMocks.expired = false window.localStorage.clear() applyTheme('light') globalThis.ResizeObserver = class ResizeObserver { @@ -131,6 +139,23 @@ describe('SettingsRoute theme pack preview', () => { vi.clearAllMocks() }) + it('replaces repeated auth errors with a safe re-login action', async () => { + const { setAuth } = await import('../../store/auth') + setAuth({ accessToken: 'expired-test', user: { id: 'same-account' } }) + syncMocks.expired = true + const user = userEvent.setup() + renderRoute() + try { + await user.click(screen.getByRole('tab', { name: /settings.module.data.title/ })) + expect(await screen.findByText('settings.data.sync.authRequired')).toBeVisible() + expect(screen.getByText('settings.data.sync.authStatus')).toBeVisible() + expect(screen.queryByText('internal error repeated')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'settings.data.sync.signInAgain' })) + expect(await screen.findByRole('dialog', { name: 'Reauthenticate' })).toBeVisible() + expect(syncMocks.sync).not.toHaveBeenCalled() + } finally { setAuth(null) } + }) + it('previews selected theme pack immediately and does not persist it', async () => { renderRoute() await chooseThemePack('settings.themePack.option.b') diff --git a/apps/web/src/app/routes/SettingsRoute.tsx b/apps/web/src/app/routes/SettingsRoute.tsx index 9010186f..1ef4ebff 100644 --- a/apps/web/src/app/routes/SettingsRoute.tsx +++ b/apps/web/src/app/routes/SettingsRoute.tsx @@ -1,4 +1,5 @@ import AnonymousCountsSetting from '../../shared/metrics/AnonymousCountsSetting' +import { useSyncAuthRequired } from '../../data/sync/auth' import { LEGAL_DOCUMENTS, policyUrl, type LegalDocument, type LegalDocumentKey } from '../../shared/legal/policies' import { Suspense, lazy, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react' import { motion } from 'motion/react' @@ -597,6 +598,7 @@ const SettingsRoute = () => { const { requireAuth, isGated } = useAuthGate() const isLoggedIn = useIsLoggedIn() const [showLoginModal, setShowLoginModal] = useState(false) + const syncAuthRequired = useSyncAuthRequired() const cloudSyncQuota = useCloudSyncQuota() const toast = useToast() const syncState = useSyncStatus() @@ -1685,7 +1687,7 @@ const SettingsRoute = () => { aria-label={t('settings.data.sync.toggle')} /> -
{syncStatusLabel}
+
{syncAuthRequired ? t('settings.data.sync.authStatus') : syncStatusLabel}
{lastSyncedLabel}
{cloudSyncQuota ?
{t('settings.data.sync.quota', { used: (cloudSyncQuota.usedBytes / 1024 / 1024).toFixed(1), limit: (cloudSyncQuota.limitBytes / 1024 / 1024).toFixed(0) })}
: null} {cloudSyncEnabled && syncState?.status === 'blocked' && syncConflicts.some((item) => item.accountId === getAuth()?.user?.id && !item.resolvedAt) ? ( @@ -1694,15 +1696,15 @@ const SettingsRoute = () => {
) : cloudSyncEnabled && syncState?.status === 'error' && syncState.lastError ? (
- {t('settings.data.sync.error', { message: syncState.lastError })} + {syncAuthRequired ? t('settings.data.sync.authRequired') : t('settings.data.sync.error', { message: syncState.lastError })}
) : null} )} diff --git a/apps/web/src/config/authClient.ts b/apps/web/src/config/authClient.ts index 93810c68..b37d7dae 100644 --- a/apps/web/src/config/authClient.ts +++ b/apps/web/src/config/authClient.ts @@ -75,7 +75,7 @@ const requestAuth = async (path: string, init?: RequestInit): Promise => { const payload = parseAuthPayload(text, response.headers.get('content-type')) if (!response.ok) { const error = payload as AuthErrorPayload | null - throw new Error(normalizeAuthErrorMessage(error, `Auth request failed (${response.status})`)) + throw Object.assign(new Error(normalizeAuthErrorMessage(error, `Auth request failed (${response.status})`)), { status: response.status }) } return payload as T } @@ -120,7 +120,7 @@ export const authClient = { body: JSON.stringify({ code, state, verifier }), }), - getSession: () => requestAuth('/get-session'), + getSession: (token?: string) => requestAuth('/get-session', { signal: AbortSignal.timeout(10_000), ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}) }), signOut: () => requestAuth('/sign-out', { diff --git a/apps/web/src/data/sync/auth.test.ts b/apps/web/src/data/sync/auth.test.ts new file mode 100644 index 00000000..b4dc50a2 --- /dev/null +++ b/apps/web/src/data/sync/auth.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), fetch: vi.fn(), save: vi.fn(), auth: { accessToken: 'old', user: { id: 'a' } } as { accessToken?: string; user: { id: string } } })) +vi.mock('../../config/authClient', () => ({ authClient: { getSession: mocks.session } })) +vi.mock('../../store/auth', () => ({ getAuth: () => mocks.auth, setAuth: (value: typeof mocks.auth) => { mocks.auth = value }, subscribeAuth: () => () => {} })) +vi.mock('../../shared/apiBase', () => ({ fetchApi: mocks.fetch })) +vi.mock('../../platform', () => ({ getPlatform: () => ({ saveAuthToken: mocks.save }) })) +const session = (token = 'old') => ({ session: { token }, user: { id: 'auth-a' } }) +beforeEach(() => { + vi.resetModules() + mocks.auth = { accessToken: 'old', user: { id: 'a' } } + mocks.session.mockReset().mockResolvedValue(session()) + mocks.fetch.mockReset().mockResolvedValue({ ok: true, json: async () => ({ id: 'a' }) }) + mocks.save.mockReset().mockResolvedValue(undefined) +}) + +describe('sync session recovery', () => { + it('keeps an offline account hint without a token out of the expired state', async () => { + mocks.auth = { user: { id: 'a' } } + const online = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) + try { + const { ensureSyncSession, isSyncAuthRequired, SyncAuthError } = await import('./auth') + const error = await ensureSyncSession().catch(error => error) + expect(error).not.toBeInstanceOf(SyncAuthError) + expect(error.message).toContain('offline') + expect(isSyncAuthRequired()).toBe(false) + expect(mocks.session).not.toHaveBeenCalled() + } finally { online.mockRestore() } + }) + it('recovers an online account hint from its cookie without needing a token', async () => { + mocks.auth = { user: { id: 'a' } } + const { ensureSyncSession } = await import('./auth') + await ensureSyncSession() + expect(mocks.auth.accessToken).toBe('old') + expect(mocks.session).toHaveBeenCalledTimes(1) + }) + it('deduplicates concurrent renewal and throttles successful checks', async () => { + const { ensureSyncSession } = await import('./auth') + await Promise.all([ensureSyncSession(), ensureSyncSession(), ensureSyncSession()]) + await ensureSyncSession() + expect(mocks.session).toHaveBeenCalledTimes(1) + await ensureSyncSession(true) + expect(mocks.session).toHaveBeenCalledTimes(2) + }) + it('recovers a missing cookie with a bearer session and persists the renewed token', async () => { + mocks.session.mockResolvedValueOnce(null).mockResolvedValueOnce(session('renewed')) + const { ensureSyncSession } = await import('./auth') + await ensureSyncSession() + expect(mocks.session.mock.calls).toEqual([[], ['old']]) + expect(mocks.auth.accessToken).toBe('renewed') + expect(mocks.save).toHaveBeenCalledWith('renewed') + }) + it('does not adopt another account from cookies', async () => { + mocks.session.mockResolvedValue(session('other-token')) + mocks.fetch.mockResolvedValue({ ok: true, json: async () => ({ id: 'b' }) }) + const { ensureSyncSession, SyncAuthError } = await import('./auth') + await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError) + expect(mocks.auth).toEqual({ accessToken: 'old', user: { id: 'a' } }) + expect(mocks.save).not.toHaveBeenCalled() + }) + it('preserves identity when expired and resumes only after a new token', async () => { + mocks.session.mockResolvedValue(null) + const { ensureSyncSession, SyncAuthError, pauseSyncForAuth, isSyncAuthRequired } = await import('./auth') + await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError) + pauseSyncForAuth() + expect(isSyncAuthRequired()).toBe(true) + await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError) + expect(mocks.session).toHaveBeenCalledTimes(2) + expect(mocks.auth.user.id).toBe('a') + mocks.auth = { ...mocks.auth, accessToken: 'new-login' } + expect(isSyncAuthRequired()).toBe(false) + }) + it('does not treat a network failure as expiration', async () => { + mocks.session.mockRejectedValue(new TypeError('Failed to fetch')) + const { ensureSyncSession, SyncAuthError, isSyncAuthRequired } = await import('./auth') + const error = await ensureSyncSession().catch(error => error) + expect(error).not.toBeInstanceOf(SyncAuthError) + expect(isSyncAuthRequired()).toBe(false) + expect(mocks.auth.accessToken).toBe('old') + }) + it('discards renewal after another login completes', async () => { + let finish!: (value: ReturnType) => void + mocks.session.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const { ensureSyncSession } = await import('./auth') + const pending = ensureSyncSession() + mocks.auth = { accessToken: 'new-login', user: { id: 'b' } } + finish(session('renewed')) + await expect(pending).rejects.toThrow('Sync account changed') + expect(mocks.fetch).not.toHaveBeenCalled() + expect(mocks.auth.accessToken).toBe('new-login') + }) +}) diff --git a/apps/web/src/data/sync/auth.ts b/apps/web/src/data/sync/auth.ts new file mode 100644 index 00000000..84135519 --- /dev/null +++ b/apps/web/src/data/sync/auth.ts @@ -0,0 +1,73 @@ +import { useSyncExternalStore } from 'react' +import { authClient } from '../../config/authClient' +import { getAuth, setAuth, subscribeAuth } from '../../store/auth' +import { getPlatform } from '../../platform' +import { fetchApi } from '../../shared/apiBase' + +export const SYNC_AUTH_REQUIRED = 'Sync paused: please sign in again. Local changes are preserved.' +export class SyncAuthError extends Error { + constructor() { super(SYNC_AUTH_REQUIRED); this.name = 'SyncAuthError' } +} +export const isSyncAuthError = (error: unknown): boolean => { + if (error instanceof SyncAuthError) return true + const wrapped = error as { message?: string; parameters?: { errors?: unknown[] } } | null + return wrapped?.message === SYNC_AUTH_REQUIRED || Boolean(wrapped?.parameters?.errors?.some(isSyncAuthError)) +} + +let blocked: { token: string | undefined; accountId: string } | undefined +let lastCheckedToken: string | undefined +let lastCheckedAt = 0 +let pending: Promise | undefined +const listeners = new Set<() => void>() +const notify = () => listeners.forEach(listener => listener()) +export const isSyncAuthRequired = () => Boolean(blocked && getAuth()?.user?.id === blocked.accountId && getAuth()?.accessToken === blocked.token) +export const pauseSyncForAuth = () => { + const auth = getAuth() + blocked = auth?.user?.id ? { token: auth.accessToken, accountId: auth.user.id } : undefined + notify() +} +export const useSyncAuthRequired = () => useSyncExternalStore(listener => { + listeners.add(listener) + const unsubscribe = subscribeAuth(listener) + return () => { listeners.delete(listener); unsubscribe() } +}, isSyncAuthRequired, () => false) + +// Recovery runs between cycles, never halfway through applying a response. A +// different cookie account must not claim this device's pending local changes. +export const ensureSyncSession = async (force = false) => { + if (isSyncAuthRequired()) throw new SyncAuthError() + if (typeof navigator !== 'undefined' && navigator.onLine === false) throw new Error('Sync paused while offline') + const original = getAuth() + const token = original?.accessToken as string | undefined + const accountId = original?.user?.id + if (!accountId) throw new SyncAuthError() + if (!force && token && lastCheckedToken === token && Date.now() - lastCheckedAt < 300_000) return + if (pending) return pending + const check = async () => { + let session = await authClient.getSession() + // Bearer fallback also supports platforms where cookies are unavailable. + // The auth library validates AND renews it; revoked/expired tokens stay invalid. + if (!session?.session?.token && token) session = await authClient.getSession(token) + if (getAuth()?.accessToken !== token || getAuth()?.user?.id !== accountId) throw new Error('Sync account changed') + const renewedToken = session?.session?.token + if (!renewedToken || !session?.user) throw new SyncAuthError() + const response = await fetchApi('/user/profile', { signal: AbortSignal.timeout(10_000), headers: { Authorization: `Bearer ${renewedToken}` } }) + if (response.status === 401 || response.status === 403) throw new SyncAuthError() + if (!response.ok) throw new Error('Unable to check sync session. Please retry.') + const profile = await response.json() + if (getAuth()?.accessToken !== token || getAuth()?.user?.id !== accountId) throw new Error('Sync account changed') + if (String(profile.id) !== String(accountId)) throw new SyncAuthError() + lastCheckedToken = renewedToken + lastCheckedAt = Date.now() + if (renewedToken !== token) { + setAuth({ ...original, accessToken: renewedToken }) + await getPlatform().saveAuthToken(renewedToken) + } + } + pending = check().catch(error => { + if (getAuth()?.accessToken !== token || getAuth()?.user?.id !== accountId) throw new Error('Sync account changed') + if (error?.status === 401 || error?.status === 403) throw new SyncAuthError() + throw error + }).finally(() => { pending = undefined }) + return pending +} diff --git a/apps/web/src/data/sync/client.test.ts b/apps/web/src/data/sync/client.test.ts index cef0d86c..4c0a797a 100644 --- a/apps/web/src/data/sync/client.test.ts +++ b/apps/web/src/data/sync/client.test.ts @@ -98,6 +98,6 @@ describe('syncApi rxdb endpoints', () => { entityType: 'notes', checkpoint: null, limit: 100, - })).rejects.toThrow(/Sync auth token invalid or expired/) + })).rejects.toThrow(/Sync request blocked by browser or network/) }) }) diff --git a/apps/web/src/data/sync/client.ts b/apps/web/src/data/sync/client.ts index 0c6fed30..3306403b 100644 --- a/apps/web/src/data/sync/client.ts +++ b/apps/web/src/data/sync/client.ts @@ -1,3 +1,4 @@ +import { SyncAuthError } from './auth' import { getAuth } from '../../store/auth' import { db } from '../db' import { buildApiUrl, fetchApi } from '../../shared/apiBase' @@ -35,7 +36,7 @@ const runSyncConnectivityDiagnosis = async () => { body: JSON.stringify({ entityType: 'notes', checkpoint: null, limit: 1 }), }) if (syncProbe.status === 401) { - return new Error('Sync auth token invalid or expired — please log in again') + return new Error(`Sync request blocked by browser or network: ${base}`) } } catch { return new Error(`Sync request blocked by browser or network: ${base}`) @@ -60,7 +61,7 @@ const fetchJson = async (path: string, init?: RequestInit): Promise => { throw await normalizeSyncFetchError(error) } if (!response.ok) { - if (response.status === 401) throw new Error('Sync failed: session expired, please log in again') + if (response.status === 401) throw new SyncAuthError() // Capture server body to surface in entityErrors so the user can see *why* // it 500'd (e.g. unknown entityType, malformed checkpoint). Without this // the client just retries forever against a broken endpoint. diff --git a/apps/web/src/data/sync/rxdb.test.ts b/apps/web/src/data/sync/rxdb.test.ts index 1702eb18..ec0cc1fd 100644 --- a/apps/web/src/data/sync/rxdb.test.ts +++ b/apps/web/src/data/sync/rxdb.test.ts @@ -53,6 +53,17 @@ describe('rxdb sync migration', () => { await db.delete({ disableAutoOpen: false }) }) + it('stops launching entities on 401, shows one error, and retains pending local edits', async () => { + const { SYNC_AUTH_REQUIRED } = await import('./auth') + await db.pendingSyncChanges.add({ accountId: 'test-account', entityType: 'notes', entityId: 'local', op: 'upsert', payload: { id: 'local', title: 'Keep me', updatedAt: 1, createdAt: 1 }, createdAt: 1 } as never) + fetchMock.mockResolvedValue({ ok: false, status: 401 } as Response) + await expect(runRxdbSyncCycle()).rejects.toThrow(SYNC_AUTH_REQUIRED) + const entityTypes = new Set(fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).entityType)) + expect(entityTypes.size).toBeLessThanOrEqual(6) + expect((await db.syncState.get('cloud-sync'))?.lastError).toBe(SYNC_AUTH_REQUIRED) + expect(await db.pendingSyncChanges.count()).toBe(1) + }) + it('migrates existing Dexie rows into RxDB and pushes them through the new endpoint', async () => { await db.tasks.put({ id: 'task-local', diff --git a/apps/web/src/data/sync/rxdb.ts b/apps/web/src/data/sync/rxdb.ts index 09f5586d..a10ab44c 100644 --- a/apps/web/src/data/sync/rxdb.ts +++ b/apps/web/src/data/sync/rxdb.ts @@ -1,3 +1,4 @@ +import { isSyncAuthError, SyncAuthError } from './auth' import { hasPendingSyncChange, pendingForAccount, type PendingSyncChange } from './pendingChanges' import { suppressSyncCapture } from './capture' import { createRxDatabase, removeRxDatabase, type RxCollection } from 'rxdb' @@ -585,9 +586,9 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) => syncValidationErrors = [] if (!getAuth()?.accessToken) { - const message = 'Sync failed: session expired, please log in again' - await setStatus('error', message) - throw new Error(message) + const error = new SyncAuthError() + await setStatus('error', error.message) + throw error } // No remote request can succeed in a known-offline browser. Keep RxDB's local @@ -600,6 +601,7 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) => await setStatus('syncing') const entityErrors: string[] = [] + let authFailed = false // Process entities in parallel using a bounded worker pool. // Each worker pulls from the shared queue until it is empty, so we never @@ -611,13 +613,14 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) => : [...SYNC_ENTITY_TYPES] const runWorker = async () => { while (queue.length > 0) { - if (rxdbResetRequested) return + if (rxdbResetRequested || authFailed) return const entityType = queue.shift() if (!entityType) return try { await syncEntity(entityType) } catch (error) { - entityErrors.push(`${entityType}: ${extractSyncErrorMessage(error)}`) + if (isSyncAuthError(error)) authFailed = true + else entityErrors.push(`${entityType}: ${extractSyncErrorMessage(error)}`) } } } @@ -628,7 +631,12 @@ export const runRxdbSyncCycle = async (requestedEntities?: SyncEntityType[]) => if (rxdbResetRequested) return - const errors = [...entityErrors, ...syncValidationErrors] + if (authFailed) { + const error = new SyncAuthError() + await setStatus('error', error.message) + throw error + } + const errors = [...new Set([...entityErrors, ...syncValidationErrors])] if (errors.length > 0) { const message = errors.join('; ') await setStatus('error', message) diff --git a/apps/web/src/data/sync/service.test.tsx b/apps/web/src/data/sync/service.test.tsx index a2aef7c5..d8d007b6 100644 --- a/apps/web/src/data/sync/service.test.tsx +++ b/apps/web/src/data/sync/service.test.tsx @@ -4,8 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SyncProvider } from './service' import { SYNC_LOCAL_COMMIT_EVENT } from './capture' -const mocks = vi.hoisted(() => ({ cycle: vi.fn(), ready: vi.fn(), stop: vi.fn(), markStatus: vi.fn().mockResolvedValue({}) })) -vi.mock('../../store/auth', () => ({ getAuth: () => ({ user: { id: 'account-a' } }), useIsLoggedIn: () => true, refreshAuthProfile: async () => {} })) +const mocks = vi.hoisted(() => ({ cycle: vi.fn(), ready: vi.fn(), stop: vi.fn(), session: vi.fn(), paused: false, token: 'token', authListener: (() => {}) as () => void, markStatus: vi.fn().mockResolvedValue({}) })) +vi.mock('./auth', async importOriginal => ({ ...(await importOriginal()), ensureSyncSession: mocks.session, isSyncAuthRequired: () => mocks.paused, useSyncAuthRequired: () => mocks.paused, pauseSyncForAuth: () => { mocks.paused = true } })) +vi.mock('../../store/auth', () => ({ getAuth: () => ({ accessToken: mocks.token, user: { id: 'account-a' } }), subscribeAuth: (listener: () => void) => { mocks.authListener = listener; return () => {} }, useIsLoggedIn: () => true, refreshAuthProfile: async () => {} })) vi.mock('../../shared/hooks/usePageActivity', () => ({ usePageActivity: () => 'visible' })) vi.mock('../seed', () => ({ seedDatabase: async () => false })) vi.mock('../db', () => ({ db: { localSymbolic: { where: () => ({ equals: () => ({ toArray: async () => [] }) }) }, pendingSyncChanges: { toArray: async () => [] } } })) @@ -16,7 +17,7 @@ vi.mock('./stream', () => ({ subscribeSyncStream: () => mocks.stop })) const commit = () => window.dispatchEvent(new CustomEvent(SYNC_LOCAL_COMMIT_EVENT, { detail: { entityTypes: ['notes'] } })) const tick = async (milliseconds: number) => { await act(async () => { await vi.advanceTimersByTimeAsync(milliseconds) }) } -beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-16T12:00:00Z')); vi.clearAllMocks(); mocks.cycle.mockResolvedValue(undefined); mocks.ready.mockResolvedValue(undefined); localStorage.clear() }) +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-16T12:00:00Z')); vi.clearAllMocks(); mocks.paused = false; mocks.token = 'token'; mocks.session.mockReset().mockResolvedValue(undefined); mocks.cycle.mockReset().mockResolvedValue(undefined); mocks.ready.mockResolvedValue(undefined); localStorage.clear() }) afterEach(() => { cleanup(); vi.useRealTimers() }) describe('sync scheduling', () => { @@ -75,3 +76,29 @@ describe('sync failures are visible even before replication starts', () => { expect(mocks.markStatus).toHaveBeenCalledWith('error', 'IndexedDB unavailable') }) }) +describe('expired sync session', () => { + it('stops periodic, reconnect and edit retries until a new login, then resumes', async () => { + const { SyncAuthError } = await import('./auth') + mocks.session.mockRejectedValueOnce(new SyncAuthError()) + render(
) + await tick(0) + expect(mocks.paused).toBe(true) + act(() => { commit(); window.dispatchEvent(new Event('online')) }) + await tick(600_000) + expect(mocks.cycle).not.toHaveBeenCalled() + expect(mocks.session).toHaveBeenCalledTimes(1) + act(() => { mocks.paused = false; mocks.token = 'new-token'; mocks.authListener() }) + await tick(1000) + expect(mocks.cycle).toHaveBeenCalledTimes(1) + }) + + it('recovers a rejected session once, and stops when the retried cycle is still unauthorized', async () => { + const { SyncAuthError } = await import('./auth') + mocks.cycle.mockRejectedValue(new SyncAuthError()) + render(
) + await tick(0) + expect(mocks.session.mock.calls).toEqual([[], [true]]) + expect(mocks.cycle).toHaveBeenCalledTimes(2) + expect(mocks.paused).toBe(true) + }) +}) diff --git a/apps/web/src/data/sync/service.tsx b/apps/web/src/data/sync/service.tsx index 3c46c3cb..dd4050ec 100644 --- a/apps/web/src/data/sync/service.tsx +++ b/apps/web/src/data/sync/service.tsx @@ -1,5 +1,6 @@ +import { ensureSyncSession, isSyncAuthError, isSyncAuthRequired, pauseSyncForAuth, useSyncAuthRequired } from './auth' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { getAuth, refreshAuthProfile, useIsLoggedIn } from '../../store/auth' +import { getAuth, refreshAuthProfile, subscribeAuth, useIsLoggedIn } from '../../store/auth' import { dispatchSyncDataUpdated, SYNC_DATA_UPDATED_EVENT, SYNC_STATUS_CHANGED_EVENT, type SyncDataUpdatedDetail } from './constants' import type { SyncEntityType } from './types' import { syncStateRepo } from './repository' @@ -39,6 +40,7 @@ const loadRxdbSync = () => import('./rxdb') export const SyncProvider = ({ children }: { children: ReactNode }) => { const isLoggedIn = useIsLoggedIn() + const authRequired = useSyncAuthRequired() const [state, setState] = useState(null) const [enabled, setEnabledState] = useState(readCloudSyncEnabled) const runningRef = useRef(false) @@ -80,7 +82,7 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => { const lastSyncEndedAtRef = useRef(0) const consecutiveErrorsRef = useRef(0) const syncNow = useCallback(async (trigger: string = 'manual', entities?: SyncEntityType[]) => { - if (!enabled || !isLoggedIn) return + if (!enabled || !isLoggedIn || isSyncAuthRequired()) return if (runningRef.current) { if (trigger === 'change' || trigger === 'manual' || trigger === 'online') { if (!entities) fullSyncRequestedRef.current = true @@ -106,23 +108,39 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => { console.info('[sync] cycle start', { trigger }) runningRef.current = true let cycleError: unknown = null + let attemptedToken = getAuth()?.accessToken try { const { runRxdbSyncCycle } = await loadRxdbSync() - await runRxdbSyncCycle(entities) - const accountId = getAuth()?.user?.id - if (accountId) { - const symbolic = await db.localSymbolic.where('ownerId').equals(accountId).toArray() - if (symbolic.some(symbolicNeedsUpload)) { - const { syncSymbolicNow } = await import('../../features/symbolic/sync') - await syncSymbolicNow(accountId) + await ensureSyncSession() + attemptedToken = getAuth()?.accessToken + const runAuthenticatedCycle = async () => { + await runRxdbSyncCycle(entities) + const accountId = getAuth()?.user?.id + if (accountId) { + const symbolic = await db.localSymbolic.where('ownerId').equals(accountId).toArray() + if (symbolic.some(symbolicNeedsUpload)) { + const { syncSymbolicNow } = await import('../../features/symbolic/sync') + await syncSymbolicNow(accountId) + } } } + try { + await runAuthenticatedCycle() + } catch (error) { + if (!isSyncAuthError(error)) throw error + await ensureSyncSession(true) + attemptedToken = getAuth()?.accessToken + await runAuthenticatedCycle() + } await refreshAuthProfile() consecutiveErrorsRef.current = 0 } catch (error) { cycleError = error consecutiveErrorsRef.current += 1 - await syncStateRepo.markStatus('error', error instanceof Error ? error.message : String(error)).catch(() => {}) + if (getAuth()?.accessToken === attemptedToken) { + if (isSyncAuthError(error)) pauseSyncForAuth() + await syncStateRepo.markStatus('error', error instanceof Error ? error.message : String(error)).catch(() => {}) + } } finally { runningRef.current = false lastSyncEndedAtRef.current = Date.now() @@ -141,6 +159,23 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { syncNowRef.current = syncNow }, [syncNow]) + useEffect(() => { + let token = getAuth()?.accessToken + let accountId = getAuth()?.user?.id + return subscribeAuth(() => { + const auth = getAuth() + if (auth?.accessToken === token && auth?.user?.id === accountId) return + token = auth?.accessToken + accountId = auth?.user?.id + if (token && !isSyncAuthRequired()) { + consecutiveErrorsRef.current = 0 + lastSyncEndedAtRef.current = 0 + queueSync() + } + }) + }, [queueSync]) + + useEffect(() => { if (!enabled || !isLoggedIn) return const queuedEntities = queuedEntitiesRef.current @@ -163,9 +198,9 @@ export const SyncProvider = ({ children }: { children: ReactNode }) => { }, [enabled, isLoggedIn, queueSync]) useEffect(() => { - if (!enabled || !isLoggedIn || pageActivity !== 'visible') return + if (!enabled || !isLoggedIn || authRequired || pageActivity !== 'visible') return return subscribeSyncStream(entities => queueSync(entities)) - }, [enabled, isLoggedIn, pageActivity, queueSync]) + }, [enabled, isLoggedIn, authRequired, pageActivity, queueSync]) const initialize = useCallback(async () => { if (!enabled || !isLoggedIn || runningRef.current) return diff --git a/apps/web/src/data/sync/stream.test.ts b/apps/web/src/data/sync/stream.test.ts index d7e04494..2e71e848 100644 --- a/apps/web/src/data/sync/stream.test.ts +++ b/apps/web/src/data/sync/stream.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { consumeSyncStream, subscribeSyncStream } from './stream' +const authState = vi.hoisted(() => ({ paused: false })) +vi.mock('./auth', () => ({ isSyncAuthRequired: () => authState.paused })) vi.mock('../../store/auth', () => ({ getAuth: () => ({ accessToken: 'stream-test-token' }) })) describe('authenticated sync notifications', () => { @@ -12,6 +14,24 @@ describe('authenticated sync notifications', () => { expect(onChange.mock.calls).toEqual([[], [['notes']]]) }) + it('hands a 401 to recovery and does not reconnect while authentication is paused', async () => { + vi.useFakeTimers() + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 }) + vi.stubGlobal('fetch', fetchMock) + const onChange = vi.fn(() => { authState.paused = true }) + const unsubscribe = subscribeSyncStream(onChange) + try { + await vi.advanceTimersByTimeAsync(60_000) + expect(onChange).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledOnce() + } finally { + unsubscribe() + authState.paused = false + vi.unstubAllGlobals() + vi.useRealTimers() + } + }) + it('aborts streaming requests and cancels retries on unsubscribe', async () => { const fetchMock = vi.fn().mockImplementation((_url, init) => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))))) vi.stubGlobal('fetch', fetchMock) diff --git a/apps/web/src/data/sync/stream.ts b/apps/web/src/data/sync/stream.ts index dcab6d7c..be324cb3 100644 --- a/apps/web/src/data/sync/stream.ts +++ b/apps/web/src/data/sync/stream.ts @@ -1,3 +1,4 @@ +import { isSyncAuthRequired } from './auth' import { getAuth } from '../../store/auth' import { fetchApi } from '../../shared/apiBase' import { SYNC_ENTITY_TYPES, type SyncEntityType } from './types' @@ -34,12 +35,13 @@ export const subscribeSyncStream = (onChange: (entities?: SyncEntityType[]) => v let timer: ReturnType | undefined let failures = 0 const connect = async () => { - if (stopped) return + if (stopped || isSyncAuthRequired()) return controller = new AbortController() try { const token = getAuth()?.accessToken if (!token) return const response = await fetchApi('/sync/events', { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal }) + if (response.status === 401) onChange() // Let the cycle recover once or pause; reconnect checks the pause gate. if (!response.ok || !response.body) throw new Error('Sync notification unavailable') failures = 0 await consumeSyncStream(response.body, onChange) diff --git a/apps/web/src/features/symbolic/sync.test.ts b/apps/web/src/features/symbolic/sync.test.ts index a962601d..e3f29b77 100644 --- a/apps/web/src/features/symbolic/sync.test.ts +++ b/apps/web/src/features/symbolic/sync.test.ts @@ -1,6 +1,7 @@ import 'fake-indexeddb/auto' import { beforeEach, afterAll, describe, it, expect, vi } from 'vitest' -const auth = vi.hoisted(() => ({ owner: 'guest', session: 'test' })) +const auth = vi.hoisted(() => ({ owner: 'guest', session: 'test', paused: false })) +vi.mock('../../data/sync/auth', async importOriginal => ({ ...(await importOriginal()), isSyncAuthRequired: () => auth.paused })) const fetchApi = vi.hoisted(() => vi.fn()) vi.mock('../../store/auth', () => ({ getAuth: () => auth.owner === 'guest' ? null : { user: { id: auth.owner }, accessToken: auth.session } })) vi.mock('../../shared/apiBase', () => ({ fetchApi })) @@ -41,10 +42,30 @@ function useServer() { const browserStorage = new Map() vi.stubGlobal('localStorage', { getItem: (key: string) => browserStorage.get(key) ?? null, setItem: (key: string, value: string) => browserStorage.set(key, value), clear: () => browserStorage.clear() }) beforeEach(async () => { - auth.owner = 'guest'; auth.session = 'test'; localStorage.clear(); resumeSymbolicSyncAfterResetFailure(); await db.localSymbolic.clear(); fetchApi.mockReset(); useServer() + auth.owner = 'guest'; auth.session = 'test'; auth.paused = false; localStorage.clear(); resumeSymbolicSyncAfterResetFailure(); await db.localSymbolic.clear(); fetchApi.mockReset(); useServer() }) afterAll(() => db.close()) describe('symbolic rebuild persistence and sync', () => { + it('does not send independent timer requests while authentication is paused', async () => { + auth.owner = 'owner' + const profile = await saveBirthProfile('owner', birth, null, null) + auth.paused = true + vi.useFakeTimers() + const stop = startSymbolicSync('owner') + try { + await vi.advanceTimersByTimeAsync(90_000) + expect(fetchApi).not.toHaveBeenCalled() + } finally { stop(); vi.useRealTimers() } + expect((await db.localSymbolic.get(['owner', profile.id!]))?.dirty).toBe(true) + }) + it('classifies a server 401 as authentication failure and preserves pending records', async () => { + auth.owner = 'owner' + const profile = await saveBirthProfile('owner', birth, null, null) + fetchApi.mockResolvedValue(Response.json({ error: 'expired' }, { status: 401 })) + const { SyncAuthError } = await import('../../data/sync/auth') + await expect(syncSymbolicNow('owner')).rejects.toBeInstanceOf(SyncAuthError) + expect((await db.localSymbolic.get(['owner', profile.id!]))?.dirty).toBe(true) + }) it('resumes after switching accounts but keeps explicit resets paused', async () => { auth.owner = 'owner-a'; pauseSymbolicSyncForAccountCleanup() const same = startSymbolicSync('owner-a'); same() diff --git a/apps/web/src/features/symbolic/sync.ts b/apps/web/src/features/symbolic/sync.ts index 18b8f6bb..87390adc 100644 --- a/apps/web/src/features/symbolic/sync.ts +++ b/apps/web/src/features/symbolic/sync.ts @@ -1,3 +1,4 @@ +import { isSyncAuthRequired, SyncAuthError } from '../../data/sync/auth' import { db } from '../../data/db' import { getAuth } from '../../store/auth' import { fetchApi } from '../../shared/apiBase' @@ -68,12 +69,13 @@ if (typeof window !== 'undefined') { window.addEventListener('focusgo:db-reset-requested', pauseSymbolicSyncForAccountCleanup) window.addEventListener('focusgo:auth-changed', handleSymbolicOwnerChange) } -const guard = (owner: string, captured: number) => { assertSymbolicOwner(owner); if (stopped || captured !== epoch) throw new Error('Symbolic sync was reset'); if (!symbolicCloudEnabled()) throw new Error('Cloud sync is off') } +const guard = (owner: string, captured: number) => { assertSymbolicOwner(owner); if (isSyncAuthRequired()) throw new SyncAuthError(); if (stopped || captured !== epoch) throw new Error('Symbolic sync was reset'); if (!symbolicCloudEnabled()) throw new Error('Cloud sync is off') } const request = async (owner: string, captured: number, path: string, body: unknown): Promise => { guard(owner, captured) const token = getAuth()?.accessToken const response = await fetchApi('/symbolic/' + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: JSON.stringify(body) }) guard(owner, captured) + if (response.status === 401) throw new SyncAuthError() const data = await response.json() guard(owner, captured) if (!response.ok) throw new Error(typeof data?.error === 'string' ? data.error : 'Symbolic sync failed') @@ -117,6 +119,11 @@ async function mergeRemote(owner: string, remote: SymbolicWireRow) { const runners = new Map>() export function syncSymbolicNow(owner: string): Promise { if (owner === 'guest' || !symbolicCloudEnabled() || stopped) { setStatus(owner, { status: 'local', conflicts: 0 }); return Promise.resolve() } + if (isSyncAuthRequired()) { + const error = new SyncAuthError() + setStatus(owner, { status: 'error', message: error.message, conflicts: 0 }) + return Promise.reject(error) + } const running = runners.get(owner) if (running) return running const promise = runSync(owner).finally(() => { if (runners.get(owner) === promise) runners.delete(owner) }) diff --git a/apps/web/src/shared/i18n/messages/en.ts b/apps/web/src/shared/i18n/messages/en.ts index e1459ec2..4f9f7bd1 100644 --- a/apps/web/src/shared/i18n/messages/en.ts +++ b/apps/web/src/shared/i18n/messages/en.ts @@ -297,6 +297,9 @@ export const enMessages: TranslationMessages = { 'settings.data.sync.lastSynced': 'Last synced: {{time}}', 'settings.data.sync.lastSynced.never': 'Last synced: Never', 'settings.data.sync.error': 'Latest error: {{message}}', + 'settings.data.sync.authStatus': 'Sync paused · Sign-in required', + 'settings.data.sync.signInAgain': 'Sign in again', + 'settings.data.sync.authRequired': 'Your session has expired. Sync is paused and local changes are preserved. Sign in to the same account to resume.', 'settings.data.sync.action': 'Sync now', 'settings.data.sync.conflicts.title': 'Sync conflict copies', 'settings.data.sync.conflicts.description': 'When two devices change the same item, the cloud version stays active and this device’s edit is saved here. Compare both copies; notes can be recovered as a separate note.', diff --git a/apps/web/src/shared/i18n/messages/zh.ts b/apps/web/src/shared/i18n/messages/zh.ts index 2649645d..88be087b 100644 --- a/apps/web/src/shared/i18n/messages/zh.ts +++ b/apps/web/src/shared/i18n/messages/zh.ts @@ -297,6 +297,9 @@ export const zhMessages: TranslationMessages = { 'settings.data.sync.lastSynced': '上次同步:{{time}}', 'settings.data.sync.lastSynced.never': '上次同步:从未', 'settings.data.sync.error': '最近错误:{{message}}', + 'settings.data.sync.authStatus': '同步已暂停 · 需要重新登录', + 'settings.data.sync.signInAgain': '重新登录', + 'settings.data.sync.authRequired': '登录已失效,云同步已暂停。本机修改仍保留,请重新登录原账号后继续同步。', 'settings.data.sync.action': '立即同步', 'settings.data.sync.conflicts.title': '同步冲突副本', 'settings.data.sync.conflicts.description': '两台设备同时修改同一条内容时,云端版本继续使用;本机修改会保存在这里。可对照两份内容,并把笔记的本机版本另存为新笔记。', diff --git a/apps/web/src/shared/i18n/types.ts b/apps/web/src/shared/i18n/types.ts index d0bbc3c4..daa581ba 100644 --- a/apps/web/src/shared/i18n/types.ts +++ b/apps/web/src/shared/i18n/types.ts @@ -1,6 +1,9 @@ export type LanguageCode = 'en' | 'zh' export type TranslationKey = + | 'settings.data.sync.authRequired' + | 'settings.data.sync.authStatus' + | 'settings.data.sync.signInAgain' | 'notes.paste.failed' | 'notes.block.delete' | 'notes.block.duplicate' From 07470c5c3a0626d4a826051b1d2c4bd68e7d0211 Mon Sep 17 00:00:00 2001 From: VelvetAbyss Date: Mon, 9 Nov 2026 17:02:00 +0800 Subject: [PATCH 2/2] ci: pin browser verification to supported Ubuntu 24.04 --- .github/workflows/verify.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 3eb51e68..3e19ead4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -8,7 +8,8 @@ on: jobs: verify: - runs-on: ubuntu-latest + # Pin the supported browser-test image; ubuntu-latest now selects 26.04. + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4