From 4a11ed988713521eb13bf8ab6eb66576ec7d138c Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 13 Jun 2026 16:15:04 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(auth):=20migrate?= =?UTF-8?q?=20auth=20pages=20to=20a=20standalone=20lightweight=20SPA=20(#1?= =?UTF-8?q?5689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat(oidc): add interaction details endpoint * ✨ feat(auth-spa): scaffold standalone auth SPA shell and build pipeline * πŸ› fix(auth-spa): address review findings in AuthShell copies * ✨ feat(auth-spa): add spa-auth html route handler * ♻️ refactor(auth-spa): migrate simple auth pages into auth SPA * πŸ”’ fix(auth-spa): validate locale segment in spa-auth route * ♻️ refactor(auth-spa): move verify-im route to main SPA * πŸ”’ fix(auth-spa): sanitize callbackUrl, fix signup form wiring, add router error element * ♻️ refactor(auth-spa): migrate oauth pages into auth SPA * πŸ› fix(auth-spa): address oauth migration review findings * ♻️ refactor(auth): route auth pages to standalone SPA and drop Next auth tree * πŸ”’ fix(auth): validate locale before middleware rewrite * πŸ”₯ chore(auth-spa): drop unused messenger i18n namespace from auth shell * ⚑️ perf(build): share one react vendor bundle across web/mobile/auth SPA builds Build react core (react, react-dom, react-dom/client, react/jsx-runtime) once as a self-contained ESM bundle under /_spa/vendor-shared, then mark those specifiers external in every SPA build and map them via rolldown output.paths to the same hashed URLs, so the auth page warms the main app's react cache. react-router-dom stays per-build: apps use ~19K of it after tree shaking while a shared bundle must export all 252K. Also split auth i18n namespaces into per-locale chunks, keep locale runtime helpers out of the default locale chunk, and group packages/const into app-const so vendor-ai-runtime no longer captures it. * ♻️ refactor(spa): extract shared SPA html serving helpers Both the main SPA and auth SPA route handlers duplicated the Vite dev asset rewriting, analytics config assembly and html template rendering. Move them into src/server/spaHtml.ts; the desktop umami block becomes an opt-in flag only the main SPA enables. * πŸ› fix(auth-spa): bundle default locale resources and disable i18n suspense to fix signin mount loop * ✨ feat(auth-spa): wrap auth shell with BusinessAuthProvider slot * πŸ‘· build(spa): support custom vite dev origin and mark SPA entries side-effectful * πŸ”₯ chore: drop dead /welcome entry from nextjsOnlyRoutes * πŸ› fix(auth-spa): forward referral to signup and fix error boundary dark-mode contrast * ♻️ refactor(spa): lift NextThemeProvider above RouterProvider so route error boundaries are theme-aware * update --- .gitignore | 2 + index.auth.html | 95 ++++++++++ package.json | 15 +- plugins/vite/platformResolve.ts | 4 +- plugins/vite/routeChunkPreload.test.ts | 25 +++ plugins/vite/routeChunkPreload.ts | 2 + plugins/vite/sharedRendererConfig.test.ts | 26 +++ plugins/vite/sharedRendererConfig.ts | 28 ++- scripts/copySpaBuild.mts | 14 +- scripts/generateSpaTemplates.mts | 18 ++ .../oidc/interaction/[uid]/route.test.ts | 156 ++++++++++++++++ .../(backend)/oidc/interaction/[uid]/route.ts | 71 ++++++++ .../(auth)/_layout/AuthGlobalProvider.tsx | 48 ----- .../(auth)/_layout/createAuthI18n.ts | 116 ------------ src/app/[variants]/(auth)/layout.tsx | 27 --- src/app/[variants]/(auth)/loading.tsx | 3 - .../(auth)/oauth/callback/error/page.tsx | 44 ----- .../(auth)/oauth/consent/[uid]/page.tsx | 88 ---------- .../(auth)/oauth/device/confirm/page.tsx | 30 ---- .../[variants]/(auth)/oauth/device/page.tsx | 41 ----- .../(auth)/oauth/device/success/page.tsx | 13 -- .../(auth)/reset-password/layout.tsx | 14 -- src/app/[variants]/(auth)/signin/layout.tsx | 22 --- src/app/[variants]/(auth)/signin/page.tsx | 60 ------- .../(auth)/signup/[[...signup]]/page.tsx | 30 ---- src/app/[variants]/(auth)/verify-im/page.tsx | 3 - .../spa-auth/[locale]/[[...path]]/route.ts | 47 +++++ .../[locale]/[[...path]]/seoMeta.test.ts | 70 ++++++++ .../spa-auth/[locale]/[[...path]]/seoMeta.ts | 66 +++++++ src/app/spa-auth/authHtmlTemplate.d.ts | 1 + src/app/spa/[variants]/[[...path]]/route.ts | 161 +---------------- src/business/client/WorkspaceContextSlot.tsx | 2 +- .../client/hooks/useBusinessSignup.tsx | 2 +- .../Auth/AuthError/index.tsx} | 16 +- .../Auth/MarketAuthCallback/index.tsx} | 4 - src/features/Auth/OAuthCallback/Error.tsx | 61 +++++++ .../Auth/OAuthCallback/Social.tsx} | 4 +- .../Auth/OAuthCallback/Success.tsx} | 4 +- .../Auth/OAuthConsent}/ClientError.tsx | 0 .../OAuthConsent}/Consent/BuiltinConsent.tsx | 0 .../Auth/OAuthConsent}/Consent/index.tsx | 10 +- .../Auth/OAuthConsent}/Login.tsx | 9 +- .../OAuthConsent}/OAuthApplicationLogo.tsx | 0 src/features/Auth/OAuthConsent/index.tsx | 95 ++++++++++ .../useInteractionDetails.test.ts | 72 ++++++++ .../OAuthConsent/useInteractionDetails.ts | 41 +++++ .../Auth/OAuthDevice}/DeviceCodeConfirm.tsx | 0 .../Auth/OAuthDevice}/DeviceCodeInput.tsx | 0 .../Auth/OAuthDevice/DeviceConfirmPage.tsx | 37 ++++ .../Auth/OAuthDevice/DeviceInputPage.tsx | 43 +++++ .../Auth/OAuthDevice}/DeviceSuccess.tsx | 0 .../Auth/OAuthDevice/DeviceSuccessPage.tsx | 16 ++ src/features/Auth/OAuthGuard.tsx | 18 ++ .../ResetPassword}/ResetPasswordContent.tsx | 0 .../Auth/ResetPassword/index.tsx} | 20 ++- .../Auth/ResetPassword}/useResetPassword.ts | 0 .../Auth/SignIn}/SignInEmailStep.tsx | 5 +- .../Auth/SignIn}/SignInPasswordStep.tsx | 2 +- src/features/Auth/SignIn/index.tsx | 52 ++++++ .../Auth/SignIn}/useSignIn.test.ts | 61 +++++-- .../Auth/SignIn}/useSignIn.ts | 25 ++- .../Auth/SignUp}/BetterAuthSignUpForm.tsx | 21 ++- src/features/Auth/SignUp/index.tsx | 19 ++ .../Auth/SignUp}/types.ts | 0 .../Auth/SignUp}/useSignUp.test.ts | 47 +++-- .../Auth/SignUp}/useSignUp.ts | 22 +-- .../Auth/VerifyEmail}/VerifyEmailContent.tsx | 0 .../Auth/VerifyEmail/index.tsx} | 10 +- .../Auth/VerifyEmail}/useVerifyEmail.ts | 0 .../Auth}/utils/authFetchOptions.ts | 0 .../AuthShell}/AuthAgreement.tsx | 4 +- .../AuthShell/AuthContainer.tsx} | 5 +- .../AuthShell}/AuthFooterLinks.tsx | 0 .../AuthShell}/AuthLangButton.tsx | 0 .../AuthShell}/AuthLocale.tsx | 6 +- .../AuthShell}/AuthServerConfigProvider.tsx | 8 +- src/features/AuthShell/AuthShell.tsx | 45 +++++ .../AuthShell}/AuthThemeButton.tsx | 0 .../AuthShell}/AuthThemeLite.tsx | 2 +- src/features/AuthShell/createAuthI18n.ts | 108 ++++++++++++ src/features/AuthShell/index.ts | 4 + .../_layout => features/AuthShell}/style.ts | 3 - src/features/Messenger/Verify/Standalone.tsx | 13 ++ src/features/Messenger/Verify/index.tsx | 4 +- src/layout/SPAGlobalProvider/index.tsx | 91 +++++----- src/libs/next/Link.tsx | 6 +- src/libs/next/nextjsOnlyRoutes.ts | 12 +- src/libs/next/proxy/define-config.test.ts | 39 ++++ src/libs/next/proxy/define-config.ts | 86 +++++---- src/routes/auth/auth-error/index.tsx | 3 + .../auth/market-auth-callback/index.tsx | 3 + .../auth/oauth/callback/error/index.tsx | 3 + .../auth/oauth/callback/social/index.tsx | 3 + .../auth/oauth/callback/success/index.tsx | 3 + src/routes/auth/oauth/consent/[uid]/index.tsx | 3 + .../auth/oauth/device/confirm/index.tsx | 3 + src/routes/auth/oauth/device/index.tsx | 3 + .../auth/oauth/device/success/index.tsx | 3 + src/routes/auth/reset-password/index.tsx | 3 + src/routes/auth/signin/index.tsx | 3 + src/routes/auth/signup/index.tsx | 3 + src/routes/auth/verify-email/index.tsx | 3 + src/routes/verify-im/index.tsx | 3 + src/server/spaHtml.test.ts | 56 ++++++ src/server/spaHtml.ts | 154 ++++++++++++++++ src/spa/entry.auth.tsx | 19 ++ src/spa/entry.desktop.tsx | 7 +- src/spa/entry.mobile.tsx | 7 +- src/spa/entry.popup.tsx | 7 +- src/spa/entry.web.tsx | 5 +- src/spa/router/authRouter.config.tsx | 166 ++++++++++++++++++ .../router/desktopRouter.config.desktop.tsx | 8 + src/spa/router/desktopRouter.config.tsx | 7 + src/spa/router/mobileRouter.config.tsx | 7 + src/types/oidc.ts | 19 ++ src/types/spaServerConfig.ts | 8 + src/utils/onboardingRedirect.ts | 11 ++ src/utils/router.tsx | 10 +- vite.config.ts | 87 ++++----- 119 files changed, 2145 insertions(+), 969 deletions(-) create mode 100644 index.auth.html create mode 100644 src/app/(backend)/oidc/interaction/[uid]/route.test.ts create mode 100644 src/app/(backend)/oidc/interaction/[uid]/route.ts delete mode 100644 src/app/[variants]/(auth)/_layout/AuthGlobalProvider.tsx delete mode 100644 src/app/[variants]/(auth)/_layout/createAuthI18n.ts delete mode 100644 src/app/[variants]/(auth)/layout.tsx delete mode 100644 src/app/[variants]/(auth)/loading.tsx delete mode 100644 src/app/[variants]/(auth)/oauth/callback/error/page.tsx delete mode 100644 src/app/[variants]/(auth)/oauth/consent/[uid]/page.tsx delete mode 100644 src/app/[variants]/(auth)/oauth/device/confirm/page.tsx delete mode 100644 src/app/[variants]/(auth)/oauth/device/page.tsx delete mode 100644 src/app/[variants]/(auth)/oauth/device/success/page.tsx delete mode 100644 src/app/[variants]/(auth)/reset-password/layout.tsx delete mode 100644 src/app/[variants]/(auth)/signin/layout.tsx delete mode 100644 src/app/[variants]/(auth)/signin/page.tsx delete mode 100644 src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx delete mode 100644 src/app/[variants]/(auth)/verify-im/page.tsx create mode 100644 src/app/spa-auth/[locale]/[[...path]]/route.ts create mode 100644 src/app/spa-auth/[locale]/[[...path]]/seoMeta.test.ts create mode 100644 src/app/spa-auth/[locale]/[[...path]]/seoMeta.ts create mode 100644 src/app/spa-auth/authHtmlTemplate.d.ts rename src/{app/[variants]/(auth)/auth-error/page.tsx => features/Auth/AuthError/index.tsx} (82%) rename src/{app/[variants]/(auth)/market-auth-callback/page.tsx => features/Auth/MarketAuthCallback/index.tsx} (97%) create mode 100644 src/features/Auth/OAuthCallback/Error.tsx rename src/{app/[variants]/(auth)/oauth/callback/social/page.tsx => features/Auth/OAuthCallback/Social.tsx} (96%) rename src/{app/[variants]/(auth)/oauth/callback/success/page.tsx => features/Auth/OAuthCallback/Success.tsx} (94%) rename src/{app/[variants]/(auth)/oauth/consent/[uid] => features/Auth/OAuthConsent}/ClientError.tsx (100%) rename src/{app/[variants]/(auth)/oauth/consent/[uid] => features/Auth/OAuthConsent}/Consent/BuiltinConsent.tsx (100%) rename src/{app/[variants]/(auth)/oauth/consent/[uid] => features/Auth/OAuthConsent}/Consent/index.tsx (94%) rename src/{app/[variants]/(auth)/oauth/consent/[uid] => features/Auth/OAuthConsent}/Login.tsx (94%) rename src/{app/[variants]/(auth)/oauth/consent/[uid]/components => features/Auth/OAuthConsent}/OAuthApplicationLogo.tsx (100%) create mode 100644 src/features/Auth/OAuthConsent/index.tsx create mode 100644 src/features/Auth/OAuthConsent/useInteractionDetails.test.ts create mode 100644 src/features/Auth/OAuthConsent/useInteractionDetails.ts rename src/{app/[variants]/(auth)/oauth/device/confirm => features/Auth/OAuthDevice}/DeviceCodeConfirm.tsx (100%) rename src/{app/[variants]/(auth)/oauth/device => features/Auth/OAuthDevice}/DeviceCodeInput.tsx (100%) create mode 100644 src/features/Auth/OAuthDevice/DeviceConfirmPage.tsx create mode 100644 src/features/Auth/OAuthDevice/DeviceInputPage.tsx rename src/{app/[variants]/(auth)/oauth/device/success => features/Auth/OAuthDevice}/DeviceSuccess.tsx (100%) create mode 100644 src/features/Auth/OAuthDevice/DeviceSuccessPage.tsx create mode 100644 src/features/Auth/OAuthGuard.tsx rename src/{app/[variants]/(auth)/reset-password => features/Auth/ResetPassword}/ResetPasswordContent.tsx (100%) rename src/{app/[variants]/(auth)/reset-password/page.tsx => features/Auth/ResetPassword/index.tsx} (60%) rename src/{app/[variants]/(auth)/reset-password => features/Auth/ResetPassword}/useResetPassword.ts (100%) rename src/{app/[variants]/(auth)/signin => features/Auth/SignIn}/SignInEmailStep.tsx (98%) rename src/{app/[variants]/(auth)/signin => features/Auth/SignIn}/SignInPasswordStep.tsx (98%) create mode 100644 src/features/Auth/SignIn/index.tsx rename src/{app/[variants]/(auth)/signin => features/Auth/SignIn}/useSignIn.test.ts (89%) rename src/{app/[variants]/(auth)/signin => features/Auth/SignIn}/useSignIn.ts (92%) rename src/{app/[variants]/(auth)/signup/[[...signup]] => features/Auth/SignUp}/BetterAuthSignUpForm.tsx (86%) create mode 100644 src/features/Auth/SignUp/index.tsx rename src/{app/[variants]/(auth)/signup/[[...signup]] => features/Auth/SignUp}/types.ts (100%) rename src/{app/[variants]/(auth)/signup/[[...signup]] => features/Auth/SignUp}/useSignUp.test.ts (86%) rename src/{app/[variants]/(auth)/signup/[[...signup]] => features/Auth/SignUp}/useSignUp.ts (84%) rename src/{app/[variants]/(auth)/verify-email => features/Auth/VerifyEmail}/VerifyEmailContent.tsx (100%) rename src/{app/[variants]/(auth)/verify-email/page.tsx => features/Auth/VerifyEmail/index.tsx} (80%) rename src/{app/[variants]/(auth)/verify-email => features/Auth/VerifyEmail}/useVerifyEmail.ts (100%) rename src/{app/[variants]/(auth) => features/Auth}/utils/authFetchOptions.ts (100%) rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthAgreement.tsx (100%) rename src/{app/[variants]/(auth)/_layout/index.tsx => features/AuthShell/AuthContainer.tsx} (91%) rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthFooterLinks.tsx (100%) rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthLangButton.tsx (100%) rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthLocale.tsx (88%) rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthServerConfigProvider.tsx (88%) create mode 100644 src/features/AuthShell/AuthShell.tsx rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthThemeButton.tsx (100%) rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/AuthThemeLite.tsx (97%) create mode 100644 src/features/AuthShell/createAuthI18n.ts create mode 100644 src/features/AuthShell/index.ts rename src/{app/[variants]/(auth)/_layout => features/AuthShell}/style.ts (88%) create mode 100644 src/features/Messenger/Verify/Standalone.tsx create mode 100644 src/libs/next/proxy/define-config.test.ts create mode 100644 src/routes/auth/auth-error/index.tsx create mode 100644 src/routes/auth/market-auth-callback/index.tsx create mode 100644 src/routes/auth/oauth/callback/error/index.tsx create mode 100644 src/routes/auth/oauth/callback/social/index.tsx create mode 100644 src/routes/auth/oauth/callback/success/index.tsx create mode 100644 src/routes/auth/oauth/consent/[uid]/index.tsx create mode 100644 src/routes/auth/oauth/device/confirm/index.tsx create mode 100644 src/routes/auth/oauth/device/index.tsx create mode 100644 src/routes/auth/oauth/device/success/index.tsx create mode 100644 src/routes/auth/reset-password/index.tsx create mode 100644 src/routes/auth/signin/index.tsx create mode 100644 src/routes/auth/signup/index.tsx create mode 100644 src/routes/auth/verify-email/index.tsx create mode 100644 src/routes/verify-im/index.tsx create mode 100644 src/server/spaHtml.test.ts create mode 100644 src/server/spaHtml.ts create mode 100644 src/spa/entry.auth.tsx create mode 100644 src/spa/router/authRouter.config.tsx create mode 100644 src/types/oidc.ts 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: {