@lobehub/lobehub 2.0.0-next.124 → 2.0.0-next.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.cursor/rules/db-migrations.mdc +16 -1
  2. package/.cursor/rules/project-introduce.mdc +1 -1
  3. package/.cursor/rules/project-structure.mdc +20 -2
  4. package/.env.example +148 -65
  5. package/.env.example.development +6 -8
  6. package/AGENTS.md +1 -3
  7. package/CHANGELOG.md +50 -0
  8. package/Dockerfile +7 -5
  9. package/GEMINI.md +63 -0
  10. package/changelog/v1.json +18 -0
  11. package/docs/development/database-schema.dbml +37 -0
  12. package/docs/self-hosting/advanced/auth.mdx +82 -2
  13. package/docs/self-hosting/advanced/auth.zh-CN.mdx +82 -2
  14. package/docs/self-hosting/environment-variables/auth.mdx +187 -1
  15. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
  16. package/locales/en-US/auth.json +93 -0
  17. package/locales/zh-CN/auth.json +107 -1
  18. package/package.json +5 -2
  19. package/packages/const/src/auth.ts +2 -1
  20. package/packages/database/migrations/0049_better_auth.sql +49 -0
  21. package/packages/database/migrations/meta/0048_snapshot.json +312 -932
  22. package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
  23. package/packages/database/migrations/meta/_journal.json +8 -1
  24. package/packages/database/src/core/migrations.json +13 -0
  25. package/packages/database/src/index.ts +1 -0
  26. package/packages/database/src/models/__tests__/session.test.ts +1 -2
  27. package/packages/database/src/models/user.ts +9 -8
  28. package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
  29. package/packages/database/src/schemas/betterAuth.ts +63 -0
  30. package/packages/database/src/schemas/index.ts +1 -0
  31. package/packages/database/src/schemas/ragEvals.ts +1 -2
  32. package/packages/database/src/schemas/user.ts +3 -2
  33. package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
  34. package/packages/types/src/user/preference.ts +11 -0
  35. package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
  36. package/packages/utils/src/server/auth.ts +18 -1
  37. package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
  38. package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
  39. package/src/app/(backend)/middleware/auth/index.ts +14 -0
  40. package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
  41. package/src/app/(backend)/middleware/auth/utils.ts +13 -10
  42. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
  43. package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
  44. package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
  45. package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
  46. package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
  47. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
  48. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
  49. package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
  50. package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
  51. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
  52. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
  53. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
  54. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
  55. package/src/auth.ts +118 -0
  56. package/src/components/NextAuth/AuthIcons.tsx +3 -1
  57. package/src/envs/auth.ts +260 -13
  58. package/src/envs/email.ts +37 -0
  59. package/src/features/User/UserPanel/PanelContent.tsx +6 -5
  60. package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
  61. package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
  62. package/src/features/User/__tests__/useMenu.test.tsx +14 -12
  63. package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
  64. package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
  65. package/src/layout/AuthProvider/index.tsx +3 -0
  66. package/src/libs/better-auth/auth-client.ts +34 -0
  67. package/src/libs/better-auth/constants.ts +13 -0
  68. package/src/libs/better-auth/email-templates/index.ts +3 -0
  69. package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
  70. package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
  71. package/src/libs/better-auth/email-templates/verification.ts +108 -0
  72. package/src/libs/better-auth/sso/helpers.ts +61 -0
  73. package/src/libs/better-auth/sso/index.ts +113 -0
  74. package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
  75. package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
  76. package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
  77. package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
  78. package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
  79. package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
  80. package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
  81. package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
  82. package/src/libs/better-auth/sso/providers/github.ts +30 -0
  83. package/src/libs/better-auth/sso/providers/google.ts +30 -0
  84. package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
  85. package/src/libs/better-auth/sso/providers/logto.ts +38 -0
  86. package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
  87. package/src/libs/better-auth/sso/providers/okta.ts +37 -0
  88. package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
  89. package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
  90. package/src/libs/better-auth/sso/types.ts +25 -0
  91. package/src/libs/better-auth/utils/client.ts +1 -0
  92. package/src/libs/better-auth/utils/common.ts +20 -0
  93. package/src/libs/better-auth/utils/server.test.ts +61 -0
  94. package/src/libs/better-auth/utils/server.ts +18 -0
  95. package/src/libs/trpc/lambda/context.test.ts +116 -0
  96. package/src/libs/trpc/lambda/context.ts +27 -0
  97. package/src/libs/trpc/middleware/userAuth.ts +4 -2
  98. package/src/locales/default/auth.ts +114 -1
  99. package/src/proxy.ts +71 -7
  100. package/src/server/globalConfig/index.ts +12 -1
  101. package/src/server/routers/lambda/user.ts +4 -0
  102. package/src/server/services/email/README.md +241 -0
  103. package/src/server/services/email/impls/index.test.ts +39 -0
  104. package/src/server/services/email/impls/index.ts +32 -0
  105. package/src/server/services/email/impls/nodemailer/index.ts +108 -0
  106. package/src/server/services/email/impls/nodemailer/type.ts +31 -0
  107. package/src/server/services/email/impls/type.ts +61 -0
  108. package/src/server/services/email/index.test.ts +144 -0
  109. package/src/server/services/email/index.ts +40 -0
  110. package/src/services/user/index.test.ts +162 -2
  111. package/src/services/user/index.ts +6 -3
  112. package/src/store/user/slices/auth/action.test.ts +213 -16
  113. package/src/store/user/slices/auth/action.ts +86 -1
  114. package/src/store/user/slices/auth/initialState.ts +13 -2
  115. package/src/store/user/slices/auth/selectors.ts +6 -2
  116. package/src/store/user/slices/common/action.ts +5 -1
  117. package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
