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={
-
+
-
+
-
-
+
+
} type="text">
{t('actions.discord')}
-
+
}
>
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: {