mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
♻️ refactor(auth): migrate auth pages to a standalone lightweight SPA (#15689)
* ✨ feat(oidc): add interaction details endpoint * ✨ feat(auth-spa): scaffold standalone auth SPA shell and build pipeline * 🐛 fix(auth-spa): address review findings in AuthShell copies * ✨ feat(auth-spa): add spa-auth html route handler * ♻️ refactor(auth-spa): migrate simple auth pages into auth SPA * 🔒 fix(auth-spa): validate locale segment in spa-auth route * ♻️ refactor(auth-spa): move verify-im route to main SPA * 🔒 fix(auth-spa): sanitize callbackUrl, fix signup form wiring, add router error element * ♻️ refactor(auth-spa): migrate oauth pages into auth SPA * 🐛 fix(auth-spa): address oauth migration review findings * ♻️ refactor(auth): route auth pages to standalone SPA and drop Next auth tree * 🔒 fix(auth): validate locale before middleware rewrite * 🔥 chore(auth-spa): drop unused messenger i18n namespace from auth shell * ⚡️ perf(build): share one react vendor bundle across web/mobile/auth SPA builds Build react core (react, react-dom, react-dom/client, react/jsx-runtime) once as a self-contained ESM bundle under /_spa/vendor-shared, then mark those specifiers external in every SPA build and map them via rolldown output.paths to the same hashed URLs, so the auth page warms the main app's react cache. react-router-dom stays per-build: apps use ~19K of it after tree shaking while a shared bundle must export all 252K. Also split auth i18n namespaces into per-locale chunks, keep locale runtime helpers out of the default locale chunk, and group packages/const into app-const so vendor-ai-runtime no longer captures it. * ♻️ refactor(spa): extract shared SPA html serving helpers Both the main SPA and auth SPA route handlers duplicated the Vite dev asset rewriting, analytics config assembly and html template rendering. Move them into src/server/spaHtml.ts; the desktop umami block becomes an opt-in flag only the main SPA enables. * 🐛 fix(auth-spa): bundle default locale resources and disable i18n suspense to fix signin mount loop * ✨ feat(auth-spa): wrap auth shell with BusinessAuthProvider slot * 👷 build(spa): support custom vite dev origin and mark SPA entries side-effectful * 🔥 chore: drop dead /welcome entry from nextjsOnlyRoutes * 🐛 fix(auth-spa): forward referral to signup and fix error boundary dark-mode contrast * ♻️ refactor(spa): lift NextThemeProvider above RouterProvider so route error boundaries are theme-aware * update
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon-32x32.ico" />
|
||||
<!--SEO_META-->
|
||||
<style>
|
||||
html body {
|
||||
background: #f8f8f8;
|
||||
}
|
||||
html[data-theme='dark'] body {
|
||||
background-color: #000;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
function supportsImportMaps() {
|
||||
return (
|
||||
typeof HTMLScriptElement !== 'undefined' &&
|
||||
typeof HTMLScriptElement.supports === 'function' &&
|
||||
HTMLScriptElement.supports('importmap')
|
||||
);
|
||||
}
|
||||
|
||||
function supportsCascadeLayers() {
|
||||
var el = document.createElement('div');
|
||||
el.className = '__layer_test__';
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-99999px';
|
||||
el.style.top = '-99999px';
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.textContent =
|
||||
'@layer a, b;' +
|
||||
'@layer a { .__layer_test__ { color: rgb(1, 2, 3); } }' +
|
||||
'@layer b { .__layer_test__ { color: rgb(4, 5, 6); } }';
|
||||
|
||||
document.documentElement.append(style);
|
||||
document.documentElement.append(el);
|
||||
|
||||
var color = getComputedStyle(el).color;
|
||||
|
||||
el.remove();
|
||||
style.remove();
|
||||
|
||||
return color === 'rgb(4, 5, 6)';
|
||||
}
|
||||
|
||||
if (!(supportsImportMaps() && supportsCascadeLayers())) {
|
||||
window.location.href = '/not-compatible.html';
|
||||
return;
|
||||
}
|
||||
|
||||
var theme = 'system';
|
||||
try {
|
||||
theme = localStorage.getItem('theme') || 'system';
|
||||
} catch (_) {}
|
||||
var systemTheme =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
var resolvedTheme = theme === 'system' ? systemTheme : theme;
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
|
||||
var hl = new URLSearchParams(location.search).get('hl');
|
||||
var m = document.cookie.match(/(?:^|;\s*)LOBE_LOCALE=([^;]*)/);
|
||||
var cookie = m ? decodeURIComponent(m[1]) : '';
|
||||
var locale = hl || cookie || navigator.language || 'en-US';
|
||||
if (locale === 'auto') locale = navigator.language || 'en-US';
|
||||
if (hl && !cookie) {
|
||||
document.cookie =
|
||||
'LOBE_LOCALE=' + encodeURIComponent(hl) + ';path=/;max-age=7776000;SameSite=Lax';
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
|
||||
document.documentElement.dir =
|
||||
rtl.indexOf(locale.split('-')[0].toLowerCase()) >= 0 ? 'rtl' : 'ltr';
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
window.__SERVER_CONFIG__ = undefined; /* SERVER_CONFIG */
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root" style="height: 100%"></div>
|
||||
|
||||
<!--ANALYTICS_SCRIPTS-->
|
||||
<script type="module" src="/src/spa/entry.auth.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+11
-4
@@ -25,7 +25,12 @@
|
||||
"license": "MIT",
|
||||
"author": "LobeHub <i@lobehub.com>",
|
||||
"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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = '<html><head></head><body></body></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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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';
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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<OidcInteractionErrorResponse>(
|
||||
{ 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<OidcInteractionDetailsResponse>({
|
||||
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<OidcInteractionErrorResponse>(
|
||||
{ error: 'session_invalid' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
log('Error handling OIDC interaction: %O', error);
|
||||
return NextResponse.json<OidcInteractionErrorResponse>(
|
||||
{ error: 'server_error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<StyleRegistry>
|
||||
<AuthLocale defaultLang={locale}>
|
||||
<NextThemeProvider>
|
||||
<AuthThemeLite globalCDN={appEnv.CDN_USE_GLOBAL}>
|
||||
<AuthServerConfigProvider
|
||||
featureFlags={featureFlags}
|
||||
isMobile={isMobile}
|
||||
segmentVariants={variants}
|
||||
serverConfig={serverConfig}
|
||||
>
|
||||
<AnalyticsRSCProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</AnalyticsRSCProvider>
|
||||
</AuthServerConfigProvider>
|
||||
</AuthThemeLite>
|
||||
</NextThemeProvider>
|
||||
</AuthLocale>
|
||||
</StyleRegistry>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthGlobalProvider;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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<DynamicLayoutProps>) => {
|
||||
const { variants } = await params;
|
||||
|
||||
return (
|
||||
<AuthGlobalProvider variants={variants}>
|
||||
<ClientOnly>
|
||||
<NuqsAdapter>
|
||||
<BusinessAuthProvider>
|
||||
<AuthContainer>{children}</AuthContainer>
|
||||
</BusinessAuthProvider>
|
||||
</NuqsAdapter>
|
||||
</ClientOnly>
|
||||
</AuthGlobalProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
@@ -1,3 +0,0 @@
|
||||
import Loading from '@/components/Loading/BrandTextLoading';
|
||||
|
||||
export default () => <Loading debugId="Auth" />;
|
||||
@@ -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<string>('errorMessage', parseAsString);
|
||||
|
||||
return (
|
||||
<Result
|
||||
icon={<FluentEmoji emoji={'🥵'} size={96} type={'anim'} />}
|
||||
status="error"
|
||||
extra={
|
||||
<Link href="/public">
|
||||
<Button block size={'large'} style={{ minWidth: 240 }}>
|
||||
{t('error.backToHome')}
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
subTitle={
|
||||
<Flexbox gap={8}>
|
||||
<Text fontSize={16} type="secondary">
|
||||
{t('error.desc', {
|
||||
reason: t(`error.reason.${reason}` as any, { defaultValue: reason ?? '' }),
|
||||
})}
|
||||
</Text>
|
||||
{!!errorMessage && <Highlighter language={'log'}>{errorMessage}</Highlighter>}
|
||||
</Flexbox>
|
||||
}
|
||||
title={
|
||||
<Text fontSize={32} weight={'bold'}>
|
||||
{t('error.title')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailedPage;
|
||||
@@ -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 (
|
||||
<ConsentClientError
|
||||
error={{
|
||||
messageKey: 'consent.error.unsupportedInteraction.message',
|
||||
titleKey: 'consent.error.unsupportedInteraction.title',
|
||||
values: { promptName: details.prompt.name },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <Login clientMetadata={clientMetadata} uid={params.uid} />;
|
||||
|
||||
return (
|
||||
<Consent
|
||||
clientId={clientId}
|
||||
clientMetadata={clientMetadata}
|
||||
redirectUri={details.params.redirect_uri as string}
|
||||
scopes={scopes}
|
||||
uid={params.uid}
|
||||
/>
|
||||
);
|
||||
} 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 (
|
||||
<ConsentClientError
|
||||
error={{
|
||||
messageKey: 'consent.error.sessionInvalid.message',
|
||||
titleKey: 'consent.error.sessionInvalid.title',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConsentClientError
|
||||
error={{
|
||||
message: errorMessage,
|
||||
messageKey: errorMessage ? undefined : 'consent.error.unknown.message',
|
||||
titleKey: 'consent.error.title',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default InteractionPage;
|
||||
@@ -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 (
|
||||
<DeviceCodeConfirm
|
||||
clientName={searchParams.client_name || searchParams.client_id || 'Unknown Application'}
|
||||
userCode={searchParams.user_code}
|
||||
xsrf={searchParams.xsrf}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceConfirmPage;
|
||||
@@ -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<string, string> = {
|
||||
'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 (
|
||||
<DeviceCodeInput
|
||||
errorKey={getErrorMessage(searchParams.error)}
|
||||
userCode={searchParams.user_code}
|
||||
xsrf={searchParams.xsrf}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceInputPage;
|
||||
@@ -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 <DeviceSuccess />;
|
||||
};
|
||||
|
||||
export default DeviceSuccessPage;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<Suspense fallback={<Loading debugId={'Signin'} />}>
|
||||
{step === 'email' ? (
|
||||
<SignInEmailStep
|
||||
disableEmailPassword={disableEmailPassword}
|
||||
form={form as any}
|
||||
isSocialOnly={isSocialOnly}
|
||||
lastAuthProvider={lastAuthProvider}
|
||||
loading={loading}
|
||||
oAuthSSOProviders={oAuthSSOProviders}
|
||||
serverConfigInit={serverConfigInit}
|
||||
socialLoading={socialLoading}
|
||||
onCheckUser={handleCheckUser}
|
||||
onSetPassword={handleForgotPassword}
|
||||
onSocialSignIn={handleSocialSignIn}
|
||||
/>
|
||||
) : (
|
||||
<SignInPasswordStep
|
||||
email={email}
|
||||
form={form as any}
|
||||
loading={loading}
|
||||
onBackToEmail={handleBackToEmail}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
onSubmit={handleSignIn}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInPage;
|
||||
@@ -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 <BetterAuthSignUpForm />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,3 +0,0 @@
|
||||
import MessengerVerifyPage from '@/features/Messenger/Verify';
|
||||
|
||||
export default MessengerVerifyPage;
|
||||
@@ -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<string> {
|
||||
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 });
|
||||
}
|
||||
@@ -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('<title>Sign In</title>');
|
||||
expect(meta).toContain('property="og:url" content="https://app.lobehub.com/signin"');
|
||||
});
|
||||
|
||||
it('normalizes hostile locale input to an allowlisted value', async () => {
|
||||
const hostile = '"><script>alert(1)</script>';
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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<AuthSeoEntry> {
|
||||
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<string> {
|
||||
const lng = normalizeLocale(locale);
|
||||
const { title, description, canonicalPath } = await buildAuthSeoEntry(lng, pathname);
|
||||
const ogUrl = canonicalPath ? urlJoin(OFFICIAL_URL, canonicalPath) : OFFICIAL_URL;
|
||||
|
||||
return [
|
||||
`<title>${title}</title>`,
|
||||
`<meta name="description" content="${description}" />`,
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${description}" />`,
|
||||
`<meta property="og:type" content="website" />`,
|
||||
`<meta property="og:url" content="${ogUrl}" />`,
|
||||
`<meta property="og:image" content="${OG_URL}" />`,
|
||||
`<meta property="og:site_name" content="${BRANDING_NAME}" />`,
|
||||
`<meta property="og:locale" content="${lng}" />`,
|
||||
`<meta name="twitter:card" content="summary_large_image" />`,
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${description}" />`,
|
||||
`<meta name="twitter:image" content="${OG_URL}" />`,
|
||||
`<meta name="twitter:site" content="${isCustomORG ? `@${ORG_NAME}` : '@lobehub'}" />`,
|
||||
].join('\n ');
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare const authHtmlTemplate: string;
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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('<!--SEO_META-->', seoMeta);
|
||||
html = html.replace('<!--ANALYTICS_SCRIPTS-->', '');
|
||||
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
return renderSpaHtml(template, { seoMeta, serverConfig: spaConfig });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
export default function WorkspaceContextSlot({ children }: PropsWithChildren) {
|
||||
return <>{children}</>;
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
+8
-8
@@ -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={
|
||||
<Flexbox gap={12} justify="center" wrap="wrap">
|
||||
<Link href="/signin">
|
||||
<Link to="/signin">
|
||||
<Button block size={'large'} type="primary">
|
||||
{t('actions.retry')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<a href={'/'}>
|
||||
<Button block size={'large'}>
|
||||
{t('actions.home')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={SOCIAL_URL.discord} rel="noopener noreferrer" target="_blank">
|
||||
</a>
|
||||
<a href={SOCIAL_URL.discord} rel="noopener noreferrer" target="_blank">
|
||||
<Button block icon={<Icon fill={cssVar.colorText} icon={SiDiscord} />} type="text">
|
||||
{t('actions.discord')}
|
||||
</Button>
|
||||
</Link>
|
||||
</a>
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
-4
@@ -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<CallbackStatus>('loading');
|
||||
@@ -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 (
|
||||
<Result
|
||||
icon={<FluentEmoji emoji={'🥵'} size={96} type={'anim'} />}
|
||||
status="error"
|
||||
extra={
|
||||
<a href="/">
|
||||
<Button block size={'large'} style={{ minWidth: 240 }}>
|
||||
{t('error.backToHome')}
|
||||
</Button>
|
||||
</a>
|
||||
}
|
||||
subTitle={
|
||||
<Flexbox gap={8}>
|
||||
<Text fontSize={16} type="secondary">
|
||||
{t('error.desc', {
|
||||
reason: t(`error.reason.${reason}` as any, { defaultValue: reason ?? '' }),
|
||||
})}
|
||||
</Text>
|
||||
{!!errorMessage && (
|
||||
<Block padding={12} style={{ maxHeight: 240, overflowY: 'auto' }} variant={'filled'}>
|
||||
<pre
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
margin: 0,
|
||||
overflowX: 'auto',
|
||||
textAlign: 'start',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{errorMessage}
|
||||
</pre>
|
||||
</Block>
|
||||
)}
|
||||
</Flexbox>
|
||||
}
|
||||
title={
|
||||
<Text fontSize={32} weight={'bold'}>
|
||||
{t('error.title')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailedPage;
|
||||
+2
-2
@@ -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<string | null>(null);
|
||||
const [status, setStatus] = useState<CallbackStatus>('success');
|
||||
+2
-2
@@ -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(() => {
|
||||
+3
-7
@@ -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;
|
||||
+3
-6
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <NotFound />;
|
||||
|
||||
if (error.status === 409)
|
||||
return (
|
||||
<ClientError
|
||||
error={{
|
||||
messageKey: 'consent.error.unsupportedInteraction.message',
|
||||
titleKey: 'consent.error.unsupportedInteraction.title',
|
||||
values: { promptName: error.promptName || '' },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (error.status === 400)
|
||||
return (
|
||||
<ClientError
|
||||
error={{
|
||||
messageKey: 'consent.error.sessionInvalid.message',
|
||||
titleKey: 'consent.error.sessionInvalid.title',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientError
|
||||
error={{
|
||||
messageKey: 'consent.error.unknown.message',
|
||||
titleKey: 'consent.error.title',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
|
||||
return (
|
||||
<ClientError
|
||||
error={{
|
||||
message,
|
||||
messageKey: message ? undefined : 'consent.error.unknown.message',
|
||||
titleKey: 'consent.error.title',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InteractionContent = memo(() => {
|
||||
const { uid } = useParams<{ uid: string }>();
|
||||
const { data, error, isLoading } = useInteractionDetails(uid);
|
||||
|
||||
if (!uid) return <NotFound />;
|
||||
if (error) return renderError(error);
|
||||
if (isLoading || !data) return <BrandTextLoading debugId={'Auth > OAuthConsent'} />;
|
||||
|
||||
if (data.prompt === 'login') return <Login clientMetadata={data.clientMetadata} uid={data.uid} />;
|
||||
|
||||
return (
|
||||
<Consent
|
||||
clientId={data.clientId}
|
||||
clientMetadata={data.clientMetadata}
|
||||
redirectUri={data.redirectUri}
|
||||
scopes={data.scopes}
|
||||
uid={data.uid}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
InteractionContent.displayName = 'OAuthInteractionContent';
|
||||
|
||||
const OAuthConsent = memo(() => (
|
||||
<OAuthGuard>
|
||||
<InteractionContent />
|
||||
</OAuthGuard>
|
||||
));
|
||||
|
||||
OAuthConsent.displayName = 'OAuthConsent';
|
||||
|
||||
export default OAuthConsent;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<OidcInteractionDetailsResponse> => {
|
||||
const res = await fetch(`/oidc/interaction/${uid}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const body: Partial<OidcInteractionErrorResponse> | 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,
|
||||
},
|
||||
);
|
||||
@@ -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 (
|
||||
<OAuthGuard>
|
||||
{userCode ? (
|
||||
<DeviceCodeConfirm
|
||||
userCode={userCode}
|
||||
xsrf={searchParams.get('xsrf') ?? undefined}
|
||||
clientName={
|
||||
searchParams.get('client_name') ||
|
||||
searchParams.get('client_id') ||
|
||||
'Unknown Application'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<NotFound />
|
||||
)}
|
||||
</OAuthGuard>
|
||||
);
|
||||
});
|
||||
|
||||
DeviceConfirmPage.displayName = 'DeviceConfirmPage';
|
||||
|
||||
export default DeviceConfirmPage;
|
||||
@@ -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<string, string> = {
|
||||
'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 (
|
||||
<OAuthGuard>
|
||||
<DeviceCodeInput
|
||||
errorKey={getDeviceErrorKey(searchParams.get('error'))}
|
||||
userCode={searchParams.get('user_code') ?? undefined}
|
||||
xsrf={searchParams.get('xsrf') ?? undefined}
|
||||
/>
|
||||
</OAuthGuard>
|
||||
);
|
||||
});
|
||||
|
||||
DeviceInputPage.displayName = 'DeviceInputPage';
|
||||
|
||||
export default DeviceInputPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import OAuthGuard from '../OAuthGuard';
|
||||
import DeviceSuccess from './DeviceSuccess';
|
||||
|
||||
const DeviceSuccessPage = memo(() => (
|
||||
<OAuthGuard>
|
||||
<DeviceSuccess />
|
||||
</OAuthGuard>
|
||||
));
|
||||
|
||||
DeviceSuccessPage.displayName = 'DeviceSuccessPage';
|
||||
|
||||
export default DeviceSuccessPage;
|
||||
@@ -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<PropsWithChildren>(({ children }) => {
|
||||
const enableOIDC = useAuthServerConfigStore((s) => s.enableOIDC);
|
||||
|
||||
if (!enableOIDC) return <NotFound />;
|
||||
|
||||
return children;
|
||||
});
|
||||
|
||||
OAuthGuard.displayName = 'OAuthGuard';
|
||||
|
||||
export default OAuthGuard;
|
||||
+13
-7
@@ -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 <Navigate replace to="/signin" />;
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
subtitle={t('betterAuth.resetPassword.description')}
|
||||
title={t('betterAuth.resetPassword.title')}
|
||||
footer={
|
||||
<Link href={'/signin'}>
|
||||
<Link to={'/signin'}>
|
||||
<Button block icon={ChevronLeftIcon} size={'large'}>
|
||||
{t('betterAuth.resetPassword.backToSignIn')}
|
||||
</Button>
|
||||
@@ -31,7 +37,7 @@ const ResetPasswordPage = () => {
|
||||
<ResetPasswordContent
|
||||
email={email}
|
||||
token={token}
|
||||
onSuccessRedirect={(url) => router.push(url)}
|
||||
onSuccessRedirect={(url) => navigate(url)}
|
||||
/>
|
||||
</AuthCard>
|
||||
);
|
||||
+2
-3
@@ -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`
|
||||
+1
-1
@@ -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;
|
||||
@@ -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' ? (
|
||||
<SignInEmailStep
|
||||
disableEmailPassword={disableEmailPassword}
|
||||
form={form as any}
|
||||
isSocialOnly={isSocialOnly}
|
||||
lastAuthProvider={lastAuthProvider}
|
||||
loading={loading}
|
||||
oAuthSSOProviders={oAuthSSOProviders}
|
||||
serverConfigInit={serverConfigInit}
|
||||
socialLoading={socialLoading}
|
||||
onCheckUser={handleCheckUser}
|
||||
onSetPassword={handleForgotPassword}
|
||||
onSocialSignIn={handleSocialSignIn}
|
||||
/>
|
||||
) : (
|
||||
<SignInPasswordStep
|
||||
email={email}
|
||||
form={form as any}
|
||||
loading={loading}
|
||||
onBackToEmail={handleBackToEmail}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
onSubmit={handleSignIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
+49
-12
@@ -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'),
|
||||
);
|
||||
});
|
||||
+16
-9
@@ -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()}`);
|
||||
});
|
||||
};
|
||||
|
||||
+10
-11
@@ -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<SignUpFormValues>();
|
||||
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<InputRef>(null);
|
||||
const passwordInputRef = useRef<InputRef>(null);
|
||||
@@ -39,11 +38,11 @@ const BetterAuthSignUpForm = () => {
|
||||
<Text>
|
||||
{t('betterAuth.signup.hasAccount')}{' '}
|
||||
<Link
|
||||
href={`/signin?${searchParams.toString()}`}
|
||||
to={`/signin?${searchParams.toString()}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void trackLoginOrSignupClicked({ spm: 'signup.go_to_signin.click' }).finally(() => {
|
||||
window.location.href = `/signin?${searchParams.toString()}`;
|
||||
navigate(`/signin?${searchParams.toString()}`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -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 <Navigate replace to="/signin" />;
|
||||
|
||||
return <BetterAuthSignUpForm />;
|
||||
};
|
||||
|
||||
export default SignUp;
|
||||
+29
-18
@@ -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 () => {
|
||||
+12
-10
@@ -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<SignUpFormValues>();
|
||||
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 };
|
||||
};
|
||||
+5
-5
@@ -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={
|
||||
<Link href={'/signin'}>
|
||||
<Link to={'/signin'}>
|
||||
<Button block icon={ChevronLeftIcon} size={'large'}>
|
||||
{t('betterAuth.verifyEmail.backToSignIn')}
|
||||
</Button>
|
||||
+2
-2
@@ -22,12 +22,12 @@ const AuthAgreement = memo(() => {
|
||||
components={{
|
||||
privacy: (
|
||||
<a href={PRIVACY_URL} style={linkStyle}>
|
||||
{t('footer.terms')}
|
||||
{t('footer.privacy')}
|
||||
</a>
|
||||
),
|
||||
terms: (
|
||||
<a href={TERMS_URL} style={linkStyle}>
|
||||
{t('footer.privacy')}
|
||||
{t('footer.terms')}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
+2
-3
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
width={'100%'}
|
||||
>
|
||||
<Flexbox horizontal align={'center'} padding={16} width={'100%'}>
|
||||
<Link aria-label={'LobeHub'} href={'/'} style={{ display: 'inline-flex' }}>
|
||||
<a aria-label={'LobeHub'} href={'/'} style={{ display: 'inline-flex' }}>
|
||||
<ProductLogo size={40} />
|
||||
</Link>
|
||||
</a>
|
||||
</Flexbox>
|
||||
<Center height={'100%'} padding={16} width={'100%'}>
|
||||
{children}
|
||||
+1
-5
@@ -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<AuthLocaleProps>(({ 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();
|
||||
}
|
||||
|
||||
+4
-4
@@ -7,9 +7,9 @@ import type { IFeatureFlagsState } from '@/config/featureFlags';
|
||||
import type { GlobalServerConfig } from '@/types/serverConfig';
|
||||
|
||||
interface AuthServerConfigState {
|
||||
enableOIDC: boolean;
|
||||
featureFlags: Partial<IFeatureFlagsState>;
|
||||
isMobile?: boolean;
|
||||
segmentVariants?: string;
|
||||
serverConfig: GlobalServerConfig;
|
||||
serverConfigInit: boolean;
|
||||
}
|
||||
@@ -18,19 +18,19 @@ const AuthServerConfigContext = createContext<AuthServerConfigState | null>(null
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
enableOIDC?: boolean;
|
||||
featureFlags?: Partial<IFeatureFlagsState>;
|
||||
isMobile?: boolean;
|
||||
segmentVariants?: string;
|
||||
serverConfig?: GlobalServerConfig;
|
||||
}
|
||||
|
||||
export const AuthServerConfigProvider = memo<Props>(
|
||||
({ children, featureFlags, serverConfig, isMobile, segmentVariants }) => (
|
||||
({ children, enableOIDC, featureFlags, serverConfig, isMobile }) => (
|
||||
<AuthServerConfigContext
|
||||
value={{
|
||||
enableOIDC: enableOIDC ?? false,
|
||||
featureFlags: featureFlags || {},
|
||||
isMobile,
|
||||
segmentVariants,
|
||||
serverConfig: serverConfig || { aiProvider: {}, telemetry: {} },
|
||||
serverConfigInit: true,
|
||||
}}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type PropsWithChildren } from 'react';
|
||||
|
||||
import BusinessAuthProvider from '@/business/client/BusinessAuthProvider';
|
||||
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
|
||||
import { mapFeatureFlagsEnvToState } from '@/config/featureFlags';
|
||||
import type { AuthSPAServerConfig } from '@/types/spaServerConfig';
|
||||
|
||||
import AuthContainer from './AuthContainer';
|
||||
import AuthLocale from './AuthLocale';
|
||||
import { AuthServerConfigProvider } from './AuthServerConfigProvider';
|
||||
import AuthThemeLite from './AuthThemeLite';
|
||||
|
||||
const AuthShell = memo<PropsWithChildren>(({ children }) => {
|
||||
const serverConfig = window.__SERVER_CONFIG__ as unknown as AuthSPAServerConfig | undefined;
|
||||
const locale = document.documentElement.lang || 'en-US';
|
||||
|
||||
return (
|
||||
<AuthLocale defaultLang={locale}>
|
||||
<AuthThemeLite globalCDN={serverConfig?.globalCDN}>
|
||||
<AuthServerConfigProvider
|
||||
enableOIDC={serverConfig?.enableOIDC}
|
||||
isMobile={false}
|
||||
serverConfig={serverConfig?.config}
|
||||
featureFlags={
|
||||
serverConfig?.featureFlags
|
||||
? mapFeatureFlagsEnvToState(serverConfig.featureFlags)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<LobeAnalyticsProviderWrapper>
|
||||
<BusinessAuthProvider>
|
||||
<AuthContainer>{children}</AuthContainer>
|
||||
</BusinessAuthProvider>
|
||||
</LobeAnalyticsProviderWrapper>
|
||||
</AuthServerConfigProvider>
|
||||
</AuthThemeLite>
|
||||
</AuthLocale>
|
||||
);
|
||||
});
|
||||
|
||||
AuthShell.displayName = 'AuthShell';
|
||||
|
||||
export default AuthShell;
|
||||
+1
-1
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
`,
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Center } from '@lobehub/ui';
|
||||
|
||||
import MessengerVerifyPage from '.';
|
||||
|
||||
const MessengerVerifyStandalonePage = () => (
|
||||
<Center padding={16} style={{ minHeight: '100dvh' }} width={'100%'}>
|
||||
<MessengerVerifyPage />
|
||||
</Center>
|
||||
);
|
||||
|
||||
export default MessengerVerifyStandalonePage;
|
||||
@@ -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') ?? '';
|
||||
|
||||
@@ -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<PropsWithChildren>(({ children }) => {
|
||||
|
||||
return (
|
||||
<Locale defaultLang={locale}>
|
||||
<NextThemeProvider>
|
||||
<AppTheme>
|
||||
<ServerConfigStoreProvider
|
||||
featureFlags={serverConfig?.featureFlags}
|
||||
isMobile={isMobile}
|
||||
serverConfig={serverConfig?.config}
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<StoreInitialization />
|
||||
<AppTheme>
|
||||
<ServerConfigStoreProvider
|
||||
featureFlags={serverConfig?.featureFlags}
|
||||
isMobile={isMobile}
|
||||
serverConfig={serverConfig?.config}
|
||||
>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<StoreInitialization />
|
||||
|
||||
{isDesktop && <ServerVersionOutdatedAlert />}
|
||||
<FaviconProvider>
|
||||
<DynamicFavicon />
|
||||
<GroupWizardProvider>
|
||||
<DragUploadProvider>
|
||||
<LazyMotion features={domMax}>
|
||||
<TooltipGroup layoutAnimation={false}>
|
||||
<StyleProvider speedy={import.meta.env.PROD}>
|
||||
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
|
||||
</StyleProvider>
|
||||
</TooltipGroup>
|
||||
<Suspense>
|
||||
<ModalHost />
|
||||
<BaseModalHost />
|
||||
<ToastHost />
|
||||
<ContextMenuHost />
|
||||
</Suspense>
|
||||
</LazyMotion>
|
||||
</DragUploadProvider>
|
||||
</GroupWizardProvider>
|
||||
</FaviconProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
<Suspense>
|
||||
<ImportSettings />
|
||||
{/* DevPanel disabled in SPA: depends on node:fs */}
|
||||
{__DEV__ && (
|
||||
<>
|
||||
<AgentMockDevtools />
|
||||
<DevFeatureFlagPanel />
|
||||
</>
|
||||
)}
|
||||
</Suspense>
|
||||
</ServerConfigStoreProvider>
|
||||
</AppTheme>
|
||||
</NextThemeProvider>
|
||||
{isDesktop && <ServerVersionOutdatedAlert />}
|
||||
<FaviconProvider>
|
||||
<DynamicFavicon />
|
||||
<GroupWizardProvider>
|
||||
<DragUploadProvider>
|
||||
<LazyMotion features={domMax}>
|
||||
<TooltipGroup layoutAnimation={false}>
|
||||
<StyleProvider speedy={import.meta.env.PROD}>
|
||||
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
|
||||
</StyleProvider>
|
||||
</TooltipGroup>
|
||||
<Suspense>
|
||||
<ModalHost />
|
||||
<BaseModalHost />
|
||||
<ToastHost />
|
||||
<ContextMenuHost />
|
||||
</Suspense>
|
||||
</LazyMotion>
|
||||
</DragUploadProvider>
|
||||
</GroupWizardProvider>
|
||||
</FaviconProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
<Suspense>
|
||||
<ImportSettings />
|
||||
{/* DevPanel disabled in SPA: depends on node:fs */}
|
||||
{__DEV__ && (
|
||||
<>
|
||||
<AgentMockDevtools />
|
||||
<DevFeatureFlagPanel />
|
||||
</>
|
||||
)}
|
||||
</Suspense>
|
||||
</ServerConfigStoreProvider>
|
||||
</AppTheme>
|
||||
</Locale>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||
href: string;
|
||||
@@ -15,11 +15,13 @@ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
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}?`),
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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/<locale>', 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import AuthError from '@/features/Auth/AuthError';
|
||||
|
||||
export default AuthError;
|
||||
@@ -0,0 +1,3 @@
|
||||
import MarketAuthCallback from '@/features/Auth/MarketAuthCallback';
|
||||
|
||||
export default MarketAuthCallback;
|
||||
@@ -0,0 +1,3 @@
|
||||
import OAuthCallbackError from '@/features/Auth/OAuthCallback/Error';
|
||||
|
||||
export default OAuthCallbackError;
|
||||
@@ -0,0 +1,3 @@
|
||||
import OAuthCallbackSocial from '@/features/Auth/OAuthCallback/Social';
|
||||
|
||||
export default OAuthCallbackSocial;
|
||||
@@ -0,0 +1,3 @@
|
||||
import OAuthCallbackSuccess from '@/features/Auth/OAuthCallback/Success';
|
||||
|
||||
export default OAuthCallbackSuccess;
|
||||
@@ -0,0 +1,3 @@
|
||||
import OAuthConsent from '@/features/Auth/OAuthConsent';
|
||||
|
||||
export default OAuthConsent;
|
||||
@@ -0,0 +1,3 @@
|
||||
import DeviceConfirmPage from '@/features/Auth/OAuthDevice/DeviceConfirmPage';
|
||||
|
||||
export default DeviceConfirmPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import DeviceInputPage from '@/features/Auth/OAuthDevice/DeviceInputPage';
|
||||
|
||||
export default DeviceInputPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import DeviceSuccessPage from '@/features/Auth/OAuthDevice/DeviceSuccessPage';
|
||||
|
||||
export default DeviceSuccessPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import ResetPassword from '@/features/Auth/ResetPassword';
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -0,0 +1,3 @@
|
||||
import SignIn from '@/features/Auth/SignIn';
|
||||
|
||||
export default SignIn;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user