@@ -0,0 +1,54 @@
1
+ import { authEnv } from '@/envs/auth';
2
+
3
+ import { buildOidcConfig, pickEnv } from '../helpers';
4
+ import type { GenericProviderDefinition } from '../types';
5
+
6
+ type ZitadelEnv = {
7
+ AUTH_ZITADEL_ID?: string;
8
+ AUTH_ZITADEL_ISSUER?: string;
9
+ AUTH_ZITADEL_SECRET?: string;
10
+ ZITADEL_CLIENT_ID?: string;
11
+ ZITADEL_CLIENT_SECRET?: string;
12
+ ZITADEL_ISSUER?: string;
13
+ };
14
+
15
+ const getClientId = (env: ZitadelEnv) => {
16
+ return pickEnv(env.ZITADEL_CLIENT_ID, env.AUTH_ZITADEL_ID);
17
+ };
18
+
19
+ const getClientSecret = (env: ZitadelEnv) => {
20
+ return pickEnv(env.ZITADEL_CLIENT_SECRET, env.AUTH_ZITADEL_SECRET);
21
+ };
22
+
23
+ const getIssuer = (env: ZitadelEnv) => {
24
+ return pickEnv(env.ZITADEL_ISSUER, env.AUTH_ZITADEL_ISSUER);
25
+ };
26
+
27
+ const provider: GenericProviderDefinition<ZitadelEnv> = {
28
+ build: (env) =>
29
+ buildOidcConfig({
30
+ clientId: getClientId(env)!,
31
+ clientSecret: getClientSecret(env)!,
32
+ issuer: getIssuer(env)!,
33
+ providerId: 'zitadel',
34
+ }),
35
+ checkEnvs: () => {
36
+ const clientId = getClientId(authEnv);
37
+ const clientSecret = getClientSecret(authEnv);
38
+ const issuer = getIssuer(authEnv);
39
+ return !!(clientId && clientSecret && issuer)
40
+ ? {
41
+ AUTH_ZITADEL_ID: authEnv.AUTH_ZITADEL_ID,
42
+ AUTH_ZITADEL_ISSUER: authEnv.AUTH_ZITADEL_ISSUER,
43
+ AUTH_ZITADEL_SECRET: authEnv.AUTH_ZITADEL_SECRET,
44
+ ZITADEL_CLIENT_ID: authEnv.ZITADEL_CLIENT_ID,
45
+ ZITADEL_CLIENT_SECRET: authEnv.ZITADEL_CLIENT_SECRET,
46
+ ZITADEL_ISSUER: authEnv.ZITADEL_ISSUER,
47
+ }
48
+ : false;
49
+ },
50
+ id: 'zitadel',
51
+ type: 'generic',
52
+ };
53
+
54
+ export default provider;
@@ -0,0 +1,25 @@
1
+ import type { GenericOAuthConfig } from 'better-auth/plugins';
2
+ import type { SocialProviders } from 'better-auth/social-providers';
3
+
4
+ export type BuiltinProviderDefinition<
5
+ E extends Record<string, string | undefined>,
6
+ Id extends keyof SocialProviders = keyof SocialProviders,
7
+ > = {
8
+ aliases?: string[];
9
+ build: (env: E) => SocialProviders[Id];
10
+ checkEnvs: () => E | false;
11
+ id: Id;
12
+ type: 'builtin';
13
+ };
14
+
15
+ export type GenericProviderDefinition<E extends Record<string, string | undefined>> = {
16
+ aliases?: string[];
17
+ build: (env: E) => GenericOAuthConfig;
18
+ checkEnvs: () => E | false;
19
+ id: string;
20
+ type: 'generic';
21
+ };
22
+
23
+ export type BetterAuthProviderDefinition =
24
+ | BuiltinProviderDefinition<Record<string, string | undefined>>
25
+ | GenericProviderDefinition<Record<string, string | undefined>>;
@@ -0,0 +1 @@
1
+ export { isBuiltinProvider, normalizeProviderId } from './common';
@@ -0,0 +1,20 @@
1
+ import { BUILTIN_BETTER_AUTH_PROVIDERS, PROVIDER_ALIAS_MAP } from '@/libs/better-auth/constants';
2
+
3
+ /**
4
+ * Normalize provider id using configured alias map (e.g. microsoft-entra-id -> microsoft).
5
+ */
6
+ export const normalizeProviderId = (provider: string) => {
7
+ return PROVIDER_ALIAS_MAP[provider] || provider;
8
+ };
9
+
10
+ /**
11
+ * Check whether a provider is handled by Better-Auth's built-in social providers.
12
+ * Uses alias normalization so callers can pass either canonical ids or aliases.
13
+ */
14
+ export const isBuiltinProvider = (provider: string) => {
15
+ const normalized = normalizeProviderId(provider);
16
+
17
+ return BUILTIN_BETTER_AUTH_PROVIDERS.includes(
18
+ normalized as (typeof BUILTIN_BETTER_AUTH_PROVIDERS)[number],
19
+ );
20
+ };
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { parseSSOProviders } from './server';
4
+
5
+ describe('parseSSOProviders', () => {
6
+ it('should return empty array when input is undefined', () => {
7
+ expect(parseSSOProviders(undefined)).toEqual([]);
8
+ });
9
+
10
+ it('should return empty array when input is empty string', () => {
11
+ expect(parseSSOProviders('')).toEqual([]);
12
+ });
13
+
14
+ it('should return empty array when input contains only whitespace', () => {
15
+ expect(parseSSOProviders(' ')).toEqual([]);
16
+ });
17
+
18
+ it('should parse single provider', () => {
19
+ expect(parseSSOProviders('google')).toEqual(['google']);
20
+ });
21
+
22
+ it('should parse multiple providers separated by English comma', () => {
23
+ expect(parseSSOProviders('google,github,microsoft')).toEqual(['google', 'github', 'microsoft']);
24
+ });
25
+
26
+ it('should parse multiple providers separated by Chinese comma', () => {
27
+ expect(parseSSOProviders('google,github,microsoft')).toEqual([
28
+ 'google',
29
+ 'github',
30
+ 'microsoft',
31
+ ]);
32
+ });
33
+
34
+ it('should parse providers with mixed comma separators', () => {
35
+ expect(parseSSOProviders('google,github,microsoft')).toEqual([
36
+ 'google',
37
+ 'github',
38
+ 'microsoft',
39
+ ]);
40
+ });
41
+
42
+ it('should trim whitespace from providers', () => {
43
+ expect(parseSSOProviders(' google , github , microsoft ')).toEqual([
44
+ 'google',
45
+ 'github',
46
+ 'microsoft',
47
+ ]);
48
+ });
49
+
50
+ it('should filter out empty entries', () => {
51
+ expect(parseSSOProviders('google,,github,,,microsoft')).toEqual([
52
+ 'google',
53
+ 'github',
54
+ 'microsoft',
55
+ ]);
56
+ });
57
+
58
+ it('should trim leading and trailing whitespace from input', () => {
59
+ expect(parseSSOProviders(' google,github ')).toEqual(['google', 'github']);
60
+ });
61
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Parse Better-Auth SSO providers from environment variable
3
+ * Supports comma-separated list (both English and Chinese commas)
4
+ * @param providersEnv - Raw environment variable value (e.g., "google,github")
5
+ * @returns Array of enabled provider names
6
+ */
7
+ export const parseSSOProviders = (providersEnv?: string): string[] => {
8
+ const providers = providersEnv?.trim();
9
+
10
+ if (!providers) {
11
+ return [];
12
+ }
13
+
14
+ return providers
15
+ .split(/[,,]/)
16
+ .map((p) => p.trim())
17
+ .filter(Boolean);
18
+ };
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createContextInner } from './context';
4
+
5
+ describe('createContextInner', () => {
6
+ it('should create context with default values when no params provided', async () => {
7
+ const context = await createContextInner();
8
+
9
+ expect(context).toMatchObject({
10
+ authorizationHeader: undefined,
11
+ clerkAuth: undefined,
12
+ marketAccessToken: undefined,
13
+ nextAuth: undefined,
14
+ oidcAuth: undefined,
15
+ userAgent: undefined,
16
+ userId: undefined,
17
+ });
18
+ expect(context.resHeaders).toBeInstanceOf(Headers);
19
+ });
20
+
21
+ it('should create context with userId', async () => {
22
+ const context = await createContextInner({ userId: 'user-123' });
23
+
24
+ expect(context.userId).toBe('user-123');
25
+ });
26
+
27
+ it('should create context with authorization header', async () => {
28
+ const context = await createContextInner({
29
+ authorizationHeader: 'Bearer token-abc',
30
+ });
31
+
32
+ expect(context.authorizationHeader).toBe('Bearer token-abc');
33
+ });
34
+
35
+ it('should create context with user agent', async () => {
36
+ const context = await createContextInner({
37
+ userAgent: 'Mozilla/5.0',
38
+ });
39
+
40
+ expect(context.userAgent).toBe('Mozilla/5.0');
41
+ });
42
+
43
+ it('should create context with market access token', async () => {
44
+ const context = await createContextInner({
45
+ marketAccessToken: 'mp-token-xyz',
46
+ });
47
+
48
+ expect(context.marketAccessToken).toBe('mp-token-xyz');
49
+ });
50
+
51
+ it('should create context with OIDC auth data', async () => {
52
+ const oidcAuth = {
53
+ sub: 'oidc-user-123',
54
+ payload: { iss: 'https://issuer.com', aud: 'client-id' },
55
+ };
56
+
57
+ const context = await createContextInner({ oidcAuth });
58
+
59
+ expect(context.oidcAuth).toEqual(oidcAuth);
60
+ });
61
+
62
+ it('should create context with Clerk auth data', async () => {
63
+ const clerkAuth = {
64
+ userId: 'clerk-user-id',
65
+ sessionId: 'session-id',
66
+ getToken: async () => 'clerk-token',
67
+ } as any;
68
+
69
+ const context = await createContextInner({ clerkAuth });
70
+
71
+ expect(context.clerkAuth).toBe(clerkAuth);
72
+ });
73
+
74
+ it('should create context with NextAuth user data', async () => {
75
+ const nextAuth = {
76
+ id: 'next-auth-user-id',
77
+ name: 'Test User',
78
+ email: 'test@example.com',
79
+ };
80
+
81
+ const context = await createContextInner({ nextAuth });
82
+
83
+ expect(context.nextAuth).toEqual(nextAuth);
84
+ });
85
+
86
+ it('should create context with all parameters combined', async () => {
87
+ const params = {
88
+ authorizationHeader: 'Bearer token',
89
+ userId: 'user-123',
90
+ userAgent: 'Test Agent',
91
+ marketAccessToken: 'mp-token',
92
+ oidcAuth: {
93
+ sub: 'oidc-sub',
94
+ payload: { data: 'test' },
95
+ },
96
+ };
97
+
98
+ const context = await createContextInner(params);
99
+
100
+ expect(context).toMatchObject({
101
+ authorizationHeader: 'Bearer token',
102
+ userId: 'user-123',
103
+ userAgent: 'Test Agent',
104
+ marketAccessToken: 'mp-token',
105
+ oidcAuth: { sub: 'oidc-sub', payload: { data: 'test' } },
106
+ });
107
+ });
108
+
109
+ it('should always include response headers', async () => {
110
+ const context1 = await createContextInner();
111
+ const context2 = await createContextInner({ userId: 'test' });
112
+
113
+ expect(context1.resHeaders).toBeInstanceOf(Headers);
114
+ expect(context2.resHeaders).toBeInstanceOf(Headers);
115
+ });
116
+ });
@@ -7,6 +7,7 @@ import { NextRequest } from 'next/server';
7
7
  import {
8
8
  LOBE_CHAT_AUTH_HEADER,
9
9
  LOBE_CHAT_OIDC_AUTH_HEADER,
10
+ enableBetterAuth,
10
11
  enableClerk,
11
12
  enableNextAuth,
12
13
  } from '@/const/auth';
@@ -163,6 +164,32 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
163
164
  });
