diff --git a/.gitignore b/.gitignore index 60f19c1fe6..07f76ef3e1 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ bun.lockb # Build outputs dist/ public/_spa/ +public/_spa-auth/ public/spa/ es/ lib/ @@ -92,6 +93,7 @@ public/swe-worker* # Generated files src/app/spa/[variants]/[[...path]]/spaHtmlTemplates.ts +src/app/spa-auth/authHtmlTemplate.ts public/*.js robots.txt diff --git a/index.auth.html b/index.auth.html new file mode 100644 index 0000000000..8a03e28eb6 --- /dev/null +++ b/index.auth.html @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + +
+ + + + + diff --git a/package.json b/package.json index 8114d5f8a9..86d68901c7 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,12 @@ "license": "MIT", "author": "LobeHub ", "sideEffects": [ - "./src/initialize.ts" + "./src/initialize.ts", + "./src/spa/entry.auth.tsx", + "./src/spa/entry.desktop.tsx", + "./src/spa/entry.mobile.tsx", + "./src/spa/entry.popup.tsx", + "./src/spa/entry.web.tsx" ], "workspaces": [ "packages/*", @@ -34,13 +39,14 @@ "apps/desktop/src/main" ], "scripts": { - "build": "bun run build:spa && bun run build:spa:copy && bun run build:next", + "build": "bun run build:spa && bun run build:spa:auth && bun run build:spa:copy && bun run build:next", "build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=81920 next experimental-analyze", - "build:docker": "pnpm run build:spa && pnpm run build:spa:mobile && pnpm run build:spa:copy && cross-env NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build", + "build:docker": "pnpm run build:spa && pnpm run build:spa:mobile && pnpm run build:spa:auth && pnpm run build:spa:copy && cross-env NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build", "build:next": "cross-env NODE_OPTIONS=--max-old-space-size=7168 bun run build:next:raw", "build:next:raw": "next build", - "build:raw": "bun run build:spa:raw && bun run build:spa:copy && bun run build:next:raw", + "build:raw": "bun run build:spa:raw && bun run build:spa:auth && bun run build:spa:copy && bun run build:next:raw", "build:spa": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm run build:spa:raw", + "build:spa:auth": "rm -rf public/_spa-auth && cross-env NODE_OPTIONS=--max-old-space-size=8192 AUTH=true vite build", "build:spa:copy": "tsx scripts/copySpaBuild.mts && tsx scripts/generateSpaTemplates.mts", "build:spa:mobile": "cross-env NODE_OPTIONS=--max-old-space-size=8192 MOBILE=true vite build", "build:spa:raw": "rm -rf public/_spa && vite build", @@ -67,6 +73,7 @@ "dev:docker:reset": "docker compose -f docker-compose/dev/docker-compose.yml down -v && rm -rf docker-compose/dev/data && npm run dev:docker && pnpm db:migrate", "dev:next": "next dev -p 3010", "dev:spa": "vite --port 9876", + "dev:spa:auth": "cross-env AUTH=true vite --port 3013", "dev:spa:mobile": "cross-env MOBILE=true vite --port 3012", "docs:cdn": "npm run workflow:docs-cdn && npm run lint:mdx", "docs:i18n": "lobe-i18n md && npm run lint:mdx", diff --git a/plugins/vite/platformResolve.ts b/plugins/vite/platformResolve.ts index 2860a6d133..882f87473a 100644 --- a/plugins/vite/platformResolve.ts +++ b/plugins/vite/platformResolve.ts @@ -2,7 +2,7 @@ import { access } from 'node:fs/promises'; import type { Plugin } from 'vite'; -type Platform = 'web' | 'mobile' | 'desktop'; +type Platform = 'web' | 'mobile' | 'desktop' | 'auth'; /** * Resolves platform-specific file variants by suffix priority: @@ -18,7 +18,7 @@ export function vitePlatformResolve(platform?: Platform): Plugin { if (platform) suffixes.push(`.${platform}`); suffixes.push('.vite'); const EXT_RE = /\.(ts|tsx|js|jsx)$/; - const PLATFORM_RE = /\.(?:vite|web|mobile|desktop)\.(?:ts|tsx|js|jsx)$/; + const PLATFORM_RE = /\.(?:vite|web|mobile|desktop|auth)\.(?:ts|tsx|js|jsx)$/; return { enforce: 'pre', diff --git a/plugins/vite/routeChunkPreload.test.ts b/plugins/vite/routeChunkPreload.test.ts index 6777f13019..9f18582adf 100644 --- a/plugins/vite/routeChunkPreload.test.ts +++ b/plugins/vite/routeChunkPreload.test.ts @@ -393,6 +393,31 @@ describe('routeChunkPreload', () => { expect(result).not.toContain("import('@/routes"); }); + it('skips the auth SPA html entirely', () => { + const plugin = routeChunkPreload(); + const configResolved = plugin.configResolved as (config: { + base: string; + root: string; + }) => void; + const bundle = { + 'assets/agent-CJm8x.js': createChunk({ + code: 'x'.repeat(2048), + facadeModuleId: '/repo/src/routes/(main)/agent/index.tsx', + fileName: 'assets/agent-CJm8x.js', + moduleIds: ['/repo/src/routes/(main)/agent/index.tsx'], + }), + } satisfies TestOutputBundle; + const transformIndexHtml = plugin.transformIndexHtml as { + handler: (html: string, ctx: { bundle: TestOutputBundle; path?: string }) => string; + }; + + configResolved({ base: '/_spa/', root: '/repo' }); + const html = ''; + const result = transformIndexHtml.handler(html, { bundle, path: '/index.auth.html' }); + + expect(result).toBe(html); + }); + it('does not inject the all-JS warmup manifest by default', () => { const plugin = routeChunkPreload(); const configResolved = plugin.configResolved as (config: { diff --git a/plugins/vite/routeChunkPreload.ts b/plugins/vite/routeChunkPreload.ts index fdd1e71790..011f2bc80b 100644 --- a/plugins/vite/routeChunkPreload.ts +++ b/plugins/vite/routeChunkPreload.ts @@ -557,6 +557,8 @@ export function routeChunkPreload(options: RouteChunkPreloadOptions = {}): Plugi order: 'post', handler(html, ctx) { if (!config || !ctx.bundle) return html; + // The auth SPA shares the build but must not preload main-app routes + if (ctx.path?.includes('index.auth')) return html; const outputBundle = ctx.bundle as OutputBundleLike; const manifest = createRoutePreloadManifest(outputBundle, config.root, groups); diff --git a/plugins/vite/sharedRendererConfig.test.ts b/plugins/vite/sharedRendererConfig.test.ts index 3a439456e1..8f3eafaacb 100644 --- a/plugins/vite/sharedRendererConfig.test.ts +++ b/plugins/vite/sharedRendererConfig.test.ts @@ -23,6 +23,32 @@ describe('sharedModulePreload', () => { }); describe('sharedManualChunks', () => { + it('splits auth SPA namespaces into their own per-locale i18n chunks', () => { + expect(__testing.sharedManualChunks('/repo/locales/zh-CN/auth.json')).toBe('i18n-zh-CN-auth'); + expect(__testing.sharedManualChunks('/repo/locales/zh-CN/common.json')).toBe( + 'i18n-zh-CN-common', + ); + expect(__testing.sharedManualChunks('/repo/packages/locales/src/default/oauth.ts')).toBe( + 'i18n-default-oauth', + ); + expect(__testing.sharedManualChunks('/repo/packages/locales/src/default/chat.ts')).toBe( + 'i18n-src', + ); + expect(__testing.sharedManualChunks('/repo/locales/zh-CN/chat.json')).toBe('i18n-zh-CN'); + expect(__testing.sharedManualChunks('/repo/locales/zh-CN/models.json')).toBe( + 'i18n-zh-CN-models', + ); + }); + + it('keeps locale runtime helpers out of the default locale chunk', () => { + expect(__testing.sharedManualChunks('/repo/packages/locales/src/resources.ts')).toBe(undefined); + expect(__testing.sharedManualChunks('/repo/packages/locales/src/create.ts')).toBe(undefined); + }); + + it('groups shared constants into a dedicated chunk', () => { + expect(__testing.sharedManualChunks('/repo/packages/const/src/url.ts')).toBe('app-const'); + }); + it('groups stable runtime packages into coarse vendor chunks', () => { expect( __testing.sharedManualChunks('/repo/node_modules/.pnpm/react@19/node_modules/react/index.js'), diff --git a/plugins/vite/sharedRendererConfig.ts b/plugins/vite/sharedRendererConfig.ts index faeb366ffc..e5bc892f6b 100644 --- a/plugins/vite/sharedRendererConfig.ts +++ b/plugins/vite/sharedRendererConfig.ts @@ -15,6 +15,13 @@ import { routeChunkPreload } from './routeChunkPreload'; /** Large i18n namespaces that get their own per-locale chunk instead of merging into the locale bundle */ const HEAVY_NS = new Set(['models', 'modelProvider']); +/** + * Namespaces loaded by the auth SPA (see createAuthI18n). They get their own + * per-locale chunk so the auth page never pulls the merged locale bundle of the + * main app, and both SPAs share the same chunk URLs for these namespaces. + */ +const AUTH_NS = new Set(['auth', 'authError', 'common', 'error', 'marketAuth', 'oauth']); + /** antd locale filename β†’ app locale */ const ANTD_LOCALE: Record = { ar_EG: 'ar', @@ -66,10 +73,25 @@ const isNodePackage = (id: string, packageName: string) => { }; function sharedManualChunks(id: string): string | undefined { + // default locale sources live in packages/locales/src/default β€” their chunk + // has historically been named i18n-src by the generic locale match below + const defaultLocaleMatch = id.match(/\/locales\/src\/default\/([^/.]+)/); + if (defaultLocaleMatch) { + const ns = defaultLocaleMatch[1]; + if (AUTH_NS.has(ns)) return `i18n-default-${ns}`; + return 'i18n-src'; + } + + // runtime helpers (resources/create/utils) in packages/locales/src must not + // share a chunk with the default locale data, or every consumer would + // statically pull the whole default bundle + if (id.includes('/locales/src/')) return; + // i18n locale JSON/TS files const localeMatch = id.match(/\/locales\/([^/]+)\/([^/.]+)/); if (localeMatch) { const [, locale, ns] = localeMatch; + if (AUTH_NS.has(ns)) return `i18n-${locale}-${ns}`; if (locale === 'default') return 'i18n-default'; if (HEAVY_NS.has(ns)) return `i18n-${locale}-${ns}`; return `i18n-${locale}`; @@ -78,6 +100,10 @@ function sharedManualChunks(id: string): string | undefined { if (id.includes('/packages/model-runtime/') || isNodePackage(id, 'openai')) return 'vendor-ai-runtime'; + // shared constants would otherwise be captured into vendor-ai-runtime, + // dragging the whole AI chunk into the auth SPA's static graph + if (id.includes('/packages/const/src/')) return 'app-const'; + // model-bank (monorepo package β€” split before node_modules guard) if (id.includes('model-bank')) return 'providerConfig'; @@ -169,7 +195,7 @@ export const createSharedRolldownOutput = (options: SharedRolldownOutputOptions }, }); -type Platform = 'web' | 'mobile' | 'desktop'; +type Platform = 'web' | 'mobile' | 'desktop' | 'auth'; const isDev = process.env.NODE_ENV !== 'production'; const enableRouteChunkPreload = process.env.LOBE_ROUTE_CHUNK_PRELOAD !== 'false'; diff --git a/scripts/copySpaBuild.mts b/scripts/copySpaBuild.mts index aabd6c4990..71c3a92451 100644 --- a/scripts/copySpaBuild.mts +++ b/scripts/copySpaBuild.mts @@ -2,13 +2,17 @@ import { cpSync, existsSync, mkdirSync } from 'node:fs'; import path from 'node:path'; const root = path.resolve(import.meta.dirname, '..'); -const spaDir = path.resolve(root, 'public/_spa'); -const distDirs = ['desktop', 'mobile'] as const; const copyDirs = ['assets', 'i18n', 'vendor'] as const; +const targets = [ + { distDir: 'desktop', publicDir: 'public/_spa' }, + { distDir: 'mobile', publicDir: 'public/_spa' }, + { distDir: 'auth', publicDir: 'public/_spa-auth' }, +] as const; -mkdirSync(spaDir, { recursive: true }); +for (const { distDir, publicDir } of targets) { + const spaDir = path.resolve(root, publicDir); + mkdirSync(spaDir, { recursive: true }); -for (const distDir of distDirs) { for (const dir of copyDirs) { const sourceDir = path.resolve(root, `dist/${distDir}/${dir}`); const targetDir = path.resolve(spaDir, dir); @@ -16,6 +20,6 @@ for (const distDir of distDirs) { if (!existsSync(sourceDir)) continue; cpSync(sourceDir, targetDir, { recursive: true }); - console.log(`Copied dist/${distDir}/${dir} -> public/_spa/${dir}`); + console.log(`Copied dist/${distDir}/${dir} -> ${publicDir}/${dir}`); } } diff --git a/scripts/generateSpaTemplates.mts b/scripts/generateSpaTemplates.mts index dcb813e19a..a47a5cc844 100644 --- a/scripts/generateSpaTemplates.mts +++ b/scripts/generateSpaTemplates.mts @@ -45,3 +45,21 @@ writeFileSync( ); console.log(`Generated spaHtmlTemplates.ts (mobile from ${hasMobileBuild ? 'build' : 'source'})`); + +const authHtmlPath = resolve(root, 'dist/auth/index.auth.html'); + +if (existsSync(authHtmlPath)) { + const authHtml = readFileSync(authHtmlPath, 'utf8'); + + const authOutput = `// Auto-generated by scripts/generateSpaTemplates.mts after vite build +// Do not edit manually + +export const authHtmlTemplate = ${JSON.stringify(authHtml)}; +`; + + writeFileSync(resolve(root, 'src/app/spa-auth/authHtmlTemplate.ts'), authOutput, 'utf8'); + + console.log('Generated authHtmlTemplate.ts'); +} else { + console.log('Skipped authHtmlTemplate.ts (no dist/auth build)'); +} diff --git a/src/app/(backend)/oidc/interaction/[uid]/route.test.ts b/src/app/(backend)/oidc/interaction/[uid]/route.test.ts new file mode 100644 index 0000000000..c4b2d209c7 --- /dev/null +++ b/src/app/(backend)/oidc/interaction/[uid]/route.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from './route'; + +const mocks = vi.hoisted(() => ({ + authEnv: { ENABLE_OIDC: true }, + getClientMetadata: vi.fn(), + getInteractionDetails: vi.fn(), +})); + +vi.mock('debug', () => ({ + default: () => vi.fn(), +})); + +vi.mock('@/envs/auth', () => ({ + authEnv: mocks.authEnv, +})); + +vi.mock('@/libs/oidc-provider/config', () => ({ + defaultClients: [{ client_id: 'lobehub-desktop' }], +})); + +vi.mock('@/server/services/oidc', () => ({ + OIDCService: { + initialize: vi.fn(async () => ({ + getClientMetadata: mocks.getClientMetadata, + getInteractionDetails: mocks.getInteractionDetails, + })), + }, +})); + +const createRequest = (uid: string) => + new Request(`https://example.com/oidc/interaction/${uid}`) as unknown as NextRequest; + +const createProps = (uid: string) => ({ params: Promise.resolve({ uid }) }); + +describe('GET /oidc/interaction/[uid]', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authEnv.ENABLE_OIDC = true; + }); + + it('returns 404 when OIDC is not enabled', async () => { + mocks.authEnv.ENABLE_OIDC = false; + + const response = await GET(createRequest('uid-1'), createProps('uid-1')); + + expect(response.status).toBe(404); + expect(mocks.getInteractionDetails).not.toHaveBeenCalled(); + }); + + it('returns interaction details for a consent prompt with a first-party client', async () => { + mocks.getInteractionDetails.mockResolvedValue({ + params: { + client_id: 'lobehub-desktop', + redirect_uri: 'https://example.com/callback', + scope: 'openid profile email', + }, + prompt: { name: 'consent' }, + }); + mocks.getClientMetadata.mockResolvedValue({ + client_name: 'LobeHub Desktop', + logo_uri: 'https://example.com/logo.png', + }); + + const response = await GET(createRequest('uid-1'), createProps('uid-1')); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + clientId: 'lobehub-desktop', + clientMetadata: { + clientName: 'LobeHub Desktop', + isFirstParty: true, + logo: 'https://example.com/logo.png', + }, + prompt: 'consent', + redirectUri: 'https://example.com/callback', + scopes: ['openid', 'profile', 'email'], + uid: 'uid-1', + }); + expect(mocks.getInteractionDetails).toHaveBeenCalledWith('uid-1'); + expect(mocks.getClientMetadata).toHaveBeenCalledWith('lobehub-desktop'); + }); + + it('marks third-party clients as not first party', async () => { + mocks.getInteractionDetails.mockResolvedValue({ + params: { + client_id: 'third-party-app', + redirect_uri: 'https://third.party/cb', + scope: 'openid', + }, + prompt: { name: 'consent' }, + }); + mocks.getClientMetadata.mockResolvedValue(undefined); + + const response = await GET(createRequest('uid-2'), createProps('uid-2')); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.clientMetadata).toEqual({ isFirstParty: false }); + expect(body.clientId).toBe('third-party-app'); + }); + + it('returns interaction details for a login prompt', async () => { + mocks.getInteractionDetails.mockResolvedValue({ + params: { client_id: 'lobehub-desktop' }, + prompt: { name: 'login' }, + }); + mocks.getClientMetadata.mockResolvedValue({ client_name: 'LobeHub Desktop' }); + + const response = await GET(createRequest('uid-3'), createProps('uid-3')); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.prompt).toBe('login'); + expect(body.scopes).toEqual([]); + expect(body.uid).toBe('uid-3'); + }); + + it('returns 409 for unsupported interaction prompts', async () => { + mocks.getInteractionDetails.mockResolvedValue({ + params: {}, + prompt: { name: 'select_account' }, + }); + + const response = await GET(createRequest('uid-4'), createProps('uid-4')); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: 'unsupported_interaction', + promptName: 'select_account', + }); + }); + + it('returns 400 when the interaction session is not found', async () => { + mocks.getInteractionDetails.mockRejectedValue(new Error('interaction session not found')); + + const response = await GET(createRequest('uid-5'), createProps('uid-5')); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'session_invalid' }); + }); + + it('returns 500 on unexpected errors', async () => { + mocks.getInteractionDetails.mockRejectedValue(new Error('database exploded')); + + const response = await GET(createRequest('uid-6'), createProps('uid-6')); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: 'server_error' }); + }); +}); diff --git a/src/app/(backend)/oidc/interaction/[uid]/route.ts b/src/app/(backend)/oidc/interaction/[uid]/route.ts new file mode 100644 index 0000000000..32a7c35021 --- /dev/null +++ b/src/app/(backend)/oidc/interaction/[uid]/route.ts @@ -0,0 +1,71 @@ +import debug from 'debug'; +import { type NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +import { authEnv } from '@/envs/auth'; +import { defaultClients } from '@/libs/oidc-provider/config'; +import { OIDCService } from '@/server/services/oidc'; +import type { OidcInteractionDetailsResponse, OidcInteractionErrorResponse } from '@/types/oidc'; + +const log = debug('lobe-oidc:interaction'); + +export async function GET(request: NextRequest, props: { params: Promise<{ uid: string }> }) { + if (!authEnv.ENABLE_OIDC) { + log('OIDC is not enabled'); + return new NextResponse(null, { status: 404 }); + } + + const { uid } = await props.params; + log('Received GET request for /oidc/interaction/%s, URL: %s', uid, request.url); + + try { + const oidcService = await OIDCService.initialize(); + const details = await oidcService.getInteractionDetails(uid); + + log( + 'Interaction details found - prompt=%s, client=%s', + details.prompt.name, + details.params.client_id, + ); + + if (details.prompt.name !== 'consent' && details.prompt.name !== 'login') { + return NextResponse.json( + { error: 'unsupported_interaction', promptName: details.prompt.name }, + { status: 409 }, + ); + } + + const clientId = (details.params.client_id as string) || 'unknown'; + const scopes = (details.params.scope as string)?.split(' ') || []; + + const clientDetail = await oidcService.getClientMetadata(clientId); + + return NextResponse.json({ + clientId, + clientMetadata: { + clientName: clientDetail?.client_name, + isFirstParty: defaultClients.map((c) => c.client_id).includes(clientId), + logo: clientDetail?.logo_uri, + }, + prompt: details.prompt.name, + redirectUri: details.params.redirect_uri as string, + scopes, + uid, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : undefined; + + if (errorMessage?.includes('interaction session not found')) { + return NextResponse.json( + { error: 'session_invalid' }, + { status: 400 }, + ); + } + + log('Error handling OIDC interaction: %O', error); + return NextResponse.json( + { error: 'server_error' }, + { status: 500 }, + ); + } +} diff --git a/src/app/[variants]/(auth)/_layout/AuthGlobalProvider.tsx b/src/app/[variants]/(auth)/_layout/AuthGlobalProvider.tsx deleted file mode 100644 index 737976034d..0000000000 --- a/src/app/[variants]/(auth)/_layout/AuthGlobalProvider.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { type ReactNode } from 'react'; - -import { appEnv } from '@/envs/app'; -import AnalyticsRSCProvider from '@/layout/AnalyticsRSCProvider'; -import AuthProvider from '@/layout/AuthProvider'; -import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; -import StyleRegistry from '@/layout/GlobalProvider/StyleRegistry'; -import { getServerFeatureFlagsStateFromRuntimeConfig } from '@/server/featureFlags'; -import { getServerAuthConfig } from '@/server/globalConfig/getServerAuthConfig'; -import { RouteVariants } from '@/utils/server/routeVariants'; - -import AuthLocale from './AuthLocale'; -import { AuthServerConfigProvider } from './AuthServerConfigProvider'; -import AuthThemeLite from './AuthThemeLite'; - -interface AuthGlobalProviderProps { - children: ReactNode; - variants: string; -} - -const AuthGlobalProvider = async ({ children, variants }: AuthGlobalProviderProps) => { - const { locale, isMobile } = RouteVariants.deserializeVariants(variants); - const serverConfig = getServerAuthConfig(); - const featureFlags = await getServerFeatureFlagsStateFromRuntimeConfig(); - - return ( - - - - - - - {children} - - - - - - - ); -}; - -export default AuthGlobalProvider; diff --git a/src/app/[variants]/(auth)/_layout/createAuthI18n.ts b/src/app/[variants]/(auth)/_layout/createAuthI18n.ts deleted file mode 100644 index b5ab8a7f0a..0000000000 --- a/src/app/[variants]/(auth)/_layout/createAuthI18n.ts +++ /dev/null @@ -1,116 +0,0 @@ -import i18next from 'i18next'; -import resourcesToBackend from 'i18next-resources-to-backend'; -import { initReactI18next } from 'react-i18next'; - -import { DEFAULT_LANG } from '@/const/locale'; -import { normalizeLocale } from '@/locales/resources'; - -const AUTH_I18N_NAMESPACES = [ - 'auth', - 'authError', - 'common', - 'error', - 'marketAuth', - 'messenger', - 'oauth', -] as const; -type AuthI18nNamespace = (typeof AUTH_I18N_NAMESPACES)[number]; - -const isAllowedNamespace = (ns: string): ns is AuthI18nNamespace => - (AUTH_I18N_NAMESPACES as readonly string[]).includes(ns); - -const loadDefaultNamespace = async (ns: AuthI18nNamespace) => { - switch (ns) { - case 'auth': { - return import('@/locales/default/auth'); - } - case 'authError': { - return import('@/locales/default/authError'); - } - case 'common': { - return import('@/locales/default/common'); - } - case 'error': { - return import('@/locales/default/error'); - } - case 'marketAuth': { - return import('@/locales/default/marketAuth'); - } - case 'messenger': { - return import('@/locales/default/messenger'); - } - case 'oauth': { - return import('@/locales/default/oauth'); - } - } -}; - -const loadZhNamespace = async (ns: AuthI18nNamespace) => { - switch (ns) { - case 'auth': { - return import('@/../locales/zh-CN/auth.json'); - } - case 'authError': { - return import('@/../locales/zh-CN/authError.json'); - } - case 'common': { - return import('@/../locales/zh-CN/common.json'); - } - case 'error': { - return import('@/../locales/zh-CN/error.json'); - } - case 'marketAuth': { - return import('@/../locales/zh-CN/marketAuth.json'); - } - case 'messenger': { - return import('@/../locales/zh-CN/messenger.json'); - } - case 'oauth': { - return import('@/../locales/zh-CN/oauth.json'); - } - } -}; - -const loadAuthNamespace = async (lng: string, ns: string) => { - const safeNamespace = isAllowedNamespace(ns) ? ns : 'auth'; - const normalizedLocale = normalizeLocale(lng); - - try { - if (normalizedLocale === DEFAULT_LANG) return loadDefaultNamespace(safeNamespace); - if (normalizedLocale === 'zh-CN') return loadZhNamespace(safeNamespace); - } catch { - // fall through to default namespace - } - - return loadDefaultNamespace(safeNamespace); -}; - -export const createAuthI18n = (lang?: string) => { - const instance = i18next - .createInstance() - .use(initReactI18next) - .use( - resourcesToBackend(async (lng: string, ns: string) => { - const mod = await loadAuthNamespace(lng, ns); - return (mod as any).default ?? mod; - }), - ); - - return { - init: (params: { initAsync?: boolean } = {}) => { - const { initAsync = true } = params; - - return instance.init({ - defaultNS: ['auth', 'common', 'error'], - fallbackLng: DEFAULT_LANG, - initAsync, - interpolation: { escapeValue: false }, - keySeparator: false, - lng: lang, - // Silence the Locize promotional console.info printed on init (i18next >= 25) - showSupportNotice: false, - }); - }, - instance, - }; -}; diff --git a/src/app/[variants]/(auth)/layout.tsx b/src/app/[variants]/(auth)/layout.tsx deleted file mode 100644 index d4fc7c5645..0000000000 --- a/src/app/[variants]/(auth)/layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { NuqsAdapter } from 'nuqs/adapters/next/app'; -import { type PropsWithChildren } from 'react'; - -import BusinessAuthProvider from '@/business/client/BusinessAuthProvider'; -import ClientOnly from '@/components/client/ClientOnly'; -import { type DynamicLayoutProps } from '@/types/next'; - -import AuthContainer from './_layout'; -import AuthGlobalProvider from './_layout/AuthGlobalProvider'; - -const AuthLayout = async ({ children, params }: PropsWithChildren) => { - const { variants } = await params; - - return ( - - - - - {children} - - - - - ); -}; - -export default AuthLayout; diff --git a/src/app/[variants]/(auth)/loading.tsx b/src/app/[variants]/(auth)/loading.tsx deleted file mode 100644 index 8bee991876..0000000000 --- a/src/app/[variants]/(auth)/loading.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import Loading from '@/components/Loading/BrandTextLoading'; - -export default () => ; diff --git a/src/app/[variants]/(auth)/oauth/callback/error/page.tsx b/src/app/[variants]/(auth)/oauth/callback/error/page.tsx deleted file mode 100644 index 17a4eb6679..0000000000 --- a/src/app/[variants]/(auth)/oauth/callback/error/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; - -import { Button, Flexbox, FluentEmoji, Highlighter, Text } from '@lobehub/ui'; -import { Result } from 'antd'; -import Link from 'next/link'; -import { parseAsString, useQueryState } from 'nuqs'; -import { useTranslation } from 'react-i18next'; - -const FailedPage = () => { - const { t } = useTranslation('oauth'); - const [reason] = useQueryState('reason'); - const [errorMessage] = useQueryState('errorMessage', parseAsString); - - return ( - } - status="error" - extra={ - - - - } - subTitle={ - - - {t('error.desc', { - reason: t(`error.reason.${reason}` as any, { defaultValue: reason ?? '' }), - })} - - {!!errorMessage && {errorMessage}} - - } - title={ - - {t('error.title')} - - } - /> - ); -}; - -export default FailedPage; diff --git a/src/app/[variants]/(auth)/oauth/consent/[uid]/page.tsx b/src/app/[variants]/(auth)/oauth/consent/[uid]/page.tsx deleted file mode 100644 index ed6b38b7fd..0000000000 --- a/src/app/[variants]/(auth)/oauth/consent/[uid]/page.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { notFound } from 'next/navigation'; - -import { authEnv } from '@/envs/auth'; -import { defaultClients } from '@/libs/oidc-provider/config'; -import { OIDCService } from '@/server/services/oidc'; - -import ConsentClientError from './ClientError'; -import Consent from './Consent'; -import Login from './Login'; - -const InteractionPage = async (props: { params: Promise<{ uid: string }> }) => { - if (!authEnv.ENABLE_OIDC) return notFound(); - - const params = await props.params; - const uid = params.uid; - - try { - const oidcService = await OIDCService.initialize(); - - // Get interaction details, passing request and response objects - const details = await oidcService.getInteractionDetails(uid); - - // Support login and consent type interactions - if (details.prompt.name !== 'consent' && details.prompt.name !== 'login') { - return ( - - ); - } - - // Get client ID and authorization scopes - const clientId = (details.params.client_id as string) || 'unknown'; - const scopes = (details.params.scope as string)?.split(' ') || []; - - const clientDetail = await oidcService.getClientMetadata(clientId); - - const clientMetadata = { - clientName: clientDetail?.client_name, - isFirstParty: defaultClients.map((c) => c.client_id).includes(clientId), - logo: clientDetail?.logo_uri, - }; - // Render client component regardless of login or consent type - if (details.prompt.name === 'login') - return ; - - return ( - - ); - } catch (error) { - console.error('Error handling OIDC interaction:', error); - // Ensure error handling can display correctly - const errorMessage = error instanceof Error ? error.message : undefined; - // Check if it is an 'interaction session not found' error for a more user-friendly message - if (errorMessage?.includes('interaction session not found')) { - return ( - - ); - } - - return ( - - ); - } -}; - -export default InteractionPage; diff --git a/src/app/[variants]/(auth)/oauth/device/confirm/page.tsx b/src/app/[variants]/(auth)/oauth/device/confirm/page.tsx deleted file mode 100644 index e7500ca347..0000000000 --- a/src/app/[variants]/(auth)/oauth/device/confirm/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { notFound } from 'next/navigation'; - -import { authEnv } from '@/envs/auth'; - -import DeviceCodeConfirm from './DeviceCodeConfirm'; - -const DeviceConfirmPage = async (props: { - searchParams: Promise<{ - client_id?: string; - client_name?: string; - user_code?: string; - xsrf?: string; - }>; -}) => { - if (!authEnv.ENABLE_OIDC) return notFound(); - - const searchParams = await props.searchParams; - - if (!searchParams.user_code) return notFound(); - - return ( - - ); -}; - -export default DeviceConfirmPage; diff --git a/src/app/[variants]/(auth)/oauth/device/page.tsx b/src/app/[variants]/(auth)/oauth/device/page.tsx deleted file mode 100644 index efe288eba0..0000000000 --- a/src/app/[variants]/(auth)/oauth/device/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { notFound } from 'next/navigation'; - -import { authEnv } from '@/envs/auth'; - -import DeviceCodeInput from './DeviceCodeInput'; - -const getErrorMessage = (error?: string): string | undefined => { - if (!error) return undefined; - - const errorMap: Record = { - 'already been used': 'device.error.alreadyUsed', - 'interaction was aborted': 'device.error.aborted', - 'code has expired': 'device.error.expired', - 'code was not found': 'device.error.notFound', - 'no code': 'device.error.noCode', - }; - - for (const [key, i18nKey] of Object.entries(errorMap)) { - if (error.toLowerCase().includes(key)) return i18nKey; - } - - return 'device.error.unknown'; -}; - -const DeviceInputPage = async (props: { - searchParams: Promise<{ error?: string; user_code?: string; xsrf?: string }>; -}) => { - if (!authEnv.ENABLE_OIDC) return notFound(); - - const searchParams = await props.searchParams; - - return ( - - ); -}; - -export default DeviceInputPage; diff --git a/src/app/[variants]/(auth)/oauth/device/success/page.tsx b/src/app/[variants]/(auth)/oauth/device/success/page.tsx deleted file mode 100644 index 7890ca7b6b..0000000000 --- a/src/app/[variants]/(auth)/oauth/device/success/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { notFound } from 'next/navigation'; - -import { authEnv } from '@/envs/auth'; - -import DeviceSuccess from './DeviceSuccess'; - -const DeviceSuccessPage = async () => { - if (!authEnv.ENABLE_OIDC) return notFound(); - - return ; -}; - -export default DeviceSuccessPage; diff --git a/src/app/[variants]/(auth)/reset-password/layout.tsx b/src/app/[variants]/(auth)/reset-password/layout.tsx deleted file mode 100644 index 5ef86f2098..0000000000 --- a/src/app/[variants]/(auth)/reset-password/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { redirect } from 'next/navigation'; -import { type PropsWithChildren } from 'react'; - -import { authEnv } from '@/envs/auth'; - -const ResetPasswordLayout = ({ children }: PropsWithChildren) => { - if (authEnv.AUTH_DISABLE_EMAIL_PASSWORD) { - redirect('/signin'); - } - - return children; -}; - -export default ResetPasswordLayout; diff --git a/src/app/[variants]/(auth)/signin/layout.tsx b/src/app/[variants]/(auth)/signin/layout.tsx deleted file mode 100644 index e27a516658..0000000000 --- a/src/app/[variants]/(auth)/signin/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { BRANDING_NAME } from '@lobechat/business-const'; -import { type PropsWithChildren } from 'react'; - -import { metadataModule } from '@/server/metadata'; -import { translation } from '@/server/translation'; -import { type DynamicLayoutProps } from '@/types/next'; -import { RouteVariants } from '@/utils/server/routeVariants'; - -export const generateMetadata = async (props: DynamicLayoutProps) => { - const locale = await RouteVariants.getLocale(props); - const { t } = await translation('auth', locale); - - return metadataModule.generate({ - description: t('signin.subtitle', { appName: BRANDING_NAME }), - title: t('betterAuth.signin.emailStep.title'), - url: '/signin', - }); -}; - -const Layout = ({ children }: PropsWithChildren) => children; - -export default Layout; diff --git a/src/app/[variants]/(auth)/signin/page.tsx b/src/app/[variants]/(auth)/signin/page.tsx deleted file mode 100644 index 3c0e6b89c5..0000000000 --- a/src/app/[variants]/(auth)/signin/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; - -import { Suspense } from 'react'; - -import Loading from '@/components/Loading/BrandTextLoading'; - -import { SignInEmailStep } from './SignInEmailStep'; -import { SignInPasswordStep } from './SignInPasswordStep'; -import { useSignIn } from './useSignIn'; - -const SignInPage = () => { - const { - disableEmailPassword, - email, - form, - handleBackToEmail, - handleCheckUser, - handleForgotPassword, - handleSignIn, - handleSocialSignIn, - isSocialOnly, - lastAuthProvider, - loading, - oAuthSSOProviders, - serverConfigInit, - socialLoading, - step, - } = useSignIn(); - - return ( - }> - {step === 'email' ? ( - - ) : ( - - )} - - ); -}; - -export default SignInPage; diff --git a/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx b/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx deleted file mode 100644 index 592b4506d1..0000000000 --- a/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { redirect } from 'next/navigation'; - -import { authEnv } from '@/envs/auth'; -import { metadataModule } from '@/server/metadata'; -import { translation } from '@/server/translation'; -import { type DynamicLayoutProps } from '@/types/next'; -import { RouteVariants } from '@/utils/server/routeVariants'; - -import BetterAuthSignUpForm from './BetterAuthSignUpForm'; - -export const generateMetadata = async (props: DynamicLayoutProps) => { - const locale = await RouteVariants.getLocale(props); - const { t } = await translation('auth', locale); - - return metadataModule.generate({ - description: t('betterAuth.signup.subtitle'), - title: t('betterAuth.signup.title'), - url: '/signup', - }); -}; - -const Page = () => { - if (authEnv.AUTH_DISABLE_EMAIL_PASSWORD) { - redirect('/signin'); - } - - return ; -}; - -export default Page; diff --git a/src/app/[variants]/(auth)/verify-im/page.tsx b/src/app/[variants]/(auth)/verify-im/page.tsx deleted file mode 100644 index c53680bc47..0000000000 --- a/src/app/[variants]/(auth)/verify-im/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import MessengerVerifyPage from '@/features/Messenger/Verify'; - -export default MessengerVerifyPage; diff --git a/src/app/spa-auth/[locale]/[[...path]]/route.ts b/src/app/spa-auth/[locale]/[[...path]]/route.ts new file mode 100644 index 0000000000..62e519ab9b --- /dev/null +++ b/src/app/spa-auth/[locale]/[[...path]]/route.ts @@ -0,0 +1,47 @@ +import { getServerFeatureFlagsValue } from '@/config/featureFlags'; +import { appEnv } from '@/envs/app'; +import { authEnv } from '@/envs/auth'; +import { type Locales, normalizeLocale } from '@/locales/resources'; +import { getServerAuthConfig } from '@/server/globalConfig/getServerAuthConfig'; +import { buildAnalyticsConfig, fetchViteDevTemplate, renderSpaHtml } from '@/server/spaHtml'; +import { type AuthSPAServerConfig } from '@/types/spaServerConfig'; + +import { buildSeoMeta } from './seoMeta'; + +export function generateStaticParams() { + const staticLocales: Locales[] = ['en-US', 'zh-CN']; + + return staticLocales.map((locale) => ({ locale })); +} + +const isDev = process.env.NODE_ENV === 'development'; + +async function getTemplate(): Promise { + if (isDev) return fetchViteDevTemplate('/index.auth.html'); + + const { authHtmlTemplate } = await import('../../authHtmlTemplate'); + + return authHtmlTemplate; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ locale: string; path?: string[] }> }, +) { + const { locale: rawLocale, path } = await params; + const locale = normalizeLocale(rawLocale); + + const authConfig: AuthSPAServerConfig = { + analyticsConfig: buildAnalyticsConfig(), + config: getServerAuthConfig(), + enableOIDC: authEnv.ENABLE_OIDC, + featureFlags: getServerFeatureFlagsValue(), + globalCDN: appEnv.CDN_USE_GLOBAL, + }; + + const template = await getTemplate(); + const pathname = `/${(path ?? []).join('/')}`; + const seoMeta = await buildSeoMeta(locale, pathname); + + return renderSpaHtml(template, { seoMeta, serverConfig: authConfig }); +} diff --git a/src/app/spa-auth/[locale]/[[...path]]/seoMeta.test.ts b/src/app/spa-auth/[locale]/[[...path]]/seoMeta.test.ts new file mode 100644 index 0000000000..4f5a8205fa --- /dev/null +++ b/src/app/spa-auth/[locale]/[[...path]]/seoMeta.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAuthSeoEntry, buildSeoMeta } from './seoMeta'; + +describe('buildAuthSeoEntry', () => { + it('maps /signin to signin metadata', async () => { + const entry = await buildAuthSeoEntry('en-US', '/signin'); + + expect(entry.canonicalPath).toBe('/signin'); + expect(entry.title).toBe('Sign In'); + expect(entry.description).toContain('account'); + }); + + it('maps /signup to signup metadata', async () => { + const entry = await buildAuthSeoEntry('en-US', '/signup'); + + expect(entry.canonicalPath).toBe('/signup'); + expect(entry.title).toBe('Create Account'); + expect(entry.description).toBe('Start your Agents collaboration space'); + }); + + it('uses hand-translated zh-CN keys', async () => { + const signin = await buildAuthSeoEntry('zh-CN', '/signin'); + const signup = await buildAuthSeoEntry('zh-CN', '/signup'); + + expect(signin.title).toBe('登录'); + expect(signup.title).toBe('εˆ›ε»Ίθ΄¦ε·'); + expect(signup.description).toBe('开启 Agents εδ½œη©Ίι—΄'); + }); + + it('strips a trailing slash before matching', async () => { + const entry = await buildAuthSeoEntry('en-US', '/signin/'); + + expect(entry.canonicalPath).toBe('/signin'); + expect(entry.title).toBe('Sign In'); + }); + + it('falls back to branding for unmapped paths', async () => { + const entry = await buildAuthSeoEntry('en-US', '/oauth/consent'); + + expect(entry.canonicalPath).toBeUndefined(); + expect(entry.title).toBeTruthy(); + expect(entry.description).toBeTruthy(); + }); +}); + +describe('buildSeoMeta', () => { + it('joins canonical path onto official url for mapped paths', async () => { + const meta = await buildSeoMeta('en-US', '/signin'); + + expect(meta).toContain('Sign In'); + expect(meta).toContain('property="og:url" content="https://app.lobehub.com/signin"'); + }); + + it('normalizes hostile locale input to an allowlisted value', async () => { + const hostile = '">'; + const meta = await buildSeoMeta(hostile, '/signin'); + + expect(meta).not.toContain(hostile); + expect(meta).not.toContain('alert(1)'); + expect(meta).toContain('property="og:locale" content="en-US"'); + }); + + it('uses official url for unmapped paths', async () => { + const meta = await buildSeoMeta('en-US', '/verify-email'); + + expect(meta).toContain('property="og:url" content="https://app.lobehub.com"'); + expect(meta).toContain('property="og:locale" content="en-US"'); + }); +}); diff --git a/src/app/spa-auth/[locale]/[[...path]]/seoMeta.ts b/src/app/spa-auth/[locale]/[[...path]]/seoMeta.ts new file mode 100644 index 0000000000..bd8c1e6ec7 --- /dev/null +++ b/src/app/spa-auth/[locale]/[[...path]]/seoMeta.ts @@ -0,0 +1,66 @@ +import { BRANDING_NAME, ORG_NAME } from '@lobechat/business-const'; +import { OG_URL } from '@lobechat/const'; +import urlJoin from 'url-join'; + +import { OFFICIAL_URL } from '@/const/url'; +import { isCustomORG } from '@/const/version'; +import { normalizeLocale } from '@/locales/resources'; +import { translation } from '@/server/translation'; + +interface AuthSeoEntry { + canonicalPath?: string; + description: string; + title: string; +} + +export async function buildAuthSeoEntry(locale: string, pathname: string): Promise { + const { t } = await translation('auth', normalizeLocale(locale)); + const normalizedPath = + pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + + switch (normalizedPath) { + case '/signin': { + return { + canonicalPath: '/signin', + description: t('signin.subtitle', { appName: BRANDING_NAME }), + title: t('betterAuth.signin.emailStep.title'), + }; + } + case '/signup': { + return { + canonicalPath: '/signup', + description: t('betterAuth.signup.subtitle'), + title: t('betterAuth.signup.title'), + }; + } + default: { + return { + description: t('signin.subtitle', { appName: BRANDING_NAME }), + title: BRANDING_NAME, + }; + } + } +} + +export async function buildSeoMeta(locale: string, pathname: string): Promise { + const lng = normalizeLocale(locale); + const { title, description, canonicalPath } = await buildAuthSeoEntry(lng, pathname); + const ogUrl = canonicalPath ? urlJoin(OFFICIAL_URL, canonicalPath) : OFFICIAL_URL; + + return [ + `${title}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join('\n '); +} diff --git a/src/app/spa-auth/authHtmlTemplate.d.ts b/src/app/spa-auth/authHtmlTemplate.d.ts new file mode 100644 index 0000000000..06e9ce0c3b --- /dev/null +++ b/src/app/spa-auth/authHtmlTemplate.d.ts @@ -0,0 +1 @@ +export declare const authHtmlTemplate: string; diff --git a/src/app/spa/[variants]/[[...path]]/route.ts b/src/app/spa/[variants]/[[...path]]/route.ts index fae36823a0..c7204b32e0 100644 --- a/src/app/spa/[variants]/[[...path]]/route.ts +++ b/src/app/spa/[variants]/[[...path]]/route.ts @@ -4,19 +4,14 @@ import { OG_URL } from '@lobechat/const'; import { getServerFeatureFlagsValue } from '@/config/featureFlags'; import { OFFICIAL_URL } from '@/const/url'; import { isCustomORG, isDesktop } from '@/const/version'; -import { analyticsEnv } from '@/envs/analytics'; import { appEnv } from '@/envs/app'; import { fileEnv } from '@/envs/file'; import { pythonEnv } from '@/envs/python'; import { type Locales } from '@/locales/resources'; import { getServerGlobalConfig } from '@/server/globalConfig'; +import { buildAnalyticsConfig, fetchViteDevTemplate, renderSpaHtml } from '@/server/spaHtml'; import { translation } from '@/server/translation'; -import { serializeForHtml } from '@/server/utils/serializeForHtml'; -import { - type AnalyticsConfig, - type SPAClientEnv, - type SPAServerConfig, -} from '@/types/spaServerConfig'; +import { type SPAClientEnv, type SPAServerConfig } from '@/types/spaServerConfig'; import { RouteVariants } from '@/utils/server/routeVariants'; export function generateStaticParams() { @@ -37,135 +32,15 @@ export function generateStaticParams() { } const isDev = process.env.NODE_ENV === 'development'; -const VITE_DEV_ORIGIN = 'http://localhost:9876'; - -async function rewriteViteAssetUrls(html: string): Promise { - const { parseHTML } = await import('linkedom'); - const { document } = parseHTML(html); - - document.querySelectorAll('script[src]').forEach((el: Element) => { - const src = el.getAttribute('src'); - if (src && src.startsWith('/')) { - el.setAttribute('src', `${VITE_DEV_ORIGIN}${src}`); - } - }); - - document.querySelectorAll('link[href]').forEach((el: Element) => { - const href = el.getAttribute('href'); - if (href && href.startsWith('/')) { - el.setAttribute('href', `${VITE_DEV_ORIGIN}${href}`); - } - }); - - document.querySelectorAll('script[type="module"]:not([src])').forEach((el: Element) => { - const text = el.textContent || ''; - if (text.includes('/@')) { - el.textContent = text.replaceAll( - /from\s+["'](\/[@\w].*?)["']/g, - (_match: string, p: string) => `from "${VITE_DEV_ORIGIN}${p}"`, - ); - } - }); - - const workerPatch = document.createElement('script'); - workerPatch.textContent = `(function(){ -var O=globalThis.Worker; -globalThis.Worker=function(u,o){ -var h=typeof u==='string'?u:u instanceof URL?u.href:''; -if(h.startsWith('${VITE_DEV_ORIGIN}')){ -var b=new Blob(['import "'+h+'";'],{type:'application/javascript'}); -return new O(URL.createObjectURL(b),Object.assign({},o,{type:'module'})); -}return new O(u,o)}; -globalThis.Worker.prototype=O.prototype; -})();`; - const head = document.querySelector('head'); - if (head?.firstChild) { - head.insertBefore(workerPatch, head.firstChild); - } - - return document.toString(); -} async function getTemplate(isMobile: boolean): Promise { - if (isDev) { - const res = await fetch(VITE_DEV_ORIGIN); - const html = await res.text(); - return rewriteViteAssetUrls(html); - } + if (isDev) return fetchViteDevTemplate(); const { desktopHtmlTemplate, mobileHtmlTemplate } = await import('./spaHtmlTemplates'); return isMobile ? mobileHtmlTemplate : desktopHtmlTemplate; } -function buildAnalyticsConfig(): AnalyticsConfig { - const config: AnalyticsConfig = {}; - - if (analyticsEnv.ENABLE_GOOGLE_ANALYTICS && analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID) { - config.google = { measurementId: analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID }; - } - - if (analyticsEnv.ENABLED_PLAUSIBLE_ANALYTICS && analyticsEnv.PLAUSIBLE_DOMAIN) { - config.plausible = { - domain: analyticsEnv.PLAUSIBLE_DOMAIN, - scriptBaseUrl: analyticsEnv.PLAUSIBLE_SCRIPT_BASE_URL, - }; - } - - if (analyticsEnv.ENABLED_UMAMI_ANALYTICS && analyticsEnv.UMAMI_WEBSITE_ID) { - config.umami = { - scriptUrl: analyticsEnv.UMAMI_SCRIPT_URL, - websiteId: analyticsEnv.UMAMI_WEBSITE_ID, - }; - } - - if (analyticsEnv.ENABLED_CLARITY_ANALYTICS && analyticsEnv.CLARITY_PROJECT_ID) { - config.clarity = { projectId: analyticsEnv.CLARITY_PROJECT_ID }; - } - - if (analyticsEnv.ENABLED_POSTHOG_ANALYTICS && analyticsEnv.POSTHOG_KEY) { - config.posthog = { - debug: analyticsEnv.DEBUG_POSTHOG_ANALYTICS, - host: analyticsEnv.POSTHOG_HOST, - key: analyticsEnv.POSTHOG_KEY, - }; - } - - if (analyticsEnv.ENABLED_X_ADS && analyticsEnv.X_ADS_PIXEL_ID) { - config.xAds = { - eventIds: { - login_or_signup_clicked: analyticsEnv.X_ADS_LOGIN_OR_SIGNUP_CLICKED_EVENT_ID, - main_page_view: analyticsEnv.X_ADS_MAIN_PAGE_VIEW_EVENT_ID, - }, - pixelId: analyticsEnv.X_ADS_PIXEL_ID, - purchaseEventId: analyticsEnv.X_ADS_PURCHASE_EVENT_ID, - }; - } - - if (analyticsEnv.REACT_SCAN_MONITOR_API_KEY) { - config.reactScan = { apiKey: analyticsEnv.REACT_SCAN_MONITOR_API_KEY }; - } - - if (analyticsEnv.ENABLE_VERCEL_ANALYTICS) { - config.vercel = { - debug: analyticsEnv.DEBUG_VERCEL_ANALYTICS, - enabled: true, - }; - } - - if ( - process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID && - process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL - ) { - config.desktop = { - baseUrl: process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL, - projectId: process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID, - }; - } - - return config; -} - function buildClientEnv(): SPAClientEnv { return { marketBaseUrl: appEnv.MARKET_BASE_URL, @@ -205,34 +80,16 @@ export async function GET( const { variants } = await params; const { locale, isMobile } = RouteVariants.deserializeVariants(variants); - const serverConfig = await getServerGlobalConfig(); - const featureFlags = getServerFeatureFlagsValue(); - const analyticsConfig = buildAnalyticsConfig(); - const clientEnv = buildClientEnv(); - const spaConfig: SPAServerConfig = { - analyticsConfig, - clientEnv, - config: serverConfig, - featureFlags, + analyticsConfig: buildAnalyticsConfig({ desktop: true }), + clientEnv: buildClientEnv(), + config: await getServerGlobalConfig(), + featureFlags: getServerFeatureFlagsValue(), isMobile, }; - let html = await getTemplate(isMobile); - - html = html.replace( - /window\.__SERVER_CONFIG__\s*=\s*undefined;\s*\/\*\s*SERVER_CONFIG\s*\*\//, - `window.__SERVER_CONFIG__ = ${serializeForHtml(spaConfig)};`, - ); - + const template = await getTemplate(isMobile); const seoMeta = await buildSeoMeta(locale); - html = html.replace('', seoMeta); - html = html.replace('', ''); - return new Response(html, { - headers: { - 'Cache-Control': 'no-cache', - 'content-type': 'text/html; charset=utf-8', - }, - }); + return renderSpaHtml(template, { seoMeta, serverConfig: spaConfig }); } diff --git a/src/business/client/WorkspaceContextSlot.tsx b/src/business/client/WorkspaceContextSlot.tsx index 7618238b51..f60efb68f1 100644 --- a/src/business/client/WorkspaceContextSlot.tsx +++ b/src/business/client/WorkspaceContextSlot.tsx @@ -1,5 +1,5 @@ import { type PropsWithChildren } from 'react'; export default function WorkspaceContextSlot({ children }: PropsWithChildren) { - return <>{children}; + return children; } diff --git a/src/business/client/hooks/useBusinessSignup.tsx b/src/business/client/hooks/useBusinessSignup.tsx index 4b982f9f90..903bacd15e 100644 --- a/src/business/client/hooks/useBusinessSignup.tsx +++ b/src/business/client/hooks/useBusinessSignup.tsx @@ -1,4 +1,4 @@ -import type { BaseSignUpFormValues } from '@/app/[variants]/(auth)/signup/[[...signup]]/types'; +import type { BaseSignUpFormValues } from '@/features/Auth/SignUp/types'; export interface BusinessSignupFomData {} diff --git a/src/app/[variants]/(auth)/auth-error/page.tsx b/src/features/Auth/AuthError/index.tsx similarity index 82% rename from src/app/[variants]/(auth)/auth-error/page.tsx rename to src/features/Auth/AuthError/index.tsx index 2a2d3eaaa8..38d90d2a04 100644 --- a/src/app/[variants]/(auth)/auth-error/page.tsx +++ b/src/features/Auth/AuthError/index.tsx @@ -4,10 +4,9 @@ import { SiDiscord } from '@icons-pack/react-simple-icons'; import { SOCIAL_URL } from '@lobechat/business-const'; import { Button, Flexbox, Icon, Text } from '@lobehub/ui'; import { cssVar } from 'antd-style'; -import Link from 'next/link'; -import { parseAsString, useQueryState } from 'nuqs'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import { Link, useSearchParams } from 'react-router-dom'; import AuthCard from '@/features/AuthCard'; @@ -16,7 +15,8 @@ const normalizeErrorCode = (code?: string | null) => const AuthErrorPage = memo(() => { const { t } = useTranslation('authError'); - const [error] = useQueryState('error', parseAsString); + const [searchParams] = useSearchParams(); + const error = searchParams.get('error'); const code = normalizeErrorCode(error); const description = t(`codes.${code}`, { defaultValue: t('codes.UNKNOWN') }); @@ -27,21 +27,21 @@ const AuthErrorPage = memo(() => { title={t('title')} footer={ - + - + - - + + - + } > diff --git a/src/app/[variants]/(auth)/market-auth-callback/page.tsx b/src/features/Auth/MarketAuthCallback/index.tsx similarity index 97% rename from src/app/[variants]/(auth)/market-auth-callback/page.tsx rename to src/features/Auth/MarketAuthCallback/index.tsx index 0964c499c8..2fc04686ad 100644 --- a/src/app/[variants]/(auth)/market-auth-callback/page.tsx +++ b/src/features/Auth/MarketAuthCallback/index.tsx @@ -9,10 +9,6 @@ import { persistMarketAuthResult } from '@/layout/AuthProvider/MarketAuth/handof type CallbackStatus = 'loading' | 'success' | 'error'; -/** - * Market OIDC authorization callback page - * Handles the authorization code returned from the OIDC server - */ const MarketAuthCallbackPage = () => { const { t } = useTranslation('marketAuth'); const [status, setStatus] = useState('loading'); diff --git a/src/features/Auth/OAuthCallback/Error.tsx b/src/features/Auth/OAuthCallback/Error.tsx new file mode 100644 index 0000000000..47d5f7c153 --- /dev/null +++ b/src/features/Auth/OAuthCallback/Error.tsx @@ -0,0 +1,61 @@ +'use client'; + +// Highlighter is intentionally avoided: it pulls every shiki grammar (~10 MB) into the auth bundle +import { Block, Button, Flexbox, FluentEmoji, Text } from '@lobehub/ui'; +import { Result } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +const FailedPage = () => { + const { t } = useTranslation('oauth'); + const [searchParams] = useSearchParams(); + + const reason = searchParams.get('reason'); + const errorMessage = searchParams.get('errorMessage'); + + return ( + } + status="error" + extra={ + + + + } + subTitle={ + + + {t('error.desc', { + reason: t(`error.reason.${reason}` as any, { defaultValue: reason ?? '' }), + })} + + {!!errorMessage && ( + +
+                {errorMessage}
+              
+
+ )} +
+ } + title={ + + {t('error.title')} + + } + /> + ); +}; + +export default FailedPage; diff --git a/src/app/[variants]/(auth)/oauth/callback/social/page.tsx b/src/features/Auth/OAuthCallback/Social.tsx similarity index 96% rename from src/app/[variants]/(auth)/oauth/callback/social/page.tsx rename to src/features/Auth/OAuthCallback/Social.tsx index 4c425974ee..84361c4411 100644 --- a/src/app/[variants]/(auth)/oauth/callback/social/page.tsx +++ b/src/features/Auth/OAuthCallback/Social.tsx @@ -2,15 +2,15 @@ import { FluentEmoji, Text } from '@lobehub/ui'; import { Result } from 'antd'; -import { useSearchParams } from 'next/navigation'; import React, { memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; type CallbackStatus = 'error' | 'success'; const SocialOAuthCallbackPage = memo(() => { const { t } = useTranslation('oauth'); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const [countdown, setCountdown] = useState(3); const [errorMessage, setErrorMessage] = useState(null); const [status, setStatus] = useState('success'); diff --git a/src/app/[variants]/(auth)/oauth/callback/success/page.tsx b/src/features/Auth/OAuthCallback/Success.tsx similarity index 94% rename from src/app/[variants]/(auth)/oauth/callback/success/page.tsx rename to src/features/Auth/OAuthCallback/Success.tsx index a3ee7b1fee..770f7a477a 100644 --- a/src/app/[variants]/(auth)/oauth/callback/success/page.tsx +++ b/src/features/Auth/OAuthCallback/Success.tsx @@ -2,13 +2,13 @@ import { FluentEmoji, Text } from '@lobehub/ui'; import { Result } from 'antd'; -import { useSearchParams } from 'next/navigation'; import React, { memo, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; const SuccessPage = memo(() => { const { t } = useTranslation('oauth'); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const [countdown, setCountdown] = useState(3); useEffect(() => { diff --git a/src/app/[variants]/(auth)/oauth/consent/[uid]/ClientError.tsx b/src/features/Auth/OAuthConsent/ClientError.tsx similarity index 100% rename from src/app/[variants]/(auth)/oauth/consent/[uid]/ClientError.tsx rename to src/features/Auth/OAuthConsent/ClientError.tsx diff --git a/src/app/[variants]/(auth)/oauth/consent/[uid]/Consent/BuiltinConsent.tsx b/src/features/Auth/OAuthConsent/Consent/BuiltinConsent.tsx similarity index 100% rename from src/app/[variants]/(auth)/oauth/consent/[uid]/Consent/BuiltinConsent.tsx rename to src/features/Auth/OAuthConsent/Consent/BuiltinConsent.tsx diff --git a/src/app/[variants]/(auth)/oauth/consent/[uid]/Consent/index.tsx b/src/features/Auth/OAuthConsent/Consent/index.tsx similarity index 94% rename from src/app/[variants]/(auth)/oauth/consent/[uid]/Consent/index.tsx rename to src/features/Auth/OAuthConsent/Consent/index.tsx index 791d855a9e..27655018e5 100644 --- a/src/app/[variants]/(auth)/oauth/consent/[uid]/Consent/index.tsx +++ b/src/features/Auth/OAuthConsent/Consent/index.tsx @@ -5,18 +5,14 @@ import React, { memo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import AuthCard from '@/features/AuthCard'; +import type { OidcClientMetadata } from '@/types/oidc'; -import OAuthApplicationLogo from '../components/OAuthApplicationLogo'; +import OAuthApplicationLogo from '../OAuthApplicationLogo'; import BuiltinConsent from './BuiltinConsent'; interface ClientProps { clientId: string; - clientMetadata: { - clientName?: string; - isFirstParty?: boolean; - logo?: string; - }; - + clientMetadata: OidcClientMetadata; redirectUri?: string; scopes: string[]; uid: string; diff --git a/src/app/[variants]/(auth)/oauth/consent/[uid]/Login.tsx b/src/features/Auth/OAuthConsent/Login.tsx similarity index 94% rename from src/app/[variants]/(auth)/oauth/consent/[uid]/Login.tsx rename to src/features/Auth/OAuthConsent/Login.tsx index 4bfab453df..7a0b2de94b 100644 --- a/src/app/[variants]/(auth)/oauth/consent/[uid]/Login.tsx +++ b/src/features/Auth/OAuthConsent/Login.tsx @@ -6,15 +6,12 @@ import { useTranslation } from 'react-i18next'; import AuthCard from '@/features/AuthCard'; import { useSession } from '@/libs/better-auth/auth-client'; +import type { OidcClientMetadata } from '@/types/oidc'; -import OAuthApplicationLogo from './components/OAuthApplicationLogo'; +import OAuthApplicationLogo from './OAuthApplicationLogo'; interface LoginConfirmProps { - clientMetadata: { - clientName?: string; - isFirstParty?: boolean; - logo?: string; - }; + clientMetadata: OidcClientMetadata; uid: string; } diff --git a/src/app/[variants]/(auth)/oauth/consent/[uid]/components/OAuthApplicationLogo.tsx b/src/features/Auth/OAuthConsent/OAuthApplicationLogo.tsx similarity index 100% rename from src/app/[variants]/(auth)/oauth/consent/[uid]/components/OAuthApplicationLogo.tsx rename to src/features/Auth/OAuthConsent/OAuthApplicationLogo.tsx diff --git a/src/features/Auth/OAuthConsent/index.tsx b/src/features/Auth/OAuthConsent/index.tsx new file mode 100644 index 0000000000..4051e21462 --- /dev/null +++ b/src/features/Auth/OAuthConsent/index.tsx @@ -0,0 +1,95 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { memo } from 'react'; +import { useParams } from 'react-router-dom'; + +import NotFound from '@/components/404'; +import BrandTextLoading from '@/components/Loading/BrandTextLoading'; + +import OAuthGuard from '../OAuthGuard'; +import ClientError from './ClientError'; +import Consent from './Consent'; +import Login from './Login'; +import { InteractionDetailsError, useInteractionDetails } from './useInteractionDetails'; + +const renderError = (error: unknown): ReactNode => { + if (error instanceof InteractionDetailsError) { + if (error.status === 404) return ; + + if (error.status === 409) + return ( + + ); + + if (error.status === 400) + return ( + + ); + + return ( + + ); + } + + const message = error instanceof Error ? error.message : undefined; + + return ( + + ); +}; + +const InteractionContent = memo(() => { + const { uid } = useParams<{ uid: string }>(); + const { data, error, isLoading } = useInteractionDetails(uid); + + if (!uid) return ; + if (error) return renderError(error); + if (isLoading || !data) return OAuthConsent'} />; + + if (data.prompt === 'login') return ; + + return ( + + ); +}); + +InteractionContent.displayName = 'OAuthInteractionContent'; + +const OAuthConsent = memo(() => ( + + + +)); + +OAuthConsent.displayName = 'OAuthConsent'; + +export default OAuthConsent; diff --git a/src/features/Auth/OAuthConsent/useInteractionDetails.test.ts b/src/features/Auth/OAuthConsent/useInteractionDetails.test.ts new file mode 100644 index 0000000000..279fdbef4a --- /dev/null +++ b/src/features/Auth/OAuthConsent/useInteractionDetails.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { OidcInteractionDetailsResponse } from '@/types/oidc'; + +import { fetchInteractionDetails, InteractionDetailsError } from './useInteractionDetails'; + +const mockFetchResponse = (status: number, body?: unknown) => + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(body === undefined ? null : JSON.stringify(body), { + status, + }) as unknown as Response, + ); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetchInteractionDetails', () => { + it('returns interaction details on success', async () => { + const details: OidcInteractionDetailsResponse = { + clientId: 'lobehub-desktop', + clientMetadata: { clientName: 'LobeHub Desktop', isFirstParty: true }, + prompt: 'consent', + redirectUri: 'https://example.com/callback', + scopes: ['openid', 'profile'], + uid: 'abc', + }; + const fetchSpy = mockFetchResponse(200, details); + + await expect(fetchInteractionDetails('abc')).resolves.toEqual(details); + expect(fetchSpy).toHaveBeenCalledWith('/oidc/interaction/abc'); + }); + + it('throws 409 error with promptName for unsupported interactions', async () => { + mockFetchResponse(409, { error: 'unsupported_interaction', promptName: 'select_account' }); + + const error = await fetchInteractionDetails('abc').catch((e) => e); + + expect(error).toBeInstanceOf(InteractionDetailsError); + expect(error.status).toBe(409); + expect(error.promptName).toBe('select_account'); + }); + + it('throws 400 error for invalid sessions', async () => { + mockFetchResponse(400, { error: 'session_invalid' }); + + const error = await fetchInteractionDetails('abc').catch((e) => e); + + expect(error).toBeInstanceOf(InteractionDetailsError); + expect(error.status).toBe(400); + expect(error.message).toBe('session_invalid'); + }); + + it('throws 404 error when OIDC is disabled', async () => { + mockFetchResponse(404); + + const error = await fetchInteractionDetails('abc').catch((e) => e); + + expect(error).toBeInstanceOf(InteractionDetailsError); + expect(error.status).toBe(404); + }); + + it('throws 500 error with fallback message when body is not json', async () => { + mockFetchResponse(500); + + const error = await fetchInteractionDetails('abc').catch((e) => e); + + expect(error).toBeInstanceOf(InteractionDetailsError); + expect(error.status).toBe(500); + expect(error.message).toBe('Request failed with status 500'); + }); +}); diff --git a/src/features/Auth/OAuthConsent/useInteractionDetails.ts b/src/features/Auth/OAuthConsent/useInteractionDetails.ts new file mode 100644 index 0000000000..cc68c227fa --- /dev/null +++ b/src/features/Auth/OAuthConsent/useInteractionDetails.ts @@ -0,0 +1,41 @@ +import useSWR from 'swr'; + +import type { OidcInteractionDetailsResponse, OidcInteractionErrorResponse } from '@/types/oidc'; + +export class InteractionDetailsError extends Error { + promptName?: string; + status: number; + + constructor(status: number, errorCode?: string, promptName?: string) { + super(errorCode || `Request failed with status ${status}`); + this.status = status; + this.promptName = promptName; + } +} + +export const fetchInteractionDetails = async ( + uid: string, +): Promise => { + const res = await fetch(`/oidc/interaction/${uid}`); + + if (!res.ok) { + const body: Partial | undefined = await res + .json() + .catch(() => undefined); + + throw new InteractionDetailsError(res.status, body?.error, body?.promptName); + } + + return res.json(); +}; + +export const useInteractionDetails = (uid?: string) => + useSWR( + uid ? ['oidc-interaction', uid] : null, + ([, id]: [string, string]) => fetchInteractionDetails(id), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + }, + ); diff --git a/src/app/[variants]/(auth)/oauth/device/confirm/DeviceCodeConfirm.tsx b/src/features/Auth/OAuthDevice/DeviceCodeConfirm.tsx similarity index 100% rename from src/app/[variants]/(auth)/oauth/device/confirm/DeviceCodeConfirm.tsx rename to src/features/Auth/OAuthDevice/DeviceCodeConfirm.tsx diff --git a/src/app/[variants]/(auth)/oauth/device/DeviceCodeInput.tsx b/src/features/Auth/OAuthDevice/DeviceCodeInput.tsx similarity index 100% rename from src/app/[variants]/(auth)/oauth/device/DeviceCodeInput.tsx rename to src/features/Auth/OAuthDevice/DeviceCodeInput.tsx diff --git a/src/features/Auth/OAuthDevice/DeviceConfirmPage.tsx b/src/features/Auth/OAuthDevice/DeviceConfirmPage.tsx new file mode 100644 index 0000000000..afa8fefd80 --- /dev/null +++ b/src/features/Auth/OAuthDevice/DeviceConfirmPage.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { memo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import NotFound from '@/components/404'; + +import OAuthGuard from '../OAuthGuard'; +import DeviceCodeConfirm from './DeviceCodeConfirm'; + +const DeviceConfirmPage = memo(() => { + const [searchParams] = useSearchParams(); + + const userCode = searchParams.get('user_code'); + + return ( + + {userCode ? ( + + ) : ( + + )} + + ); +}); + +DeviceConfirmPage.displayName = 'DeviceConfirmPage'; + +export default DeviceConfirmPage; diff --git a/src/features/Auth/OAuthDevice/DeviceInputPage.tsx b/src/features/Auth/OAuthDevice/DeviceInputPage.tsx new file mode 100644 index 0000000000..26c07241d1 --- /dev/null +++ b/src/features/Auth/OAuthDevice/DeviceInputPage.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { memo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import OAuthGuard from '../OAuthGuard'; +import DeviceCodeInput from './DeviceCodeInput'; + +export const getDeviceErrorKey = (error?: string | null): string | undefined => { + if (!error) return undefined; + + const errorMap: Record = { + 'already been used': 'device.error.alreadyUsed', + 'code has expired': 'device.error.expired', + 'code was not found': 'device.error.notFound', + 'interaction was aborted': 'device.error.aborted', + 'no code': 'device.error.noCode', + }; + + for (const [key, i18nKey] of Object.entries(errorMap)) { + if (error.toLowerCase().includes(key)) return i18nKey; + } + + return 'device.error.unknown'; +}; + +const DeviceInputPage = memo(() => { + const [searchParams] = useSearchParams(); + + return ( + + + + ); +}); + +DeviceInputPage.displayName = 'DeviceInputPage'; + +export default DeviceInputPage; diff --git a/src/app/[variants]/(auth)/oauth/device/success/DeviceSuccess.tsx b/src/features/Auth/OAuthDevice/DeviceSuccess.tsx similarity index 100% rename from src/app/[variants]/(auth)/oauth/device/success/DeviceSuccess.tsx rename to src/features/Auth/OAuthDevice/DeviceSuccess.tsx diff --git a/src/features/Auth/OAuthDevice/DeviceSuccessPage.tsx b/src/features/Auth/OAuthDevice/DeviceSuccessPage.tsx new file mode 100644 index 0000000000..692e4c93f0 --- /dev/null +++ b/src/features/Auth/OAuthDevice/DeviceSuccessPage.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { memo } from 'react'; + +import OAuthGuard from '../OAuthGuard'; +import DeviceSuccess from './DeviceSuccess'; + +const DeviceSuccessPage = memo(() => ( + + + +)); + +DeviceSuccessPage.displayName = 'DeviceSuccessPage'; + +export default DeviceSuccessPage; diff --git a/src/features/Auth/OAuthGuard.tsx b/src/features/Auth/OAuthGuard.tsx new file mode 100644 index 0000000000..45091a5e0e --- /dev/null +++ b/src/features/Auth/OAuthGuard.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { memo, type PropsWithChildren } from 'react'; + +import NotFound from '@/components/404'; +import { useAuthServerConfigStore } from '@/features/AuthShell'; + +const OAuthGuard = memo(({ children }) => { + const enableOIDC = useAuthServerConfigStore((s) => s.enableOIDC); + + if (!enableOIDC) return ; + + return children; +}); + +OAuthGuard.displayName = 'OAuthGuard'; + +export default OAuthGuard; diff --git a/src/app/[variants]/(auth)/reset-password/ResetPasswordContent.tsx b/src/features/Auth/ResetPassword/ResetPasswordContent.tsx similarity index 100% rename from src/app/[variants]/(auth)/reset-password/ResetPasswordContent.tsx rename to src/features/Auth/ResetPassword/ResetPasswordContent.tsx diff --git a/src/app/[variants]/(auth)/reset-password/page.tsx b/src/features/Auth/ResetPassword/index.tsx similarity index 60% rename from src/app/[variants]/(auth)/reset-password/page.tsx rename to src/features/Auth/ResetPassword/index.tsx index a04d5777ce..6ceabefb1a 100644 --- a/src/app/[variants]/(auth)/reset-password/page.tsx +++ b/src/features/Auth/ResetPassword/index.tsx @@ -2,26 +2,32 @@ import { Button } from '@lobehub/ui'; import { ChevronLeftIcon } from 'lucide-react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; import { useTranslation } from 'react-i18next'; +import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'; + +import AuthCard from '@/features/AuthCard'; +import { useAuthServerConfigStore } from '@/features/AuthShell'; -import AuthCard from '../../../../features/AuthCard'; import { ResetPasswordContent } from './ResetPasswordContent'; const ResetPasswordPage = () => { const { t } = useTranslation('auth'); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const disableEmailPassword = useAuthServerConfigStore( + (s) => s.serverConfig.disableEmailPassword || false, + ); const token = searchParams.get('token'); const email = searchParams.get('email'); + if (disableEmailPassword) return ; + return ( + @@ -31,7 +37,7 @@ const ResetPasswordPage = () => { router.push(url)} + onSuccessRedirect={(url) => navigate(url)} /> ); diff --git a/src/app/[variants]/(auth)/reset-password/useResetPassword.ts b/src/features/Auth/ResetPassword/useResetPassword.ts similarity index 100% rename from src/app/[variants]/(auth)/reset-password/useResetPassword.ts rename to src/features/Auth/ResetPassword/useResetPassword.ts diff --git a/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx b/src/features/Auth/SignIn/SignInEmailStep.tsx similarity index 98% rename from src/app/[variants]/(auth)/signin/SignInEmailStep.tsx rename to src/features/Auth/SignIn/SignInEmailStep.tsx index e5d425300f..4aae286f49 100644 --- a/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx +++ b/src/features/Auth/SignIn/SignInEmailStep.tsx @@ -8,9 +8,8 @@ import { type CSSProperties, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import AuthIcons from '@/components/AuthIcons'; - -import AuthCard from '../../../../features/AuthCard'; -import AuthAgreement from '../_layout/AuthAgreement'; +import AuthCard from '@/features/AuthCard'; +import { AuthAgreement } from '@/features/AuthShell'; const styles = createStaticStyles(({ css, cssVar }) => ({ setPasswordLink: css` diff --git a/src/app/[variants]/(auth)/signin/SignInPasswordStep.tsx b/src/features/Auth/SignIn/SignInPasswordStep.tsx similarity index 98% rename from src/app/[variants]/(auth)/signin/SignInPasswordStep.tsx rename to src/features/Auth/SignIn/SignInPasswordStep.tsx index 8fcf61b884..ea8fcf8fae 100644 --- a/src/app/[variants]/(auth)/signin/SignInPasswordStep.tsx +++ b/src/features/Auth/SignIn/SignInPasswordStep.tsx @@ -6,7 +6,7 @@ import { ChevronLeft, ChevronRight, Lock } from 'lucide-react'; import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import AuthCard from '../../../../features/AuthCard'; +import AuthCard from '@/features/AuthCard'; export interface SignInPasswordStepProps { email: string; diff --git a/src/features/Auth/SignIn/index.tsx b/src/features/Auth/SignIn/index.tsx new file mode 100644 index 0000000000..c8e17d37bf --- /dev/null +++ b/src/features/Auth/SignIn/index.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { SignInEmailStep } from './SignInEmailStep'; +import { SignInPasswordStep } from './SignInPasswordStep'; +import { useSignIn } from './useSignIn'; + +const SignIn = () => { + const { + disableEmailPassword, + email, + form, + handleBackToEmail, + handleCheckUser, + handleForgotPassword, + handleSignIn, + handleSocialSignIn, + isSocialOnly, + lastAuthProvider, + loading, + oAuthSSOProviders, + serverConfigInit, + socialLoading, + step, + } = useSignIn(); + + return step === 'email' ? ( + + ) : ( + + ); +}; + +export default SignIn; diff --git a/src/app/[variants]/(auth)/signin/useSignIn.test.ts b/src/features/Auth/SignIn/useSignIn.test.ts similarity index 89% rename from src/app/[variants]/(auth)/signin/useSignIn.test.ts rename to src/features/Auth/SignIn/useSignIn.test.ts index 50bfdf61ac..1d6e714867 100644 --- a/src/app/[variants]/(auth)/signin/useSignIn.test.ts +++ b/src/features/Auth/SignIn/useSignIn.test.ts @@ -1,11 +1,9 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// ── import under test ────────────────────────────────────────── import { useSignIn } from './useSignIn'; -// ── hoisted mocks ────────────────────────────────────────────── -const mockPush = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); const mockSearchParamsGet = vi.hoisted(() => vi.fn().mockReturnValue(null)); const mockMessageError = vi.hoisted(() => vi.fn()); const mockMessageSuccess = vi.hoisted(() => vi.fn()); @@ -25,9 +23,9 @@ const mockLocalStorage = vi.hoisted(() => { }; }); -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), - useSearchParams: () => ({ get: mockSearchParamsGet }), +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, + useSearchParams: () => [{ get: mockSearchParamsGet }], })); vi.mock('@/components/AntdStaticMethods', () => ({ @@ -62,7 +60,7 @@ vi.mock('@/business/client/hooks/useBusinessSignin', () => ({ }), })); -vi.mock('../_layout/AuthServerConfigProvider', () => ({ +vi.mock('@/features/AuthShell', () => ({ useAuthServerConfigStore: (selector: (s: any) => any) => selector({ serverConfig: { @@ -74,7 +72,6 @@ vi.mock('../_layout/AuthServerConfigProvider', () => ({ }), })); -// Mock antd Form.useForm const mockSetFieldValue = vi.fn(); const mockGetFieldValue = vi.fn(); const mockValidateFields = vi.fn(); @@ -97,19 +94,30 @@ vi.mock('antd', async () => { }; }); -// Mock global fetch const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); vi.stubGlobal('localStorage', mockLocalStorage); +const originalLocation = window.location; + describe('useSignIn', () => { beforeEach(() => { vi.clearAllMocks(); mockLocalStorage.clear(); mockSearchParamsGet.mockReturnValue(null); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, href: '' }, + writable: true, + }); }); afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + writable: true, + }); vi.restoreAllMocks(); }); @@ -139,7 +147,7 @@ describe('useSignIn', () => { await result.current.handleCheckUser({ email: 'new@example.com' }); }); - expect(mockPush).toHaveBeenCalledWith( + expect(mockNavigate).toHaveBeenCalledWith( expect.stringContaining('/signup?email=new%40example.com'), ); }); @@ -242,9 +250,38 @@ describe('useSignIn', () => { }), expect.any(Object), ); - expect(mockPush).toHaveBeenCalledWith('/'); + expect(window.location.href).toBe('/'); }); + it.each(['javascript:alert(1)', 'https://evil.com', '//evil.com'])( + 'should fall back to "/" instead of redirecting to hostile callbackUrl %s', + async (hostileUrl) => { + mockSearchParamsGet.mockImplementation((key: string) => + key === 'callbackUrl' ? hostileUrl : null, + ); + mockSignInEmail.mockImplementation(async (_data: any, opts: any) => { + opts.onSuccess(); + return { error: null }; + }); + mockFetch.mockResolvedValueOnce({ + json: async () => ({ exists: true, hasPassword: true }), + ok: true, + }); + + const { result } = renderHook(() => useSignIn()); + + await act(async () => { + await result.current.handleCheckUser({ email: 'user@example.com' }); + }); + + await act(async () => { + await result.current.handleSignIn({ password: 'password123' }); + }); + + expect(window.location.href).toBe('/'); + }, + ); + it('should show error on sign in failure', async () => { mockSignInEmail.mockResolvedValue({ error: { message: 'Invalid credentials', status: 401 }, @@ -289,7 +326,7 @@ describe('useSignIn', () => { await result.current.handleSignIn({ password: 'password' }); }); - expect(mockPush).toHaveBeenCalledWith( + expect(mockNavigate).toHaveBeenCalledWith( expect.stringContaining('/verify-email?email=user%40example.com'), ); }); diff --git a/src/app/[variants]/(auth)/signin/useSignIn.ts b/src/features/Auth/SignIn/useSignIn.ts similarity index 92% rename from src/app/[variants]/(auth)/signin/useSignIn.ts rename to src/features/Auth/SignIn/useSignIn.ts index 734d44c49e..97f788436e 100644 --- a/src/app/[variants]/(auth)/signin/useSignIn.ts +++ b/src/features/Auth/SignIn/useSignIn.ts @@ -1,19 +1,19 @@ import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const'; import { Form } from 'antd'; -import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import type { CheckUserResponseData } from '@/app/(backend)/api/auth/check-user/route'; import type { ResolveUsernameResponseData } from '@/app/(backend)/api/auth/resolve-username/route'; import { useBusinessSignin } from '@/business/client/hooks/useBusinessSignin'; import { message } from '@/components/AntdStaticMethods'; +import { useAuthServerConfigStore } from '@/features/AuthShell'; import { trackLoginOrSignupClicked } from '@/features/User/UserLoginOrSignup/trackLoginOrSignupClicked'; import { requestPasswordReset, signIn } from '@/libs/better-auth/auth-client'; import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client'; -import { buildOnboardingRedirectUrl } from '@/utils/onboardingRedirect'; +import { buildOnboardingRedirectUrl, sanitizeRedirectPath } from '@/utils/onboardingRedirect'; -import { useAuthServerConfigStore } from '../_layout/AuthServerConfigProvider'; import { EMAIL_REGEX, USERNAME_REGEX } from './SignInEmailStep'; const LAST_AUTH_PROVIDER_KEY = 'lobehub:auth:last-provider:v1'; @@ -32,8 +32,8 @@ interface ResolvedEmailResult { export const useSignIn = () => { const { t } = useTranslation('auth'); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const enableMagicLink = useAuthServerConfigStore((s) => s.serverConfig.enableMagicLink || false); const disableEmailPassword = useAuthServerConfigStore( (s) => s.serverConfig.disableEmailPassword || false, @@ -151,7 +151,9 @@ export const useSignIn = () => { signupParams.set('callbackUrl', callbackUrl); const utmSource = searchParams.get('utm_source'); if (utmSource) signupParams.set('utm_source', utmSource); - router.push(`/signup?${signupParams.toString()}`); + const referral = searchParams.get('referral'); + if (referral) signupParams.set('referral', referral); + navigate(`/signup?${signupParams.toString()}`); return; } @@ -188,12 +190,15 @@ export const useSignIn = () => { onError: (ctx) => { console.error('Sign in error:', ctx.error); if (ctx.error.status === 403) { - router.push( + navigate( `/verify-email?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`, ); } }, - onSuccess: () => router.push(callbackUrl), + // callbackUrl targets the main app, outside this auth SPA β€” full page load required + onSuccess: () => { + window.location.href = sanitizeRedirectPath(callbackUrl); + }, }, ); @@ -272,8 +277,10 @@ export const useSignIn = () => { params.set('callbackUrl', callbackUrl); const utmSource = searchParams.get('utm_source'); if (utmSource) params.set('utm_source', utmSource); + const referral = searchParams.get('referral'); + if (referral) params.set('referral', referral); void trackLoginOrSignupClicked({ spm: 'signin.go_to_signup.click' }).finally(() => { - router.push(`/signup?${params.toString()}`); + navigate(`/signup?${params.toString()}`); }); }; diff --git a/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx b/src/features/Auth/SignUp/BetterAuthSignUpForm.tsx similarity index 86% rename from src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx rename to src/features/Auth/SignUp/BetterAuthSignUpForm.tsx index 93d2615e48..aed07bd264 100644 --- a/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +++ b/src/features/Auth/SignUp/BetterAuthSignUpForm.tsx @@ -4,23 +4,22 @@ import { BRANDING_NAME } from '@lobechat/business-const'; import { Button, Icon, Text } from '@lobehub/ui'; import { Form, Input, type InputRef } from 'antd'; import { Lock, Mail } from 'lucide-react'; -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; + +import { AuthCard } from '@/features/AuthCard'; +import { AuthAgreement } from '@/features/AuthShell'; +import { trackLoginOrSignupClicked } from '@/features/User/UserLoginOrSignup/trackLoginOrSignupClicked'; -import { AuthCard } from '../../../../../features/AuthCard'; -import { trackLoginOrSignupClicked } from '../../../../../features/User/UserLoginOrSignup/trackLoginOrSignupClicked'; -import AuthAgreement from '../../_layout/AuthAgreement'; -import { type SignUpFormValues } from './useSignUp'; import { useSignUp } from './useSignUp'; const BetterAuthSignUpForm = () => { - const [form] = Form.useForm(); - const { loading, onSubmit, businessElement } = useSignUp(); + const { form, loading, onSubmit, businessElement } = useSignUp(); const { t } = useTranslation('auth'); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const emailInputRef = useRef(null); const passwordInputRef = useRef(null); @@ -39,11 +38,11 @@ const BetterAuthSignUpForm = () => { {t('betterAuth.signup.hasAccount')}{' '} { event.preventDefault(); void trackLoginOrSignupClicked({ spm: 'signup.go_to_signin.click' }).finally(() => { - window.location.href = `/signin?${searchParams.toString()}`; + navigate(`/signin?${searchParams.toString()}`); }); }} > diff --git a/src/features/Auth/SignUp/index.tsx b/src/features/Auth/SignUp/index.tsx new file mode 100644 index 0000000000..1be75d213d --- /dev/null +++ b/src/features/Auth/SignUp/index.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Navigate } from 'react-router-dom'; + +import { useAuthServerConfigStore } from '@/features/AuthShell'; + +import BetterAuthSignUpForm from './BetterAuthSignUpForm'; + +const SignUp = () => { + const disableEmailPassword = useAuthServerConfigStore( + (s) => s.serverConfig.disableEmailPassword || false, + ); + + if (disableEmailPassword) return ; + + return ; +}; + +export default SignUp; diff --git a/src/app/[variants]/(auth)/signup/[[...signup]]/types.ts b/src/features/Auth/SignUp/types.ts similarity index 100% rename from src/app/[variants]/(auth)/signup/[[...signup]]/types.ts rename to src/features/Auth/SignUp/types.ts diff --git a/src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.test.ts b/src/features/Auth/SignUp/useSignUp.test.ts similarity index 86% rename from src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.test.ts rename to src/features/Auth/SignUp/useSignUp.test.ts index 372538205a..6d52a3c1d7 100644 --- a/src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.test.ts +++ b/src/features/Auth/SignUp/useSignUp.test.ts @@ -1,19 +1,17 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// ── import under test ────────────────────────────────────────── import { useSignUp } from './useSignUp'; -// ── hoisted mocks ────────────────────────────────────────────── -const mockPush = vi.hoisted(() => vi.fn()); +const mockNavigate = vi.hoisted(() => vi.fn()); const mockSearchParamsGet = vi.hoisted(() => vi.fn().mockReturnValue(null)); const mockMessageError = vi.hoisted(() => vi.fn()); const mockSignUpEmail = vi.hoisted(() => vi.fn()); const mockGetCaptchaTokenOnError = vi.hoisted(() => vi.fn()); -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), - useSearchParams: () => ({ get: mockSearchParamsGet }), +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, + useSearchParams: () => [{ get: mockSearchParamsGet }], })); vi.mock('@/components/AntdStaticMethods', () => ({ @@ -38,11 +36,8 @@ vi.mock('@/business/client/hooks/useBusinessSignup', () => ({ }), })); -// motion/react-m exports `form` as a motion HTML element β€” mock the whole module -vi.mock('motion/react-m', () => ({ form: {} })); - let mockEnableEmailVerification = false; -vi.mock('../../_layout/AuthServerConfigProvider', () => ({ +vi.mock('@/features/AuthShell', () => ({ useAuthServerConfigStore: (selector: (s: any) => any) => selector({ serverConfig: { @@ -51,15 +46,27 @@ vi.mock('../../_layout/AuthServerConfigProvider', () => ({ }), })); +const originalLocation = window.location; + describe('useSignUp', () => { beforeEach(() => { vi.clearAllMocks(); mockSearchParamsGet.mockReturnValue(null); mockGetCaptchaTokenOnError.mockResolvedValue(undefined); mockEnableEmailVerification = false; + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, href: '' }, + writable: true, + }); }); afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + writable: true, + }); vi.restoreAllMocks(); }); @@ -106,7 +113,7 @@ describe('useSignUp', () => { await result.current.onSubmit(validValues); }); - expect(mockPush).toHaveBeenCalledWith('/onboarding'); + expect(window.location.href).toBe('/onboarding'); }); it('should thread callbackUrl from search params through onboarding', async () => { @@ -124,7 +131,7 @@ describe('useSignUp', () => { expect(mockSignUpEmail).toHaveBeenCalledWith( expect.objectContaining({ callbackURL: '/onboarding?callbackUrl=%2Fdashboard' }), ); - expect(mockPush).toHaveBeenCalledWith('/onboarding?callbackUrl=%2Fdashboard'); + expect(window.location.href).toBe('/onboarding?callbackUrl=%2Fdashboard'); }); it('should redirect to verify-email when email verification is enabled', async () => { @@ -137,7 +144,7 @@ describe('useSignUp', () => { await result.current.onSubmit(validValues); }); - expect(mockPush).toHaveBeenCalledWith( + expect(mockNavigate).toHaveBeenCalledWith( expect.stringContaining('/verify-email?email=new%40example.com'), ); }); @@ -169,7 +176,8 @@ describe('useSignUp', () => { }); expect(mockMessageError).toHaveBeenCalled(); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(window.location.href).toBe(''); }); it('should show error for invalid email', async () => { @@ -184,7 +192,8 @@ describe('useSignUp', () => { }); expect(mockMessageError).toHaveBeenCalled(); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(window.location.href).toBe(''); }); it('should show translated error for known error codes', async () => { @@ -199,7 +208,8 @@ describe('useSignUp', () => { }); expect(mockMessageError).toHaveBeenCalled(); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(window.location.href).toBe(''); }); it('should retry sign up with captcha token when captcha is required', async () => { @@ -223,7 +233,7 @@ describe('useSignUp', () => { }), ); expect(mockMessageError).not.toHaveBeenCalled(); - expect(mockPush).toHaveBeenCalledWith('/onboarding'); + expect(window.location.href).toBe('/onboarding'); }); it('should stop sign up when captcha modal is cancelled', async () => { @@ -240,7 +250,8 @@ describe('useSignUp', () => { expect(mockSignUpEmail).toHaveBeenCalledTimes(1); expect(mockMessageError).not.toHaveBeenCalled(); - expect(mockPush).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(window.location.href).toBe(''); }); it('should show generic error on unexpected exception', async () => { diff --git a/src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.ts b/src/features/Auth/SignUp/useSignUp.ts similarity index 84% rename from src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.ts rename to src/features/Auth/SignUp/useSignUp.ts index dda3680829..b76f7976e4 100644 --- a/src/app/[variants]/(auth)/signup/[[...signup]]/useSignUp.ts +++ b/src/features/Auth/SignUp/useSignUp.ts @@ -1,19 +1,19 @@ import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const'; -import { form } from 'motion/react-m'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Form } from 'antd'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import type { BusinessSignupFomData } from '@/business/client/hooks/useBusinessSignup'; import { useBusinessSignup } from '@/business/client/hooks/useBusinessSignup'; import { message } from '@/components/AntdStaticMethods'; +import type { AuthFetchOptions } from '@/features/Auth/utils/authFetchOptions'; +import { withCaptchaToken } from '@/features/Auth/utils/authFetchOptions'; +import { useAuthServerConfigStore } from '@/features/AuthShell'; import { trackLoginOrSignupClicked } from '@/features/User/UserLoginOrSignup/trackLoginOrSignupClicked'; import { signUp } from '@/libs/better-auth/auth-client'; import { buildOnboardingRedirectUrl } from '@/utils/onboardingRedirect'; -import { useAuthServerConfigStore } from '../../_layout/AuthServerConfigProvider'; -import type { AuthFetchOptions } from '../../utils/authFetchOptions'; -import { withCaptchaToken } from '../../utils/authFetchOptions'; import type { BaseSignUpFormValues } from './types'; export type SignUpFormValues = BaseSignUpFormValues & BusinessSignupFomData; @@ -30,8 +30,9 @@ interface SignUpErrorLike { export const useSignUp = () => { const { t } = useTranslation(['auth', 'authError']); - const router = useRouter(); - const searchParams = useSearchParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const { getCaptchaTokenOnError, getFetchOptions, preSocialSignupCheck, businessElement } = useBusinessSignup(form); @@ -99,11 +100,12 @@ export const useSignUp = () => { } if (enableEmailVerification) { - router.push( + navigate( `/verify-email?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(redirectUrl)}`, ); } else { - router.push(redirectUrl); + // onboarding lives in the main app, outside this auth SPA β€” full page load required + window.location.href = redirectUrl; } } catch { message.error(t('betterAuth.signup.error')); @@ -112,5 +114,5 @@ export const useSignUp = () => { } }; - return { businessElement, loading, onSubmit: handleSignUp }; + return { businessElement, form, loading, onSubmit: handleSignUp }; }; diff --git a/src/app/[variants]/(auth)/verify-email/VerifyEmailContent.tsx b/src/features/Auth/VerifyEmail/VerifyEmailContent.tsx similarity index 100% rename from src/app/[variants]/(auth)/verify-email/VerifyEmailContent.tsx rename to src/features/Auth/VerifyEmail/VerifyEmailContent.tsx diff --git a/src/app/[variants]/(auth)/verify-email/page.tsx b/src/features/Auth/VerifyEmail/index.tsx similarity index 80% rename from src/app/[variants]/(auth)/verify-email/page.tsx rename to src/features/Auth/VerifyEmail/index.tsx index 1a5c518a4b..d261760cd9 100644 --- a/src/app/[variants]/(auth)/verify-email/page.tsx +++ b/src/features/Auth/VerifyEmail/index.tsx @@ -2,16 +2,16 @@ import { Button } from '@lobehub/ui'; import { ChevronLeftIcon } from 'lucide-react'; -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; import { useTranslation } from 'react-i18next'; +import { Link, useSearchParams } from 'react-router-dom'; + +import AuthCard from '@/features/AuthCard'; -import AuthCard from '../../../../features/AuthCard'; import { VerifyEmailContent } from './VerifyEmailContent'; const VerifyEmailPage = () => { const { t } = useTranslation('auth'); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const email = searchParams.get('email'); const callbackUrl = searchParams.get('callbackUrl') || '/'; @@ -20,7 +20,7 @@ const VerifyEmailPage = () => { subtitle={t('betterAuth.verifyEmail.description', { email: email || '@' })} title={t('betterAuth.verifyEmail.title')} footer={ - + diff --git a/src/app/[variants]/(auth)/verify-email/useVerifyEmail.ts b/src/features/Auth/VerifyEmail/useVerifyEmail.ts similarity index 100% rename from src/app/[variants]/(auth)/verify-email/useVerifyEmail.ts rename to src/features/Auth/VerifyEmail/useVerifyEmail.ts diff --git a/src/app/[variants]/(auth)/utils/authFetchOptions.ts b/src/features/Auth/utils/authFetchOptions.ts similarity index 100% rename from src/app/[variants]/(auth)/utils/authFetchOptions.ts rename to src/features/Auth/utils/authFetchOptions.ts diff --git a/src/app/[variants]/(auth)/_layout/AuthAgreement.tsx b/src/features/AuthShell/AuthAgreement.tsx similarity index 100% rename from src/app/[variants]/(auth)/_layout/AuthAgreement.tsx rename to src/features/AuthShell/AuthAgreement.tsx index 1688294421..90eb3e67f3 100644 --- a/src/app/[variants]/(auth)/_layout/AuthAgreement.tsx +++ b/src/features/AuthShell/AuthAgreement.tsx @@ -22,12 +22,12 @@ const AuthAgreement = memo(() => { components={{ privacy: ( - {t('footer.terms')} + {t('footer.privacy')} ), terms: ( - {t('footer.privacy')} + {t('footer.terms')} ), }} diff --git a/src/app/[variants]/(auth)/_layout/index.tsx b/src/features/AuthShell/AuthContainer.tsx similarity index 91% rename from src/app/[variants]/(auth)/_layout/index.tsx rename to src/features/AuthShell/AuthContainer.tsx index d1a1e9a021..49d17493c6 100644 --- a/src/app/[variants]/(auth)/_layout/index.tsx +++ b/src/features/AuthShell/AuthContainer.tsx @@ -3,7 +3,6 @@ import { Center, Flexbox } from '@lobehub/ui'; import { Divider } from 'antd'; import { cx } from 'antd-style'; -import Link from 'next/link'; import { type FC, type PropsWithChildren } from 'react'; import { ProductLogo } from '@/components/Branding'; @@ -24,9 +23,9 @@ const AuthContainer: FC = ({ children }) => { width={'100%'} > - + - +
{children} diff --git a/src/app/[variants]/(auth)/_layout/AuthFooterLinks.tsx b/src/features/AuthShell/AuthFooterLinks.tsx similarity index 100% rename from src/app/[variants]/(auth)/_layout/AuthFooterLinks.tsx rename to src/features/AuthShell/AuthFooterLinks.tsx diff --git a/src/app/[variants]/(auth)/_layout/AuthLangButton.tsx b/src/features/AuthShell/AuthLangButton.tsx similarity index 100% rename from src/app/[variants]/(auth)/_layout/AuthLangButton.tsx rename to src/features/AuthShell/AuthLangButton.tsx diff --git a/src/app/[variants]/(auth)/_layout/AuthLocale.tsx b/src/features/AuthShell/AuthLocale.tsx similarity index 88% rename from src/app/[variants]/(auth)/_layout/AuthLocale.tsx rename to src/features/AuthShell/AuthLocale.tsx index 9b42832d2e..6b8fd322cd 100644 --- a/src/app/[variants]/(auth)/_layout/AuthLocale.tsx +++ b/src/features/AuthShell/AuthLocale.tsx @@ -4,8 +4,6 @@ import { ConfigProvider } from 'antd'; import { memo, type PropsWithChildren, useEffect, useState } from 'react'; import { isRtlLang } from 'rtl-detect'; -import { isOnServerSide } from '@/utils/env'; - import { createAuthI18n } from './createAuthI18n'; interface AuthLocaleProps extends PropsWithChildren { @@ -16,9 +14,7 @@ const AuthLocale = memo(({ children, defaultLang }) => { const [i18n] = useState(() => createAuthI18n(defaultLang)); const [lang, setLang] = useState(defaultLang ?? 'en-US'); - if (isOnServerSide) { - i18n.init({ initAsync: false }); - } else if (!i18n.instance.isInitialized) { + if (!i18n.instance.isInitialized) { i18n.init(); } diff --git a/src/app/[variants]/(auth)/_layout/AuthServerConfigProvider.tsx b/src/features/AuthShell/AuthServerConfigProvider.tsx similarity index 88% rename from src/app/[variants]/(auth)/_layout/AuthServerConfigProvider.tsx rename to src/features/AuthShell/AuthServerConfigProvider.tsx index d3e8f58a9c..dba61c6b3a 100644 --- a/src/app/[variants]/(auth)/_layout/AuthServerConfigProvider.tsx +++ b/src/features/AuthShell/AuthServerConfigProvider.tsx @@ -7,9 +7,9 @@ import type { IFeatureFlagsState } from '@/config/featureFlags'; import type { GlobalServerConfig } from '@/types/serverConfig'; interface AuthServerConfigState { + enableOIDC: boolean; featureFlags: Partial; isMobile?: boolean; - segmentVariants?: string; serverConfig: GlobalServerConfig; serverConfigInit: boolean; } @@ -18,19 +18,19 @@ const AuthServerConfigContext = createContext(null interface Props { children: ReactNode; + enableOIDC?: boolean; featureFlags?: Partial; isMobile?: boolean; - segmentVariants?: string; serverConfig?: GlobalServerConfig; } export const AuthServerConfigProvider = memo( - ({ children, featureFlags, serverConfig, isMobile, segmentVariants }) => ( + ({ children, enableOIDC, featureFlags, serverConfig, isMobile }) => ( (({ children }) => { + const serverConfig = window.__SERVER_CONFIG__ as unknown as AuthSPAServerConfig | undefined; + const locale = document.documentElement.lang || 'en-US'; + + return ( + + + + + + {children} + + + + + + ); +}); + +AuthShell.displayName = 'AuthShell'; + +export default AuthShell; diff --git a/src/app/[variants]/(auth)/_layout/AuthThemeButton.tsx b/src/features/AuthShell/AuthThemeButton.tsx similarity index 100% rename from src/app/[variants]/(auth)/_layout/AuthThemeButton.tsx rename to src/features/AuthShell/AuthThemeButton.tsx diff --git a/src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx b/src/features/AuthShell/AuthThemeLite.tsx similarity index 97% rename from src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx rename to src/features/AuthShell/AuthThemeLite.tsx index c520279870..cfda333a7e 100644 --- a/src/app/[variants]/(auth)/_layout/AuthThemeLite.tsx +++ b/src/features/AuthShell/AuthThemeLite.tsx @@ -5,13 +5,13 @@ import 'antd/dist/reset.css'; import { ConfigProvider, ThemeProvider } from '@lobehub/ui'; import { App } from 'antd'; import * as m from 'motion/react-m'; -import Link from 'next/link'; import { type PropsWithChildren } from 'react'; import { memo } from 'react'; import AntdStaticMethods from '@/components/AntdStaticMethods'; import { useIsDark } from '@/hooks/useIsDark'; import Image from '@/libs/next/Image'; +import Link from '@/libs/next/Link'; interface AuthThemeLiteProps extends PropsWithChildren { globalCDN?: boolean; diff --git a/src/features/AuthShell/createAuthI18n.ts b/src/features/AuthShell/createAuthI18n.ts new file mode 100644 index 0000000000..eda16b0635 --- /dev/null +++ b/src/features/AuthShell/createAuthI18n.ts @@ -0,0 +1,108 @@ +import i18next from 'i18next'; +import resourcesToBackend from 'i18next-resources-to-backend'; +import { initReactI18next } from 'react-i18next'; + +import { DEFAULT_LANG } from '@/const/locale'; +import defaultAuth from '@/locales/default/auth'; +import defaultAuthError from '@/locales/default/authError'; +import defaultCommon from '@/locales/default/common'; +import defaultError from '@/locales/default/error'; +import defaultMarketAuth from '@/locales/default/marketAuth'; +import defaultOauth from '@/locales/default/oauth'; +import { normalizeLocale } from '@/locales/resources'; + +const defaultResources = { + auth: defaultAuth, + authError: defaultAuthError, + common: defaultCommon, + error: defaultError, + marketAuth: defaultMarketAuth, + oauth: defaultOauth, +}; + +type AuthI18nNamespace = keyof typeof defaultResources; + +const isAllowedNamespace = (ns: string): ns is AuthI18nNamespace => ns in defaultResources; + +const loadZhNamespace = async (ns: AuthI18nNamespace) => { + switch (ns) { + case 'auth': { + return import('@/../locales/zh-CN/auth.json'); + } + case 'authError': { + return import('@/../locales/zh-CN/authError.json'); + } + case 'common': { + return import('@/../locales/zh-CN/common.json'); + } + case 'error': { + return import('@/../locales/zh-CN/error.json'); + } + case 'marketAuth': { + return import('@/../locales/zh-CN/marketAuth.json'); + } + case 'oauth': { + return import('@/../locales/zh-CN/oauth.json'); + } + } +}; + +const loadAuthNamespace = async (lng: string, ns: string) => { + const safeNamespace = isAllowedNamespace(ns) ? ns : 'auth'; + const normalizedLocale = normalizeLocale(lng); + + if (normalizedLocale === 'zh-CN') { + try { + const mod = await loadZhNamespace(safeNamespace); + return (mod as any).default ?? mod; + } catch { + // fall through to bundled default namespace + } + } + + return defaultResources[safeNamespace]; +}; + +export const createAuthI18n = (lang?: string) => { + const instance = i18next + .createInstance() + .use(initReactI18next) + .use(resourcesToBackend(loadAuthNamespace)); + + // With ns: [] and the en-US fallback bundled, i18next considers every namespace + // "loaded" and never asks the backend after a language switch β€” fetch explicitly. + instance.on('languageChanged', (lng) => { + const locale = normalizeLocale(lng); + if (locale === DEFAULT_LANG) return; + void instance.reloadResources([locale], Object.keys(defaultResources)); + }); + + return { + init: (params: { initAsync?: boolean } = {}) => { + const { initAsync = true } = params; + + return instance.init({ + defaultNS: ['auth', 'common', 'error'], + fallbackLng: DEFAULT_LANG, + initAsync, + interpolation: { escapeValue: false }, + keySeparator: false, + lng: lang, + ns: [], + // Bundle en-US synchronously so the first render never suspends: with the + // default useSuspense=true and no Suspense boundary above AuthShell, every + // retry of the initial mount re-creates this instance and the auth SPA + // remounts forever with a blank #root. + partialBundledLanguages: true, + react: { + bindI18nStore: 'added', + useSuspense: false, + }, + resources: { [DEFAULT_LANG]: defaultResources }, + // Silence the Locize promotional console.info printed on init (i18next >= 25) + showSupportNotice: false, + }); + }, + instance, + }; +}; diff --git a/src/features/AuthShell/index.ts b/src/features/AuthShell/index.ts new file mode 100644 index 0000000000..4bc06716f0 --- /dev/null +++ b/src/features/AuthShell/index.ts @@ -0,0 +1,4 @@ +export { default as AuthAgreement } from './AuthAgreement'; +export { default as AuthContainer } from './AuthContainer'; +export { AuthServerConfigProvider, useAuthServerConfigStore } from './AuthServerConfigProvider'; +export { default } from './AuthShell'; diff --git a/src/app/[variants]/(auth)/_layout/style.ts b/src/features/AuthShell/style.ts similarity index 88% rename from src/app/[variants]/(auth)/_layout/style.ts rename to src/features/AuthShell/style.ts index bbef965586..7059eb7f1e 100644 --- a/src/app/[variants]/(auth)/_layout/style.ts +++ b/src/features/AuthShell/style.ts @@ -5,7 +5,6 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({ height: 24px; `, - // Inner container - dark mode innerContainerDark: css` position: relative; @@ -17,7 +16,6 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({ background: ${cssVar.colorBgContainer}; `, - // Inner container - light mode innerContainerLight: css` position: relative; @@ -29,7 +27,6 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({ background: ${cssVar.colorBgContainer}; `, - // Outer container outerContainer: css` position: relative; `, diff --git a/src/features/Messenger/Verify/Standalone.tsx b/src/features/Messenger/Verify/Standalone.tsx new file mode 100644 index 0000000000..bb84b41d3b --- /dev/null +++ b/src/features/Messenger/Verify/Standalone.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Center } from '@lobehub/ui'; + +import MessengerVerifyPage from '.'; + +const MessengerVerifyStandalonePage = () => ( +
+ +
+); + +export default MessengerVerifyStandalonePage; diff --git a/src/features/Messenger/Verify/index.tsx b/src/features/Messenger/Verify/index.tsx index 6c6506906e..0bf3420633 100644 --- a/src/features/Messenger/Verify/index.tsx +++ b/src/features/Messenger/Verify/index.tsx @@ -1,9 +1,9 @@ 'use client'; import { Button, Flexbox } from '@lobehub/ui'; -import { useSearchParams } from 'next/navigation'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import useSWR from 'swr'; import Loading from '@/components/Loading/BrandTextLoading'; @@ -20,7 +20,7 @@ const isSupportedPlatform = (value: string): value is MessengerPlatform => const MessengerVerifyPage = memo(() => { const { t } = useTranslation('messenger'); - const searchParams = useSearchParams(); + const [searchParams] = useSearchParams(); const randomId = searchParams.get('random_id') ?? ''; const imType = searchParams.get('im_type') ?? ''; diff --git a/src/layout/SPAGlobalProvider/index.tsx b/src/layout/SPAGlobalProvider/index.tsx index 6fe6be5769..d7c5556ced 100644 --- a/src/layout/SPAGlobalProvider/index.tsx +++ b/src/layout/SPAGlobalProvider/index.tsx @@ -16,7 +16,6 @@ import DynamicFavicon from '@/layout/GlobalProvider/DynamicFavicon'; import { FaviconProvider } from '@/layout/GlobalProvider/FaviconProvider'; import { GroupWizardProvider } from '@/layout/GlobalProvider/GroupWizardProvider'; import ImportSettings from '@/layout/GlobalProvider/ImportSettings'; -import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; import QueryProvider from '@/layout/GlobalProvider/Query'; import ServerVersionOutdatedAlert from '@/layout/GlobalProvider/ServerVersionOutdatedAlert'; import StoreInitialization from '@/layout/GlobalProvider/StoreInitialization'; @@ -47,53 +46,51 @@ const SPAGlobalProvider = memo(({ children }) => { return ( - - - - - - + + + + + - {isDesktop && } - - - - - - - - {children} - - - - - - - - - - - - - - - - - {/* DevPanel disabled in SPA: depends on node:fs */} - {__DEV__ && ( - <> - - - - )} - - - - + {isDesktop && } + + + + + + + + {children} + + + + + + + + + + + + + + + + + {/* DevPanel disabled in SPA: depends on node:fs */} + {__DEV__ && ( + <> + + + + )} + + + ); }); diff --git a/src/libs/next/Link.tsx b/src/libs/next/Link.tsx index a7ee4a984d..047f91f53d 100644 --- a/src/libs/next/Link.tsx +++ b/src/libs/next/Link.tsx @@ -6,7 +6,7 @@ import { type AnchorHTMLAttributes } from 'react'; import { Link as RRLink } from 'react-router-dom'; -import { nextjsOnlyRoutes } from './nextjsOnlyRoutes'; +import { authSpaRoutes, nextjsOnlyRoutes } from './nextjsOnlyRoutes'; export interface LinkProps extends Omit, 'href'> { href: string; @@ -15,11 +15,13 @@ export interface LinkProps extends Omit, scroll?: boolean; } +const hardNavRoutes = [...nextjsOnlyRoutes, ...authSpaRoutes]; + const isExternalOrNextOnly = (href: string) => href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || - nextjsOnlyRoutes.some( + hardNavRoutes.some( (route) => href === route || href.startsWith(`${route}/`) || href.startsWith(`${route}?`), ); diff --git a/src/libs/next/nextjsOnlyRoutes.ts b/src/libs/next/nextjsOnlyRoutes.ts index 57355a0efa..a27e6bf9de 100644 --- a/src/libs/next/nextjsOnlyRoutes.ts +++ b/src/libs/next/nextjsOnlyRoutes.ts @@ -1,6 +1,11 @@ -// Auth/Next.js routes that must NOT go to SPA catch-all. +// Next.js routes that must NOT go to SPA catch-all. // Shared between middleware (define-config.ts) and the client Link adapter. -export const nextjsOnlyRoutes = [ +export const nextjsOnlyRoutes = ['/discover']; + +// Routes served by the standalone auth SPA (/spa-auth). The main SPA must +// hard-navigate to them (cross-app), so the Link adapter treats them like +// nextjsOnlyRoutes. +export const authSpaRoutes = [ '/signin', '/signup', '/auth-error', @@ -8,7 +13,4 @@ export const nextjsOnlyRoutes = [ '/verify-email', '/oauth', '/market-auth-callback', - '/discover', - '/welcome', - '/verify-im', ]; diff --git a/src/libs/next/proxy/define-config.test.ts b/src/libs/next/proxy/define-config.test.ts new file mode 100644 index 0000000000..49e698b9b4 --- /dev/null +++ b/src/libs/next/proxy/define-config.test.ts @@ -0,0 +1,39 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { defineConfig } from './define-config'; + +vi.mock('@/auth', () => ({ + auth: { api: { getSession: vi.fn().mockResolvedValue(null) } }, +})); + +const { middleware } = defineConfig(); + +const run = async (url: string) => { + const res = await middleware(new NextRequest(url)); + return res?.headers.get('x-middleware-rewrite'); +}; + +describe('defineConfig locale path-traversal hardening', () => { + it('rewrites a normal locale into /spa-auth/', async () => { + const rewrite = await run('http://localhost:3010/signin?hl=ja-JP'); + expect(new URL(rewrite!).pathname).toBe('/spa-auth/ja-JP/signin'); + }); + + it('falls back to en-US for a traversal locale (plain)', async () => { + const rewrite = await run('http://localhost:3010/signin?hl=../../api/dev/x'); + const { pathname } = new URL(rewrite!); + expect(pathname.startsWith('/spa-auth/')).toBe(true); + expect(pathname).toBe('/spa-auth/en-US/signin'); + }); + + it('falls back to en-US for a traversal locale (percent-encoded)', async () => { + const rewrite = await run('http://localhost:3010/signin?hl=..%2F..%2Fapi%2Fdev%2Fx'); + const { pathname } = new URL(rewrite!); + expect(pathname.startsWith('/spa-auth/')).toBe(true); + expect(pathname).toBe('/spa-auth/en-US/signin'); + }); +}); diff --git a/src/libs/next/proxy/define-config.ts b/src/libs/next/proxy/define-config.ts index 74564c56f2..b38fca3829 100644 --- a/src/libs/next/proxy/define-config.ts +++ b/src/libs/next/proxy/define-config.ts @@ -10,9 +10,9 @@ import { appEnv } from '@/envs/app'; import { authEnv } from '@/envs/auth'; import { type Locales } from '@/locales/resources'; import { parseBrowserLanguage } from '@/utils/locale'; -import { RouteVariants } from '@/utils/server/routeVariants'; +import { DEFAULT_LANG, locales, RouteVariants } from '@/utils/server/routeVariants'; -import { nextjsOnlyRoutes } from '../nextjsOnlyRoutes'; +import { authSpaRoutes, nextjsOnlyRoutes } from '../nextjsOnlyRoutes'; import { createRouteMatcher } from './createRouteMatcher'; // Create debug logger instances @@ -22,6 +22,29 @@ const logBetterAuth = debug('middleware:better-auth'); // Dev-only debug proxy route should bypass all middleware rewrites. const dangerousLocalDevProxyRoute = '/_dangerous_local_dev_proxy'; +// The locale is embedded raw into rewrite paths (/spa-auth/${locale}, /spa/${route}). +// An unvalidated value (e.g. ?hl=../../api/dev) would let the URL parser collapse the +// traversal and rewrite to a confused internal target, so allowlist it before use. +const toSafeLocale = (locale: string): Locales => + (locales as readonly string[]).includes(locale) ? (locale as Locales) : DEFAULT_LANG; + +const persistLocaleCookie = ( + response: NextResponse, + request: NextRequest, + explicitlyLocale: Locales | undefined, +) => { + if (!explicitlyLocale) return; + const existingLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value as Locales | undefined; + if (existingLocale) return; + response.cookies.set(LOBE_LOCALE_COOKIE, explicitlyLocale, { + // 90 days is a balanced persistence for locale preference + maxAge: 60 * 60 * 24 * 90, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); +}; + export function defineConfig() { // `/oauth/connector` is a backend route handler (custom connector OAuth callback); // the rest of `/oauth/*` (e.g. /oauth/callback/success) are SPA pages, so scope @@ -70,10 +93,12 @@ export function defineConfig() { // so mobile UA does not land on mobile-specific routes. const isSharePath = url.pathname === '/share' || url.pathname.startsWith('/share/'); + const safeLocale = toSafeLocale(locale); + // 2. Create normalized preference values const route = RouteVariants.serializeVariants({ isMobile: !isSharePath && device.type === 'mobile', - locale, + locale: safeLocale, }); logDefault('Serialized route variant: %s', route); @@ -101,6 +126,20 @@ export function defineConfig() { return NextResponse.next(); } + const isAuthSpaRoute = authSpaRoutes.some((r) => url.pathname.startsWith(r)); + + // Auth SPA routes: rewrite to /spa-auth/[locale]/[[...path]] catch-all + if (isAuthSpaRoute) { + const authSpaPath = `/spa-auth/${safeLocale}${url.pathname}`; + logDefault('Auth SPA route, rewriting to: %s', authSpaPath); + url.pathname = authSpaPath; + + const response = NextResponse.rewrite(url); + persistLocaleCookie(response, request, explicitlyLocale); + + return response; + } + const isNextjsRoute = nextjsOnlyRoutes.some((r) => url.pathname.startsWith(r)); // SPA routes: rewrite to /spa/[variants]/[...path] catch-all @@ -110,21 +149,7 @@ export function defineConfig() { url.pathname = spaPath; const response = NextResponse.rewrite(url); - - // If locale explicitly provided via query (?hl=), persist it in cookie - if (explicitlyLocale) { - const existingLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value as - | Locales - | undefined; - if (!existingLocale) { - response.cookies.set(LOBE_LOCALE_COOKIE, explicitlyLocale, { - maxAge: 60 * 60 * 24 * 90, - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }); - } - } + persistLocaleCookie(response, request, explicitlyLocale); return response; } @@ -148,27 +173,7 @@ export function defineConfig() { // build rewrite response first const rewrite = NextResponse.rewrite(url, { status: 200 }); - // If locale explicitly provided via query (?hl=), persist it in cookie when user has no prior preference - if (explicitlyLocale) { - const existingLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value as Locales | undefined; - if (!existingLocale) { - rewrite.cookies.set(LOBE_LOCALE_COOKIE, explicitlyLocale, { - // 90 days is a balanced persistence for locale preference - maxAge: 60 * 60 * 24 * 90, - - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }); - logDefault('Persisted explicit locale to cookie (no prior cookie): %s', explicitlyLocale); - } else { - logDefault( - 'Locale cookie exists (%s), skip overwrite with %s', - existingLocale, - explicitlyLocale, - ); - } - } + persistLocaleCookie(rewrite, request, explicitlyLocale); return rewrite; }; @@ -201,6 +206,9 @@ export function defineConfig() { '/oidc/handoff', '/oidc/device/auth', '/oidc/token', + // Interaction details for the consent/login page β€” must be reachable + // before the user has a session, so it cannot be session-gated. + '/oidc/interaction/(.*)', // market '/market-auth-callback', // public share pages diff --git a/src/routes/auth/auth-error/index.tsx b/src/routes/auth/auth-error/index.tsx new file mode 100644 index 0000000000..a5e5e49255 --- /dev/null +++ b/src/routes/auth/auth-error/index.tsx @@ -0,0 +1,3 @@ +import AuthError from '@/features/Auth/AuthError'; + +export default AuthError; diff --git a/src/routes/auth/market-auth-callback/index.tsx b/src/routes/auth/market-auth-callback/index.tsx new file mode 100644 index 0000000000..0ed2e7b2b7 --- /dev/null +++ b/src/routes/auth/market-auth-callback/index.tsx @@ -0,0 +1,3 @@ +import MarketAuthCallback from '@/features/Auth/MarketAuthCallback'; + +export default MarketAuthCallback; diff --git a/src/routes/auth/oauth/callback/error/index.tsx b/src/routes/auth/oauth/callback/error/index.tsx new file mode 100644 index 0000000000..29633db5e3 --- /dev/null +++ b/src/routes/auth/oauth/callback/error/index.tsx @@ -0,0 +1,3 @@ +import OAuthCallbackError from '@/features/Auth/OAuthCallback/Error'; + +export default OAuthCallbackError; diff --git a/src/routes/auth/oauth/callback/social/index.tsx b/src/routes/auth/oauth/callback/social/index.tsx new file mode 100644 index 0000000000..f45d822c81 --- /dev/null +++ b/src/routes/auth/oauth/callback/social/index.tsx @@ -0,0 +1,3 @@ +import OAuthCallbackSocial from '@/features/Auth/OAuthCallback/Social'; + +export default OAuthCallbackSocial; diff --git a/src/routes/auth/oauth/callback/success/index.tsx b/src/routes/auth/oauth/callback/success/index.tsx new file mode 100644 index 0000000000..b91168bf2f --- /dev/null +++ b/src/routes/auth/oauth/callback/success/index.tsx @@ -0,0 +1,3 @@ +import OAuthCallbackSuccess from '@/features/Auth/OAuthCallback/Success'; + +export default OAuthCallbackSuccess; diff --git a/src/routes/auth/oauth/consent/[uid]/index.tsx b/src/routes/auth/oauth/consent/[uid]/index.tsx new file mode 100644 index 0000000000..3faa0a10e9 --- /dev/null +++ b/src/routes/auth/oauth/consent/[uid]/index.tsx @@ -0,0 +1,3 @@ +import OAuthConsent from '@/features/Auth/OAuthConsent'; + +export default OAuthConsent; diff --git a/src/routes/auth/oauth/device/confirm/index.tsx b/src/routes/auth/oauth/device/confirm/index.tsx new file mode 100644 index 0000000000..ab087413ef --- /dev/null +++ b/src/routes/auth/oauth/device/confirm/index.tsx @@ -0,0 +1,3 @@ +import DeviceConfirmPage from '@/features/Auth/OAuthDevice/DeviceConfirmPage'; + +export default DeviceConfirmPage; diff --git a/src/routes/auth/oauth/device/index.tsx b/src/routes/auth/oauth/device/index.tsx new file mode 100644 index 0000000000..f98ddf197b --- /dev/null +++ b/src/routes/auth/oauth/device/index.tsx @@ -0,0 +1,3 @@ +import DeviceInputPage from '@/features/Auth/OAuthDevice/DeviceInputPage'; + +export default DeviceInputPage; diff --git a/src/routes/auth/oauth/device/success/index.tsx b/src/routes/auth/oauth/device/success/index.tsx new file mode 100644 index 0000000000..ad1a18f61f --- /dev/null +++ b/src/routes/auth/oauth/device/success/index.tsx @@ -0,0 +1,3 @@ +import DeviceSuccessPage from '@/features/Auth/OAuthDevice/DeviceSuccessPage'; + +export default DeviceSuccessPage; diff --git a/src/routes/auth/reset-password/index.tsx b/src/routes/auth/reset-password/index.tsx new file mode 100644 index 0000000000..d1d29559e5 --- /dev/null +++ b/src/routes/auth/reset-password/index.tsx @@ -0,0 +1,3 @@ +import ResetPassword from '@/features/Auth/ResetPassword'; + +export default ResetPassword; diff --git a/src/routes/auth/signin/index.tsx b/src/routes/auth/signin/index.tsx new file mode 100644 index 0000000000..d21d7488fb --- /dev/null +++ b/src/routes/auth/signin/index.tsx @@ -0,0 +1,3 @@ +import SignIn from '@/features/Auth/SignIn'; + +export default SignIn; diff --git a/src/routes/auth/signup/index.tsx b/src/routes/auth/signup/index.tsx new file mode 100644 index 0000000000..94de63915d --- /dev/null +++ b/src/routes/auth/signup/index.tsx @@ -0,0 +1,3 @@ +import SignUp from '@/features/Auth/SignUp'; + +export default SignUp; diff --git a/src/routes/auth/verify-email/index.tsx b/src/routes/auth/verify-email/index.tsx new file mode 100644 index 0000000000..0c7a0afaa3 --- /dev/null +++ b/src/routes/auth/verify-email/index.tsx @@ -0,0 +1,3 @@ +import VerifyEmail from '@/features/Auth/VerifyEmail'; + +export default VerifyEmail; diff --git a/src/routes/verify-im/index.tsx b/src/routes/verify-im/index.tsx new file mode 100644 index 0000000000..eb712c1238 --- /dev/null +++ b/src/routes/verify-im/index.tsx @@ -0,0 +1,3 @@ +import MessengerVerifyStandalonePage from '@/features/Messenger/Verify/Standalone'; + +export default MessengerVerifyStandalonePage; diff --git a/src/server/spaHtml.test.ts b/src/server/spaHtml.test.ts new file mode 100644 index 0000000000..2863772a6e --- /dev/null +++ b/src/server/spaHtml.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildAnalyticsConfig, renderSpaHtml } from './spaHtml'; + +describe('renderSpaHtml', () => { + it('injects server config, seo meta and strips the analytics placeholder', async () => { + const template = [ + '', + '', + '', + '', + ].join('\n'); + + const res = renderSpaHtml(template, { + seoMeta: 'Hi', + serverConfig: { enableOIDC: true }, + }); + const html = await res.text(); + + expect(html).toContain('window.__SERVER_CONFIG__ = {"enableOIDC":true};'); + expect(html).toContain('Hi'); + expect(html).not.toContain('SEO_META'); + expect(html).not.toContain('ANALYTICS_SCRIPTS'); + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + expect(res.headers.get('Cache-Control')).toBe('no-cache'); + }); + + it('escapes script-breaking sequences in the server config', async () => { + const template = 'window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */'; + const res = renderSpaHtml(template, { + seoMeta: '', + serverConfig: { html: '' }, + }); + + expect(await res.text()).not.toContain(''); + }); +}); + +describe('buildAnalyticsConfig', () => { + afterEach(() => { + delete process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID; + delete process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL; + }); + + it('includes desktop analytics only when opted in', () => { + process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID = 'pid'; + process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL = 'https://umami.example.com'; + + expect(buildAnalyticsConfig().desktop).toBeUndefined(); + expect(buildAnalyticsConfig({ desktop: true }).desktop).toEqual({ + baseUrl: 'https://umami.example.com', + projectId: 'pid', + }); + }); +}); diff --git a/src/server/spaHtml.ts b/src/server/spaHtml.ts new file mode 100644 index 0000000000..0735c87228 --- /dev/null +++ b/src/server/spaHtml.ts @@ -0,0 +1,154 @@ +import { analyticsEnv } from '@/envs/analytics'; +import { serializeForHtml } from '@/server/utils/serializeForHtml'; +import { type AnalyticsConfig } from '@/types/spaServerConfig'; + +export const VITE_DEV_ORIGIN = 'http://localhost:9876'; + +const SERVER_CONFIG_PLACEHOLDER = + /window\.__SERVER_CONFIG__\s*=\s*undefined;\s*\/\*\s*SERVER_CONFIG\s*\*\//; + +async function rewriteViteAssetUrls(html: string, origin = VITE_DEV_ORIGIN): Promise { + const { parseHTML } = await import('linkedom'); + const { document } = parseHTML(html); + + document.querySelectorAll('script[src]').forEach((el: Element) => { + const src = el.getAttribute('src'); + if (src && src.startsWith('/')) { + el.setAttribute('src', `${origin}${src}`); + } + }); + + document.querySelectorAll('link[href]').forEach((el: Element) => { + const href = el.getAttribute('href'); + if (href && href.startsWith('/')) { + el.setAttribute('href', `${origin}${href}`); + } + }); + + document.querySelectorAll('script[type="module"]:not([src])').forEach((el: Element) => { + const text = el.textContent || ''; + if (text.includes('/@')) { + el.textContent = text.replaceAll( + /from\s+["'](\/[@\w].*?)["']/g, + (_match: string, p: string) => `from "${origin}${p}"`, + ); + } + }); + + const workerPatch = document.createElement('script'); + workerPatch.textContent = `(function(){ +var O=globalThis.Worker; +globalThis.Worker=function(u,o){ +var h=typeof u==='string'?u:u instanceof URL?u.href:''; +if(h.startsWith('${origin}')){ +var b=new Blob(['import "'+h+'";'],{type:'application/javascript'}); +return new O(URL.createObjectURL(b),Object.assign({},o,{type:'module'})); +}return new O(u,o)}; +globalThis.Worker.prototype=O.prototype; +})();`; + const head = document.querySelector('head'); + if (head?.firstChild) { + head.insertBefore(workerPatch, head.firstChild); + } + + return document.toString(); +} + +export async function fetchViteDevTemplate( + pathname = '/', + origin = VITE_DEV_ORIGIN, +): Promise { + const res = await fetch(`${origin}${pathname}`); + const html = await res.text(); + + return rewriteViteAssetUrls(html, origin); +} + +export function buildAnalyticsConfig(options: { desktop?: boolean } = {}): AnalyticsConfig { + const config: AnalyticsConfig = {}; + + if (analyticsEnv.ENABLE_GOOGLE_ANALYTICS && analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID) { + config.google = { measurementId: analyticsEnv.GOOGLE_ANALYTICS_MEASUREMENT_ID }; + } + + if (analyticsEnv.ENABLED_PLAUSIBLE_ANALYTICS && analyticsEnv.PLAUSIBLE_DOMAIN) { + config.plausible = { + domain: analyticsEnv.PLAUSIBLE_DOMAIN, + scriptBaseUrl: analyticsEnv.PLAUSIBLE_SCRIPT_BASE_URL, + }; + } + + if (analyticsEnv.ENABLED_UMAMI_ANALYTICS && analyticsEnv.UMAMI_WEBSITE_ID) { + config.umami = { + scriptUrl: analyticsEnv.UMAMI_SCRIPT_URL, + websiteId: analyticsEnv.UMAMI_WEBSITE_ID, + }; + } + + if (analyticsEnv.ENABLED_CLARITY_ANALYTICS && analyticsEnv.CLARITY_PROJECT_ID) { + config.clarity = { projectId: analyticsEnv.CLARITY_PROJECT_ID }; + } + + if (analyticsEnv.ENABLED_POSTHOG_ANALYTICS && analyticsEnv.POSTHOG_KEY) { + config.posthog = { + debug: analyticsEnv.DEBUG_POSTHOG_ANALYTICS, + host: analyticsEnv.POSTHOG_HOST, + key: analyticsEnv.POSTHOG_KEY, + }; + } + + if (analyticsEnv.ENABLED_X_ADS && analyticsEnv.X_ADS_PIXEL_ID) { + config.xAds = { + eventIds: { + login_or_signup_clicked: analyticsEnv.X_ADS_LOGIN_OR_SIGNUP_CLICKED_EVENT_ID, + main_page_view: analyticsEnv.X_ADS_MAIN_PAGE_VIEW_EVENT_ID, + }, + pixelId: analyticsEnv.X_ADS_PIXEL_ID, + purchaseEventId: analyticsEnv.X_ADS_PURCHASE_EVENT_ID, + }; + } + + if (analyticsEnv.REACT_SCAN_MONITOR_API_KEY) { + config.reactScan = { apiKey: analyticsEnv.REACT_SCAN_MONITOR_API_KEY }; + } + + if (analyticsEnv.ENABLE_VERCEL_ANALYTICS) { + config.vercel = { + debug: analyticsEnv.DEBUG_VERCEL_ANALYTICS, + enabled: true, + }; + } + + if ( + options.desktop && + process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID && + process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL + ) { + config.desktop = { + baseUrl: process.env.NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL, + projectId: process.env.NEXT_PUBLIC_DESKTOP_PROJECT_ID, + }; + } + + return config; +} + +export function renderSpaHtml( + template: string, + options: { seoMeta: string; serverConfig: unknown }, +): Response { + let html = template.replace( + SERVER_CONFIG_PLACEHOLDER, + `window.__SERVER_CONFIG__ = ${serializeForHtml(options.serverConfig)};`, + ); + + html = html.replace('', options.seoMeta); + html = html.replace('', ''); + + return new Response(html, { + headers: { + 'Cache-Control': 'no-cache', + 'content-type': 'text/html; charset=utf-8', + }, + }); +} diff --git a/src/spa/entry.auth.tsx b/src/spa/entry.auth.tsx new file mode 100644 index 0000000000..d7f1d541ef --- /dev/null +++ b/src/spa/entry.auth.tsx @@ -0,0 +1,19 @@ +import '../initialize'; + +import { createRoot } from 'react-dom/client'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; + +import BootErrorBoundary from '@/components/BootErrorBoundary'; +import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; + +import { authRoutes } from './router/authRouter.config'; + +const router = createBrowserRouter(authRoutes); + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/src/spa/entry.desktop.tsx b/src/spa/entry.desktop.tsx index d3c65b86ae..fd60178409 100644 --- a/src/spa/entry.desktop.tsx +++ b/src/spa/entry.desktop.tsx @@ -3,10 +3,15 @@ import '../initialize'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; import { createAppRouter } from '@/utils/router'; import { desktopRoutes } from './router/desktopRouter.config'; const router = createAppRouter(desktopRoutes); -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/spa/entry.mobile.tsx b/src/spa/entry.mobile.tsx index a7e934d91b..64526b32aa 100644 --- a/src/spa/entry.mobile.tsx +++ b/src/spa/entry.mobile.tsx @@ -3,10 +3,15 @@ import '../initialize'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; import { createAppRouter } from '@/utils/router'; import { mobileRoutes } from './router/mobileRouter.config'; const router = createAppRouter(mobileRoutes); -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/spa/entry.popup.tsx b/src/spa/entry.popup.tsx index f27f611e87..7141eef060 100644 --- a/src/spa/entry.popup.tsx +++ b/src/spa/entry.popup.tsx @@ -3,10 +3,15 @@ import '../initialize'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; +import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; import { createAppRouter } from '@/utils/router'; import { popupRoutes } from './router/popupRouter.config'; const router = createAppRouter(popupRoutes); -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/spa/entry.web.tsx b/src/spa/entry.web.tsx index b654037287..bb31c9b1df 100644 --- a/src/spa/entry.web.tsx +++ b/src/spa/entry.web.tsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import BootErrorBoundary from '@/components/BootErrorBoundary'; +import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider'; import { createAppRouter } from '@/utils/router'; import { desktopRoutes } from './router/desktopRouter.config'; @@ -18,6 +19,8 @@ const router = createAppRouter(desktopRoutes, { basename }); createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/src/spa/router/authRouter.config.tsx b/src/spa/router/authRouter.config.tsx new file mode 100644 index 0000000000..c06592903e --- /dev/null +++ b/src/spa/router/authRouter.config.tsx @@ -0,0 +1,166 @@ +import { useTheme } from 'next-themes'; +import type { ComponentType, CSSProperties, ReactElement } from 'react'; +import { lazy, Suspense } from 'react'; +import type { RouteObject } from 'react-router-dom'; +import { Outlet, useRouteError } from 'react-router-dom'; + +import Loading from '@/components/Loading/BrandTextLoading'; +import AuthShell from '@/features/AuthShell'; +import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError'; + +// Local helper on purpose: @/utils/router's dynamicElement would pull SPAGlobalProvider/global store into the auth bundle +const lazyElement = ( + importFn: () => Promise<{ default: ComponentType }>, + debugId: string, +): ReactElement => { + const LazyComponent = lazy(importFn); + + return ( + }> + + + ); +}; + +const buttonStyle: CSSProperties = { + background: 'transparent', + border: '1px solid currentcolor', + borderRadius: 6, + color: 'inherit', + cursor: 'pointer', + font: 'inherit', + padding: '6px 16px', +}; + +// Renders outside AuthShell (no i18n provider), so plain elements and English copy only +const AuthErrorBoundary = () => { + const error = useRouteError() as Error; + const { resolvedTheme } = useTheme(); + + if (typeof window !== 'undefined' && isChunkLoadError(error)) { + notifyChunkError(); + } + + // index.auth.html paints the body black in dark mode before React mounts + const isDark = resolvedTheme === 'dark'; + + return ( +
+

Something went wrong

+
+ + +
+
+ ); +}; + +export const authRoutes: RouteObject[] = [ + { + children: [ + { + element: lazyElement(() => import('@/routes/auth/signin'), 'Auth > SignIn'), + path: 'signin', + }, + { + element: lazyElement(() => import('@/routes/auth/signup'), 'Auth > SignUp'), + path: 'signup', + }, + { + element: lazyElement(() => import('@/routes/auth/verify-email'), 'Auth > VerifyEmail'), + path: 'verify-email', + }, + { + element: lazyElement(() => import('@/routes/auth/reset-password'), 'Auth > ResetPassword'), + path: 'reset-password', + }, + { + element: lazyElement(() => import('@/routes/auth/auth-error'), 'Auth > AuthError'), + path: 'auth-error', + }, + { + element: lazyElement( + () => import('@/routes/auth/market-auth-callback'), + 'Auth > MarketAuthCallback', + ), + path: 'market-auth-callback', + }, + { + element: lazyElement( + () => import('@/routes/auth/oauth/consent/[uid]'), + 'Auth > OAuthConsent', + ), + path: 'oauth/consent/:uid', + }, + { + element: lazyElement(() => import('@/routes/auth/oauth/device'), 'Auth > OAuthDevice'), + path: 'oauth/device', + }, + { + element: lazyElement( + () => import('@/routes/auth/oauth/device/confirm'), + 'Auth > OAuthDeviceConfirm', + ), + path: 'oauth/device/confirm', + }, + { + element: lazyElement( + () => import('@/routes/auth/oauth/device/success'), + 'Auth > OAuthDeviceSuccess', + ), + path: 'oauth/device/success', + }, + { + element: lazyElement( + () => import('@/routes/auth/oauth/callback/success'), + 'Auth > OAuthCallbackSuccess', + ), + path: 'oauth/callback/success', + }, + { + element: lazyElement( + () => import('@/routes/auth/oauth/callback/social'), + 'Auth > OAuthCallbackSocial', + ), + path: 'oauth/callback/social', + }, + { + element: lazyElement( + () => import('@/routes/auth/oauth/callback/error'), + 'Auth > OAuthCallbackError', + ), + path: 'oauth/callback/error', + }, + ], + element: ( + + + + ), + errorElement: , + path: '/', + }, +]; diff --git a/src/spa/router/desktopRouter.config.desktop.tsx b/src/spa/router/desktopRouter.config.desktop.tsx index 0a003fd938..05a1f8098d 100644 --- a/src/spa/router/desktopRouter.config.desktop.tsx +++ b/src/spa/router/desktopRouter.config.desktop.tsx @@ -115,6 +115,7 @@ import SharePagePage from '@/routes/share/page/[id]'; import ShareTopicPage from '@/routes/share/t/[id]'; import ShareTopicLayout from '@/routes/share/t/[id]/_layout'; import { shareTopicRouteMeta } from '@/routes/share/t/[id]/routeMeta'; +import VerifyImPage from '@/routes/verify-im'; import { routeMeta } from '@/spa/router/routeMeta'; import { SettingsTabs } from '@/store/global/initialState'; import { ErrorBoundary, redirectElement } from '@/utils/router'; @@ -713,6 +714,13 @@ export const desktopRoutes: RouteObject[] = [ path: '/share/page', }, + // Messenger verify route (outside main layout) + { + element: , + errorElement: , + path: '/verify-im', + }, + // Devtools route (outside main layout, dev-only) ...(__DEV__ ? [ diff --git a/src/spa/router/desktopRouter.config.tsx b/src/spa/router/desktopRouter.config.tsx index 6320664427..c8fd2d25f1 100644 --- a/src/spa/router/desktopRouter.config.tsx +++ b/src/spa/router/desktopRouter.config.tsx @@ -913,6 +913,13 @@ export const desktopRoutes: RouteObject[] = [ path: '/share/page', }, + // Messenger verify route (outside main layout) + { + element: dynamicElement(() => import('@/routes/verify-im'), 'Desktop > VerifyIm'), + errorElement: , + path: '/verify-im', + }, + // Devtools route (outside main layout, dev-only) ...(__DEV__ ? [ diff --git a/src/spa/router/mobileRouter.config.tsx b/src/spa/router/mobileRouter.config.tsx index 698a8fec37..2520292e54 100644 --- a/src/spa/router/mobileRouter.config.tsx +++ b/src/spa/router/mobileRouter.config.tsx @@ -524,4 +524,11 @@ export const mobileRoutes: RouteObject[] = [ ], path: '/share/page', }, + + // Messenger verify route (outside main layout) + { + element: dynamicElement(() => import('@/routes/verify-im'), 'Mobile > VerifyIm'), + errorElement: , + path: '/verify-im', + }, ]; diff --git a/src/types/oidc.ts b/src/types/oidc.ts new file mode 100644 index 0000000000..5b46f58ff0 --- /dev/null +++ b/src/types/oidc.ts @@ -0,0 +1,19 @@ +export interface OidcClientMetadata { + clientName?: string; + isFirstParty: boolean; + logo?: string; +} + +export interface OidcInteractionDetailsResponse { + clientId: string; + clientMetadata: OidcClientMetadata; + prompt: 'consent' | 'login'; + redirectUri?: string; + scopes: string[]; + uid: string; +} + +export interface OidcInteractionErrorResponse { + error: 'server_error' | 'session_invalid' | 'unsupported_interaction'; + promptName?: string; +} diff --git a/src/types/spaServerConfig.ts b/src/types/spaServerConfig.ts index 106fad9ab6..348e08fcf7 100644 --- a/src/types/spaServerConfig.ts +++ b/src/types/spaServerConfig.ts @@ -24,6 +24,14 @@ export interface SPAClientEnv { s3FilePath?: string; } +export interface AuthSPAServerConfig { + analyticsConfig: AnalyticsConfig; + config: GlobalServerConfig; + enableOIDC: boolean; + featureFlags: Partial; + globalCDN?: boolean; +} + export interface SPAServerConfig { analyticsConfig: AnalyticsConfig; clientEnv: SPAClientEnv; diff --git a/src/utils/onboardingRedirect.ts b/src/utils/onboardingRedirect.ts index 55ad7d5d3a..ed6625e0dd 100644 --- a/src/utils/onboardingRedirect.ts +++ b/src/utils/onboardingRedirect.ts @@ -27,6 +27,17 @@ const toRelativePath = (url: string): string => { return url; }; +/** + * Sanitize a user-supplied redirect target before it reaches + * `window.location.href`: same-origin absolute URLs are normalized to relative + * paths, anything unsafe (`javascript:`, `https://evil.com`, `//…`) falls back. + */ +export const sanitizeRedirectPath = (url: string | null | undefined, fallback = '/'): string => { + if (!url) return fallback; + const target = toRelativePath(url); + return isSafeRedirectPath(target) ? target : fallback; +}; + /** * Build the first-hop URL for a freshly signed-up user. New users always land * on onboarding first; the original target (if any) is threaded through the diff --git a/src/utils/router.tsx b/src/utils/router.tsx index 81cd9c73fc..3547f7d19f 100644 --- a/src/utils/router.tsx +++ b/src/utils/router.tsx @@ -15,6 +15,7 @@ import { import BusinessGlobalProvider from '@/business/client/BusinessGlobalProvider'; import ErrorCapture from '@/components/Error'; import Loading from '@/components/Loading/BrandTextLoading'; +import { useIsDark } from '@/hooks/useIsDark'; import SPAGlobalProvider from '@/layout/SPAGlobalProvider'; import { useGlobalStore } from '@/store/global'; import { createNavigationRef } from '@/store/global/initialState'; @@ -98,13 +99,20 @@ export interface ErrorBoundaryProps { export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => { const error = useRouteError() as Error; + const isDark = useIsDark(); + const appearance = isDark ? 'dark' : 'light'; if (typeof window !== 'undefined' && isChunkLoadError(error)) { notifyChunkError(); } return ( - + ); diff --git a/vite.config.ts b/vite.config.ts index f75018deb5..c99dfdcfed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,12 +18,13 @@ import { import { vercelSkewProtection } from './plugins/vite/vercelSkewProtection'; const isMobile = process.env.MOBILE === 'true'; +const isAuth = process.env.AUTH === 'true'; const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; Object.assign(process.env, loadEnv(mode, process.cwd(), '')); const isDev = process.env.NODE_ENV !== 'production'; -const platform = isMobile ? 'mobile' : 'web'; +const platform = isAuth ? 'auth' : isMobile ? 'mobile' : 'web'; const enableViteDevTools = process.env.LOBE_VITE_DEVTOOLS === 'true'; const resolveCommandExecutable = (cmd: string) => { @@ -101,14 +102,17 @@ const openExternalBrowser = async ( }; export default defineConfig({ - base: isDev ? '/' : process.env.VITE_CDN_BASE || '/_spa/', + base: isDev ? '/' : process.env.VITE_CDN_BASE || (isAuth ? '/_spa-auth/' : '/_spa/'), build: { modulePreload: sharedModulePreload, - outDir: isMobile ? 'dist/mobile' : 'dist/desktop', + outDir: isAuth ? 'dist/auth' : isMobile ? 'dist/mobile' : 'dist/desktop', reportCompressedSize: false, rolldownOptions: { ...(enableViteDevTools && { devtools: {} }), - input: path.resolve(__dirname, isMobile ? 'index.mobile.html' : 'index.html'), + input: path.resolve( + __dirname, + isAuth ? 'index.auth.html' : isMobile ? 'index.mobile.html' : 'index.html', + ), output: createSharedRolldownOutput({ strictExecutionOrder: true }), }, }, @@ -250,46 +254,47 @@ export default defineConfig({ }, }, - VitePWA({ - injectRegister: null, - manifest: false, - registerType: 'prompt', - workbox: { - globPatterns: ['**/*.{js,css,html,woff2}'], - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, - runtimeCaching: [ - { - handler: 'StaleWhileRevalidate', - options: { cacheName: 'google-fonts-stylesheets' }, - urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, - }, - { - handler: 'CacheFirst', - options: { - cacheName: 'google-fonts-webfonts', - expiration: { maxAgeSeconds: 60 * 60 * 24 * 365, maxEntries: 30 }, + !isAuth && + VitePWA({ + injectRegister: null, + manifest: false, + registerType: 'prompt', + workbox: { + globPatterns: ['**/*.{js,css,html,woff2}'], + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + runtimeCaching: [ + { + handler: 'StaleWhileRevalidate', + options: { cacheName: 'google-fonts-stylesheets' }, + urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, }, - urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, - }, - { - handler: 'StaleWhileRevalidate', - options: { - cacheName: 'image-assets', - expiration: { maxAgeSeconds: 60 * 60 * 24 * 30, maxEntries: 100 }, + { + handler: 'CacheFirst', + options: { + cacheName: 'google-fonts-webfonts', + expiration: { maxAgeSeconds: 60 * 60 * 24 * 365, maxEntries: 30 }, + }, + urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, }, - urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico|avif)$/i, - }, - { - handler: 'NetworkFirst', - options: { - cacheName: 'api-cache', - expiration: { maxAgeSeconds: 60 * 5, maxEntries: 50 }, + { + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'image-assets', + expiration: { maxAgeSeconds: 60 * 60 * 24 * 30, maxEntries: 100 }, + }, + urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico|avif)$/i, }, - urlPattern: /\/(api|trpc)\/.*/i, - }, - ], - }, - }), + { + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { maxAgeSeconds: 60 * 5, maxEntries: 50 }, + }, + urlPattern: /\/(api|trpc)\/.*/i, + }, + ], + }, + }), ].filter(Boolean) as PluginOption[], server: {