diff --git a/src/components/auth/session-gate.tsx b/src/components/auth/session-gate.tsx new file mode 100644 index 00000000..c0bdb85d --- /dev/null +++ b/src/components/auth/session-gate.tsx @@ -0,0 +1,33 @@ +import { AlertCircle, Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' + +export function SessionGatePending() { + const { t } = useTranslation() + + return ( +
+
+ +

{t('auth.session.loading')}

+
+
+ ) +} + +export function SessionGateError({ reset }: { reset: () => void }) { + const { t } = useTranslation() + + return ( +
+
+ +
+

{t('auth.session.errorTitle')}

+

{t('auth.session.errorDescription')}

+
+ +
+
+ ) +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 060fe5c1..fe933cdf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -853,6 +853,10 @@ "common.globalSearchPlaceholder": "Search files, shares, teams and settings…", "common.globalSearchComingSoon": "Global search is coming soon", "common.moreOptions": "More options", + "auth.session.loading": "Checking your session...", + "auth.session.errorTitle": "Unable to load your session", + "auth.session.errorDescription": "The server did not respond. Check your connection and try again.", + "auth.session.retry": "Try again", "teams.title": "Teams", "teams.tabMembers": "Members", "teams.tabActivity": "Activity", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index dbc199b6..1e986828 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -853,6 +853,10 @@ "common.globalSearchPlaceholder": "搜索文件、分享、团队和设置…", "common.globalSearchComingSoon": "全局搜索即将推出", "common.moreOptions": "更多选项", + "auth.session.loading": "正在检查登录状态...", + "auth.session.errorTitle": "无法加载登录状态", + "auth.session.errorDescription": "服务器没有响应,请检查网络连接后重试。", + "auth.session.retry": "重试", "teams.title": "团队", "teams.tabMembers": "成员", "teams.tabActivity": "动态", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index d449072a..8965053e 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1616,14 +1616,32 @@ describe('api', () => { const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toBe('/api/auth/get-session') expect(init.credentials).toBe('include') + expect(init.signal).toBeInstanceOf(AbortSignal) }) - it('returns null when response is not ok', async () => { + it('throws ApiError when response is not ok', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401)) - const result = await getSession() + const promise = getSession() - expect(result).toBeNull() + await expect(promise).rejects.toMatchObject({ name: 'ApiError', status: 401 }) + }) + + it('aborts and throws when the session request times out', async () => { + vi.useFakeTimers() + vi.mocked(fetch).mockImplementationOnce( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + }), + ) + + const promise = getSession() + const assertion = expect(promise).rejects.toThrow('Session request timed out') + await vi.advanceTimersByTimeAsync(10_000) + + await assertion + vi.useRealTimers() }) }) diff --git a/src/lib/api.ts b/src/lib/api.ts index 73789255..8c62035c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -128,6 +128,8 @@ export class ApiError extends Error { } } +const SESSION_REQUEST_TIMEOUT_MS = 10_000 + export interface NameConflictBody extends ApiErrorBody { code: 'NAME_CONFLICT' conflictingName: string @@ -1051,9 +1053,22 @@ export function disconnectCloud() { // Auth API — Better Auth passthrough, not typed via Hono RPC export async function getSession(): Promise<{ session: unknown; user: unknown } | null> { - const res = await fetch('/api/auth/get-session', { credentials: 'include' }) - if (!res.ok) return null - return res.json() + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), SESSION_REQUEST_TIMEOUT_MS) + + try { + const res = await fetch('/api/auth/get-session', { credentials: 'include', signal: controller.signal }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ApiErrorBody + throw new ApiError(res.status, body) + } + return res.json() + } catch (error) { + if (controller.signal.aborted) throw new Error('Session request timed out') + throw error + } finally { + window.clearTimeout(timeout) + } } export interface UploadProgress { diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx index db7972ea..c182d635 100644 --- a/src/routes/_authenticated/route.tsx +++ b/src/routes/_authenticated/route.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet, redirect, useMatchRoute } from '@tanstack/react-router' import { SiteAnnouncements } from '@/components/announcements/site-announcements' +import { SessionGateError, SessionGatePending } from '@/components/auth/session-gate' import { AppSidebar } from '@/components/layout/app-sidebar' import { GlobalSearchBar } from '@/components/layout/global-search-bar' import { MusicPlayerButton } from '@/components/music/music-player-button' @@ -17,6 +18,8 @@ export const Route = createFileRoute('/_authenticated')({ } return { user: data.user } }, + pendingComponent: SessionGatePending, + errorComponent: SessionGateError, component: AuthenticatedLayout, }) diff --git a/src/routes/store/checkout.tsx b/src/routes/store/checkout.tsx index f77d5fb6..b7b325cd 100644 --- a/src/routes/store/checkout.tsx +++ b/src/routes/store/checkout.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link, redirect } from '@tanstack/react-router' import { Loader2 } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { SessionGateError, SessionGatePending } from '@/components/auth/session-gate' import { Button } from '@/components/ui/button' import { ApiError, @@ -30,6 +31,8 @@ export const Route = createFileRoute('/store/checkout')({ throw redirect({ to: '/sign-in', search: { redirect: redirectUrl } as never }) } }, + pendingComponent: SessionGatePending, + errorComponent: SessionGateError, component: StorageCheckoutPage, }) diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts index cb0d7ae7..eea71e80 100644 --- a/workers/bootstrap.ts +++ b/workers/bootstrap.ts @@ -16,11 +16,6 @@ interface Env { [key: string]: unknown } -// Cache auth instance at isolate scope to avoid per-request DB queries -// for OIDC config loading. Changes to OIDC provider configs or env vars -// (BETTER_AUTH_URL, TRUSTED_ORIGINS) take effect on isolate recycle. -let cachedAuth: Auth | null = null - const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/ export default { @@ -30,24 +25,21 @@ export default { throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.') } const platform = createCloudflarePlatform(env) - - if (!cachedAuth) { - const origin = new URL(request.url).origin - const baseURL = env.BETTER_AUTH_URL || origin - const trustedOrigins = env.TRUSTED_ORIGINS?.split(',') - .map((o) => o.trim()) - .filter(Boolean) || [origin] - cachedAuth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins) - } + const origin = new URL(request.url).origin + const baseURL = env.BETTER_AUTH_URL || origin + const trustedOrigins = env.TRUSTED_ORIGINS?.split(',') + .map((o) => o.trim()) + .filter(Boolean) || [origin] + const auth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins) const url = new URL(request.url) const shareMatch = SHARE_TOKEN_RE.exec(url.pathname) if (shareMatch && request.method === 'GET') { - return handleShareSsr(request, env, ctx, shareMatch[1], platform, cachedAuth) + return handleShareSsr(request, env, ctx, shareMatch[1], platform, auth) } - return createApp(platform, cachedAuth).fetch(request, env, ctx) + return createApp(platform, auth).fetch(request, env, ctx) }, async scheduled(event: ScheduledEvent, env: Env): Promise {