164
165
  }
165
166
 
167
+ if (enableBetterAuth) {
168
+ log('Attempting Better Auth authentication');
169
+ try {
170
+ const { auth: betterAuth } = await import('@/auth');
171
+
172
+ const session = await betterAuth.api.getSession({
173
+ headers: request.headers,
174
+ });
175
+
176
+ if (session && session?.user?.id) {
177
+ userId = session.user.id;
178
+ log('Better Auth authentication successful, userId: %s', userId);
179
+ } else {
180
+ log('Better Auth authentication failed, no valid session');
181
+ }
182
+
183
+ return createContextInner({
184
+ ...commonContext,
185
+ userId,
186
+ });
187
+ } catch (e) {
188
+ log('Better Auth authentication error: %O', e);
189
+ console.error('better auth err', e);
190
+ }
191
+ }
192
+
166
193
  if (enableNextAuth) {
167
194
  log('Attempting NextAuth authentication');
168
195
  try {
@@ -1,6 +1,6 @@
1
1
  import { TRPCError } from '@trpc/server';
2
2
 
3
- import { enableClerk } from '@/const/auth';
3
+ import { enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
4
4
  import { DESKTOP_USER_ID } from '@/const/desktop';
5
5
  import { isDesktop } from '@/const/version';
6
6
 
@@ -19,7 +19,9 @@ export const userAuth = trpc.middleware(async (opts) => {
19
19
  if (!ctx.userId) {
20
20
  if (enableClerk) {
21
21
  console.log('clerk auth:', ctx.clerkAuth);
22
- } else {
22
+ } else if (enableBetterAuth) {
23
+ console.log('better auth: no session found in context');
24
+ } else if (enableNextAuth) {
23
25
  console.log('next auth:', ctx.nextAuth);
24
26
  }
25
27
  throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -52,6 +52,104 @@ export default {
52
52
  required: '内容不得为空',
53
53
  },
54
54
  },
55
+ betterAuth: {
56
+ errors: {
57
+ emailInvalid: '请输入有效的邮箱地址',
58
+ emailNotRegistered: '该邮箱尚未注册',
59
+ emailNotVerified: '邮箱尚未验证,请先验证邮箱',
60
+ emailRequired: '请输入邮箱地址',
61
+ firstNameRequired: '请输入名字',
62
+ lastNameRequired: '请输入姓氏',
63
+ loginFailed: '登录失败,请检查邮箱和密码',
64
+ passwordFormat: '密码必须同时包含字母和数字',
65
+ passwordMaxLength: '密码最多不超过 64 个字符',
66
+ passwordMinLength: '密码至少需要 8 个字符',
67
+ passwordRequired: '请输入密码',
68
+ usernameRequired: '请输入用户名',
69
+ },
70
+ resetPassword: {
71
+ backToSignIn: '返回登录',
72
+ confirmPasswordPlaceholder: '确认新密码',
73
+ confirmPasswordRequired: '请确认新密码',
74
+ description: '请输入您的新密码',
75
+ error: '重置密码失败,请重试',
76
+ invalidToken: '无效或已过期的重置链接',
77
+ newPasswordPlaceholder: '输入新密码',
78
+ passwordMismatch: '两次输入的密码不一致',
79
+ submit: '重置密码',
80
+ success: '密码重置成功,请使用新密码登录',
81
+ title: '重置密码',
82
+ },
83
+ signin: {
84
+ backToEmail: '返回修改邮箱',
85
+ continueWithAuth0: '使用 Auth0 登录',
86
+ continueWithAuthelia: '使用 Authelia 登录',
87
+ continueWithAuthentik: '使用 Authentik 登录',
88
+ continueWithCasdoor: '使用 Casdoor 登录',
89
+ continueWithCloudflareZeroTrust: '使用 Cloudflare Zero Trust 登录',
90
+ continueWithCognito: '使用 AWS Cognito 登录',
91
+ continueWithFeishu: '使用飞书登录',
92
+ continueWithGithub: '使用 GitHub 登录',
93
+ continueWithGoogle: '使用 Google 登录',
94
+ continueWithKeycloak: '使用 Keycloak 登录',
95
+ continueWithLogto: '使用 Logto 登录',
96
+ continueWithMicrosoft: '使用 Microsoft 登录',
97
+ continueWithOIDC: '使用 OIDC 登录',
98
+ continueWithOkta: '使用 Okta 登录',
99
+ continueWithWechat: '使用微信登录',
100
+ continueWithZitadel: '使用 Zitadel 登录',
101
+ emailPlaceholder: '请输入邮箱地址',
102
+ emailStep: {
103
+ subtitle: '请输入您的邮箱地址以继续',
104
+ title: '登录',
105
+ },
106
+ error: '登录失败,请检查邮箱和密码',
107
+ forgotPassword: '忘记密码?',
108
+ forgotPasswordError: '发送重置密码链接失败',
109
+ forgotPasswordSent: '重置密码链接已发送,请检查邮箱',
110
+ magicLinkButton: '发送登录链接',
111
+ magicLinkError: '发送登录链接失败,请稍后再试',
112
+ magicLinkSent: '登录链接已发送,请检查邮箱',
113
+ nextStep: '下一步',
114
+ noAccount: '还没有账号?',
115
+ orContinueWith: '或',
116
+ passwordPlaceholder: '请输入密码',
117
+ passwordStep: {
118
+ subtitle: '请输入密码以继续',
119
+ },
120
+ signupLink: '立即注册',
121
+ socialError: '社交登录失败,请重试',
122
+ socialOnlyHint: '该邮箱使用社交账号注册,请使用社交账号登录',
123
+ submit: '登录',
124
+ },
125
+ signup: {
126
+ emailPlaceholder: '请输入邮箱地址',
127
+ error: '注册失败,请重试',
128
+ firstNamePlaceholder: '名字',
129
+ hasAccount: '已有账号?',
130
+ lastNamePlaceholder: '姓氏',
131
+ passwordPlaceholder: '请输入密码',
132
+ signinLink: '立即登录',
133
+ submit: '注册',
134
+ subtitle: '加入 LobeChat 社区',
135
+ success: '注册成功!请检查您的邮箱验证邮件',
136
+ title: '创建账号',
137
+ usernamePlaceholder: '请输入用户名',
138
+ },
139
+ verifyEmail: {
140
+ backToSignIn: '返回登录',
141
+ checkSpam: '如果没有收到邮件,请检查垃圾邮件文件夹',
142
+ descriptionPrefix: '我们已向',
143
+ descriptionSuffix: '发送了验证邮件',
144
+ resend: {
145
+ button: '重新发送验证邮件',
146
+ error: '发送失败,请稍后重试',
147
+ noEmail: '邮箱地址缺失',
148
+ success: '验证邮件已重新发送,请检查您的邮箱',
149
+ },
150
+ title: '验证您的邮箱',
151
+ },
152
+ },
55
153
  date: {
56
154
  prevMonth: '上个月',
57
155
  recent30Days: '最近30天',
@@ -86,17 +184,32 @@ export default {
86
184
  loginOrSignup: '登录 / 注册',
87
185
  profile: {
88
186
  avatar: '头像',
187
+ cancel: '取消',
188
+ changePassword: '重置密码',
89
189
  email: '电子邮件地址',
190
+ fullName: '全名',
191
+ fullNameInputHint: '请输入新的全名',
192
+ password: '密码',
193
+ resetPasswordError: '发送密码重置链接失败',
194
+ resetPasswordSent: '密码重置链接已发送,请检查邮箱',
195
+ save: '保存',
90
196
  sso: {
197
+ link: {
198
+ button: '连接帐户',
199
+ success: '账户关联成功',
200
+ },
91
201
  loading: '正在加载已绑定的第三方账户',
92
202
  providers: '连接的帐户',
93
203
  unlink: {
94
204
  description:
95
- '解绑后,您将无法使用 {{provider}} 账户“{{providerAccountId}}”登录。如果您需要重新绑定 {{provider}} 账户到当前账户,请确保 {{provider}} 账户的邮件地址为 {{email}} ,我们会在登陆时为你自动绑定到当前登录账户。',
205
+ '解绑后,您将无法使用 {{provider}} 账户"{{providerAccountId}}"登录。如果您需要重新绑定 {{provider}} 账户到当前账户,请确保 {{provider}} 账户的邮件地址为 {{email}} ,我们会在登陆时为你自动绑定到当前登录账户。',
96
206
  forbidden: '您至少需要保留一个第三方账户绑定。',
97
207
  title: '是否解绑该第三方账户 {{provider}} ?',
98
208
  },
99
209
  },
210
+ title: '个人资料详情',
211
+ updateAvatar: '更新头像',
212
+ updateFullName: '更新全名',
100
213
  username: '用户名',
101
214
  },
102
215
  signout: '退出登录',
package/src/proxy.ts CHANGED
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
5
5
  import { UAParser } from 'ua-parser-js';
6
6
  import urlJoin from 'url-join';
7
7
 
8
+ import { auth } from '@/auth';
8
9
  import { OAUTH_AUTHORIZED } from '@/const/auth';
9
10
  import { LOBE_LOCALE_COOKIE } from '@/const/locale';
10
11
  import { LOBE_THEME_APPEARANCE } from '@/const/theme';
@@ -21,6 +22,7 @@ import { RouteVariants } from './utils/server/routeVariants';
21
22
  const logDefault = debug('middleware:default');
22
23
  const logNextAuth = debug('middleware:next-auth');
23
24
  const logClerk = debug('middleware:clerk');
25
+ const logBetterAuth = debug('middleware:better-auth');
24
26
 
25
27
  // OIDC session pre-sync constant
26
28
  const OIDC_SESSION_HEADER = 'x-oidc-session-sync';
@@ -47,10 +49,12 @@ export const config = {
47
49
 
48
50
  '/login(.*)',
49
51
  '/signup(.*)',
52
+ '/signin(.*)',
53
+ '/verify-email(.*)',
54
+ '/reset-password(.*)',
50
55
  '/next-auth/(.*)',
51
56
  '/oauth(.*)',
52
57
  '/oidc(.*)',
53
- // ↓ cloud ↓
54
58
  ],
55
59
  };
56
60
 
@@ -129,8 +133,18 @@ const defaultMiddleware = (request: NextRequest) => {
129
133
  // / -> /zh-CN__0__dark
130
134
  // /discover -> /zh-CN__0__dark/discover
131
135
  // All SPA routes that use react-router-dom should be rewritten to just /${route}
132
- const spaRoutes = ['/chat', '/discover', '/knowledge', '/settings', '/image', '/labs', '/changelog', '/profile', '/me'];
133
- const isSpaRoute = spaRoutes.some(route => url.pathname.startsWith(route));
136
+ const spaRoutes = [
137
+ '/chat',
138
+ '/discover',
139
+ '/knowledge',
140
+ '/settings',
141
+ '/image',
142
+ '/labs',
143
+ '/changelog',
144
+ '/profile',
145
+ '/me',
146
+ ];
147
+ const isSpaRoute = spaRoutes.some((route) => url.pathname.startsWith(route));
134
148
 
135
149
  let nextPathname: string;
136
150
  if (isSpaRoute) {
@@ -142,7 +156,6 @@ const defaultMiddleware = (request: NextRequest) => {
142
156
  ? urlJoin(url.origin, nextPathname)
143
157
  : nextPathname;
144
158
 
145
-
146
159
  console.log('nextURL', nextURL);
147
160
 
148
161
  logDefault('URL rewrite: %O', {
@@ -194,6 +207,10 @@ const isPublicRoute = createRouteMatcher([
194
207
  // clerk
195
208
  '/login',
196
209
  '/signup',
210
+ // better auth
211
+ '/signin',
212
+ '/verify-email',
213
+ '/reset-password',
197
214
  // oauth
198
215
  // Make only the consent view public (GET page), not other oauth paths
199
216
  '/oauth/consent/(.*)',
@@ -304,8 +321,53 @@ const clerkAuthMiddleware = clerkMiddleware(
304
321
  },
305
322
  );
306
323
 
324
+ const betterAuthMiddleware = async (req: NextRequest) => {
325
+ logBetterAuth('BetterAuth middleware processing request: %s %s', req.method, req.url);
326
+
327
+ const response = defaultMiddleware(req);
328
+
329
+ // when enable auth protection, only public route is not protected, others are all protected
330
+ const isProtected = appEnv.ENABLE_AUTH_PROTECTION ? !isPublicRoute(req) : isProtectedRoute(req);
331
+
332
+ logBetterAuth('Route protection status: %s, %s', req.url, isProtected ? 'protected' : 'public');
333
+
334
+ // Skip session lookup for public routes to reduce latency
335
+ if (!isProtected) return response;
336
+
337
+ // Get full session with user data (Next.js 15.2.0+ feature)
338
+ const session = await auth.api.getSession({
339
+ headers: req.headers,
340
+ });
341
+
342
+ const isLoggedIn = !!session?.user;
343
+
344
+ logBetterAuth('BetterAuth session status: %O', {
345
+ isLoggedIn,
346
+ userId: session?.user?.id,
347
+ });
348
+
349
+ if (!isLoggedIn) {
350
+ // If request a protected route, redirect to sign-in page
351
+ if (isProtected) {
352
+ logBetterAuth('Request a protected route, redirecting to sign-in page');
353
+ const signInUrl = new URL('/signin', req.nextUrl.origin);
354
+ signInUrl.searchParams.set('callbackUrl', req.nextUrl.href);
355
+ const hl = req.nextUrl.searchParams.get('hl');
356
+ if (hl) {
357
+ signInUrl.searchParams.set('hl', hl);
358
+ logBetterAuth('Preserving locale to sign-in: hl=%s', hl);
359
+ }
360
+ return Response.redirect(signInUrl);
361
+ }
362
+ logBetterAuth('Request a free route but not login, allow visit without auth header');
363
+ }
364
+
365
+ return response;
366
+ };
367
+
307
368
  logDefault('Middleware configuration: %O', {
308
369
  enableAuthProtection: appEnv.ENABLE_AUTH_PROTECTION,
370
+ enableBetterAuth: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH,
309
371
  enableClerk: authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH,
310
372
  enableNextAuth: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH,
311
373
  enableOIDC: oidcEnv.ENABLE_OIDC,
@@ -313,6 +375,8 @@ logDefault('Middleware configuration: %O', {
313
375
 
314
376
  export default authEnv.NEXT_PUBLIC_ENABLE_CLERK_AUTH
315
377
  ? clerkAuthMiddleware
316
- : authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
317
- ? nextAuthMiddleware
318
- : defaultMiddleware;
378
+ : authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH
379
+ ? betterAuthMiddleware
380
+ : authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH
381
+ ? nextAuthMiddleware
382
+ : defaultMiddleware;
@@ -5,6 +5,7 @@ import { fileEnv } from '@/envs/file';
5
5
  import { imageEnv } from '@/envs/image';
6
6
  import { knowledgeEnv } from '@/envs/knowledge';
7
7
  import { langfuseEnv } from '@/envs/langfuse';
8
+ import { parseSSOProviders } from '@/libs/better-auth/utils/server';
8
9
  import { parseSystemAgent } from '@/server/globalConfig/parseSystemAgent';
9
10
  import { GlobalServerConfig } from '@/types/serverConfig';
10
11
  import { cleanObject } from '@/utils/object';
@@ -13,6 +14,14 @@ import { genServerAiProvidersConfig } from './genServerAiProviderConfig';
13
14
  import { parseAgentConfig } from './parseDefaultAgent';
14
15
  import { parseFilesConfig } from './parseFilesConfig';
15
16
 
17
+ /**
18
+ * Get Better-Auth SSO providers list
19
+ * Parses AUTH_SSO_PROVIDERS and returns enabled providers
20
+ */
21
+ const getBetterAuthSSOProviders = () => {
22
+ return parseSSOProviders(authEnv.AUTH_SSO_PROVIDERS);
23
+ };
24
+
16
25
  export const getServerGlobalConfig = async () => {
17
26
  const { ACCESS_CODES, DEFAULT_AGENT_CONFIG } = getAppConfig();
18
27
 
@@ -63,7 +72,9 @@ export const getServerGlobalConfig = async () => {
63
72
  image: cleanObject({
64
73
  defaultImageNum: imageEnv.AI_IMAGE_DEFAULT_IMAGE_NUM,
65
74
  }),
66
- oAuthSSOProviders: authEnv.NEXT_AUTH_SSO_PROVIDERS.trim().split(/[,,]/),
75
+ oAuthSSOProviders: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH
76
+ ? getBetterAuthSSOProviders()
77
+ : authEnv.NEXT_AUTH_SSO_PROVIDERS.trim().split(/[,,]/),
67
78
  systemAgent: parseSystemAgent(appEnv.SYSTEM_AGENT),
68
79
  telemetry: {
69
80
  langfuse: langfuseEnv.ENABLE_LANGFUSE,
@@ -198,6 +198,10 @@ export const userRouter = router({
198
198
  return ctx.userModel.updateUser({ avatar: input });
199
199
  }),
200
200
 
201
+ updateFullName: userProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
202
+ return ctx.userModel.updateUser({ fullName: input });
203
+ }),
204
+
201
205
  updateGuide: userProcedure.input(UserGuideSchema).mutation(async ({ ctx, input }) => {
202
206
  return ctx.userModel.updateGuide(input);
203
207
  }),