@lobehub/lobehub 2.0.0-next.158 → 2.0.0-next.159

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 (64) hide show
  1. package/.nvmrc +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/changelog/v1.json +9 -0
  4. package/docs/development/database-schema.dbml +6 -0
  5. package/locales/ar/auth.json +11 -2
  6. package/locales/ar/models.json +25 -13
  7. package/locales/bg-BG/auth.json +11 -2
  8. package/locales/bg-BG/models.json +25 -13
  9. package/locales/de-DE/auth.json +11 -2
  10. package/locales/de-DE/models.json +25 -13
  11. package/locales/en-US/auth.json +18 -9
  12. package/locales/en-US/models.json +25 -13
  13. package/locales/es-ES/auth.json +11 -2
  14. package/locales/es-ES/models.json +25 -13
  15. package/locales/fa-IR/auth.json +11 -2
  16. package/locales/fa-IR/models.json +25 -13
  17. package/locales/fr-FR/auth.json +11 -2
  18. package/locales/fr-FR/models.json +25 -13
  19. package/locales/it-IT/auth.json +11 -2
  20. package/locales/it-IT/models.json +25 -13
  21. package/locales/ja-JP/auth.json +11 -2
  22. package/locales/ja-JP/models.json +25 -13
  23. package/locales/ko-KR/auth.json +11 -2
  24. package/locales/ko-KR/models.json +25 -13
  25. package/locales/nl-NL/auth.json +11 -2
  26. package/locales/nl-NL/models.json +25 -13
  27. package/locales/pl-PL/auth.json +11 -2
  28. package/locales/pl-PL/models.json +25 -13
  29. package/locales/pt-BR/auth.json +11 -2
  30. package/locales/pt-BR/models.json +25 -13
  31. package/locales/ru-RU/auth.json +11 -2
  32. package/locales/ru-RU/models.json +25 -13
  33. package/locales/tr-TR/auth.json +11 -2
  34. package/locales/tr-TR/models.json +25 -13
  35. package/locales/vi-VN/auth.json +11 -2
  36. package/locales/vi-VN/models.json +25 -13
  37. package/locales/zh-CN/auth.json +18 -9
  38. package/locales/zh-CN/models.json +25 -13
  39. package/locales/zh-TW/auth.json +11 -2
  40. package/locales/zh-TW/models.json +25 -13
  41. package/next.config.ts +1 -1
  42. package/package.json +2 -1
  43. package/packages/database/migrations/0059_add_normalized_email_indexes.sql +4 -0
  44. package/packages/database/migrations/meta/0059_snapshot.json +8474 -0
  45. package/packages/database/migrations/meta/_journal.json +7 -0
  46. package/packages/database/src/core/migrations.json +12 -0
  47. package/packages/database/src/models/user.ts +13 -1
  48. package/packages/database/src/schemas/user.ts +37 -29
  49. package/src/app/(backend)/api/auth/resolve-username/route.ts +52 -0
  50. package/src/app/[variants]/(auth)/signin/page.tsx +102 -14
  51. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +15 -0
  52. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +152 -12
  53. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +4 -9
  54. package/src/app/[variants]/desktopRouter.config.tsx +7 -1
  55. package/src/app/[variants]/mobileRouter.config.tsx +7 -1
  56. package/src/auth.ts +2 -0
  57. package/src/locales/default/auth.ts +17 -9
  58. package/src/server/routers/lambda/user.ts +18 -0
  59. package/src/services/user/index.ts +4 -0
  60. package/src/store/user/slices/auth/action.test.ts +2 -2
  61. package/src/store/user/slices/auth/action.ts +8 -8
  62. package/src/store/user/slices/auth/initialState.ts +1 -1
  63. package/src/store/user/slices/auth/selectors.ts +1 -1
  64. package/src/store/user/slices/common/action.ts +6 -0
@@ -12,23 +12,22 @@ import { userService } from '@/services/user';
12
12
  import { useServerConfigStore } from '@/store/serverConfig';
13
13
  import { serverConfigSelectors } from '@/store/serverConfig/selectors';
14
14
  import { useUserStore } from '@/store/user';
15
- import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
15
+ import { authSelectors } from '@/store/user/selectors';
16
16
 
17
17
  const providerNameStyle: CSSProperties = {
18
18
  textTransform: 'capitalize',
19
19
  };
20
20
 
21
21
  export const SSOProvidersList = memo(() => {
22
- const userProfile = useUserStore(userProfileSelectors.userProfile);
23
22
  const isLoginWithBetterAuth = useUserStore(authSelectors.isLoginWithBetterAuth);
24
23
  const providers = useUserStore(authSelectors.authProviders);
25
- const isEmailPasswordAuth = useUserStore(authSelectors.isEmailPasswordAuth);
24
+ const hasPasswordAccount = useUserStore(authSelectors.hasPasswordAccount);
26
25
  const refreshAuthProviders = useUserStore((s) => s.refreshAuthProviders);
27
26
  const oAuthSSOProviders = useServerConfigStore(serverConfigSelectors.oAuthSSOProviders);
28
27
  const { t } = useTranslation('auth');
29
28
 
30
29
  // Allow unlink if user has multiple SSO providers OR has email/password login
31
- const allowUnlink = providers.length > 1 || isEmailPasswordAuth;
30
+ const allowUnlink = providers.length > 1 || hasPasswordAccount;
32
31
 
33
32
  // Get linked provider IDs for filtering
34
33
  const linkedProviderIds = useMemo(() => {
@@ -49,11 +48,7 @@ export const SSOProvidersList = memo(() => {
49
48
  return;
50
49
  }
51
50
  modal.confirm({
52
- content: t('profile.sso.unlink.description', {
53
- email: userProfile?.email || 'None',
54
- provider,
55
- providerAccountId,
56
- }),
51
+ content: t('profile.sso.unlink.description', { provider }),
57
52
  okButtonProps: {
58
53
  danger: true,
59
54
  },
@@ -150,6 +150,12 @@ const DiscoverLayout = dynamic(() => import('./(main)/discover/_layout/Desktop/i
150
150
  ssr: false,
151
151
  });
152
152
 
153
+ // NotFound component
154
+ const NotFoundPage = dynamic(() => import('@/components/404'), {
155
+ loading: () => <Loading />,
156
+ ssr: false,
157
+ });
158
+
153
159
  // Knowledge components
154
160
  const KnowledgeHome = dynamic(() => import('./(main)/knowledge/routes/KnowledgeHome'), {
155
161
  loading: () => <Loading />,
@@ -491,7 +497,7 @@ export const createDesktopRouter = (locale: Locales) =>
491
497
 
492
498
  // Catch-all route
493
499
  {
494
- loader: () => redirect('/chat', { status: 302 }),
500
+ element: <NotFoundPage />,
495
501
  path: '*',
496
502
  },
497
503
  ],
@@ -150,6 +150,12 @@ const DiscoverLayout = dynamic(() => import('./(main)/discover/_layout/Mobile/in
150
150
  ssr: false,
151
151
  });
152
152
 
153
+ // NotFound component
154
+ const NotFoundPage = dynamic(() => import('@/components/404'), {
155
+ loading: () => <Loading />,
156
+ ssr: false,
157
+ });
158
+
153
159
  // Knowledge components
154
160
  const KnowledgeHome = dynamic(() => import('./(main)/knowledge/routes/KnowledgeHome'), {
155
161
  loading: () => <Loading />,
@@ -535,7 +541,7 @@ export const createMobileRouter = (locale: Locales) =>
535
541
 
536
542
  // Catch-all route
537
543
  {
538
- loader: () => redirect('/chat', { status: 302 }),
544
+ element: <NotFoundPage />,
539
545
  path: '*',
540
546
  },
541
547
  ],
package/src/auth.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
2
2
  import { createNanoId, idGenerator, serverDB } from '@lobechat/database';
3
3
  import { betterAuth } from 'better-auth';
4
+ import { emailHarmony } from 'better-auth-harmony';
4
5
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
5
6
  import { admin, genericOAuth, magicLink } from 'better-auth/plugins';
6
7
 
@@ -176,6 +177,7 @@ export const auth = betterAuth({
176
177
  },
177
178
  },
178
179
  plugins: [
180
+ emailHarmony({ allowNormalizedSignin: false }),
179
181
  admin(),
180
182
  ...(genericOAuthProviders.length > 0
181
183
  ? [
@@ -54,10 +54,11 @@ export default {
54
54
  },
55
55
  betterAuth: {
56
56
  errors: {
57
- emailInvalid: '请输入有效的邮箱地址',
58
- emailNotRegistered: '该邮箱尚未注册',
57
+ emailExists: '该邮箱已注册,请直接登录',
58
+ emailInvalid: '请输入有效的邮箱地址或用户名',
59
+ emailNotRegistered: '该邮箱或用户名尚未注册',
59
60
  emailNotVerified: '邮箱尚未验证,请先验证邮箱',
60
- emailRequired: '请输入邮箱地址',
61
+ emailRequired: '请输入邮箱或用户名',
61
62
  firstNameRequired: '请输入名字',
62
63
  lastNameRequired: '请输入姓氏',
63
64
  loginFailed: '登录失败,请检查邮箱和密码',
@@ -65,6 +66,7 @@ export default {
65
66
  passwordMaxLength: '密码最多不超过 64 个字符',
66
67
  passwordMinLength: '密码至少需要 8 个字符',
67
68
  passwordRequired: '请输入密码',
69
+ usernameNotRegistered: '该用户名尚未注册',
68
70
  usernameRequired: '请输入用户名',
69
71
  },
70
72
  resetPassword: {
@@ -99,9 +101,8 @@ export default {
99
101
  continueWithOkta: '使用 Okta 登录',
100
102
  continueWithWechat: '使用微信登录',
101
103
  continueWithZitadel: '使用 Zitadel 登录',
102
- emailPlaceholder: '请输入邮箱地址',
104
+ emailPlaceholder: '请输入邮箱或用户名',
103
105
  emailStep: {
104
- subtitle: '请输入您的邮箱地址以继续',
105
106
  title: '登录',
106
107
  },
107
108
  error: '登录失败,请检查邮箱和密码',
@@ -194,6 +195,7 @@ export default {
194
195
  resetPasswordError: '发送密码重置链接失败',
195
196
  resetPasswordSent: '密码重置链接已发送,请检查邮箱',
196
197
  save: '保存',
198
+ setPassword: '设置密码',
197
199
  sso: {
198
200
  link: {
199
201
  button: '连接帐户',
@@ -202,16 +204,22 @@ export default {
202
204
  loading: '正在加载已绑定的第三方账户',
203
205
  providers: '连接的帐户',
204
206
  unlink: {
205
- description:
206
- '解绑后,您将无法使用 {{provider}} 账户"{{providerAccountId}}"登录。如果您需要重新绑定 {{provider}} 账户到当前账户,请确保 {{provider}} 账户的邮件地址为 {{email}} ,我们会在登陆时为你自动绑定到当前登录账户。',
207
- forbidden: '您至少需要保留一个第三方账户绑定。',
208
- title: '是否解绑该第三方账户 {{provider}} ?',
207
+ description: '解绑后再次使用 {{provider}} 登录需要重新授权或绑定。',
208
+ forbidden: '您至少需要保留一个登录方式。',
209
+ title: '确认解绑 {{provider}} 账户?',
209
210
  },
210
211
  },
211
212
  title: '个人资料详情',
212
213
  updateAvatar: '更新头像',
213
214
  updateFullName: '更新全名',
215
+ updateUsername: '更新用户名',
214
216
  username: '用户名',
217
+ usernameDuplicate: '用户名已被占用',
218
+ usernameInputHint: '请输入新的用户名',
219
+ usernamePlaceholder: '请输入由字母、数字或下划线组成的用户名',
220
+ usernameRequired: '用户名不能为空',
221
+ usernameRule: '用户名仅支持字母、数字或下划线',
222
+ usernameUpdateFailed: '更新用户名失败,请稍后重试',
215
223
  },
216
224
  signout: '退出登录',
217
225
  signup: '注册',
@@ -9,6 +9,7 @@ import {
9
9
  UserSettings,
10
10
  UserSettingsSchema,
11
11
  } from '@lobechat/types';
12
+ import { TRPCError } from '@trpc/server';
12
13
  import { v4 as uuidv4 } from 'uuid';
13
14
  import { z } from 'zod';
14
15
 
@@ -25,6 +26,12 @@ import { FileService } from '@/server/services/file';
25
26
  import { NextAuthUserService } from '@/server/services/nextAuthUser';
26
27
  import { UserService } from '@/server/services/user';
27
28
 
29
+ const usernameSchema = z
30
+ .string()
31
+ .trim()
32
+ .min(1, { message: 'USERNAME_REQUIRED' })
33
+ .regex(/^\w+$/, { message: 'USERNAME_INVALID' });
34
+
28
35
  const userProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
29
36
  return next({
30
37
  ctx: {
@@ -228,6 +235,17 @@ export const userRouter = router({
228
235
 
229
236
  return ctx.userModel.updateSetting(nextValue);
230
237
  }),
238
+
239
+ updateUsername: userProcedure.input(usernameSchema).mutation(async ({ ctx, input }) => {
240
+ const username = input.trim();
241
+
242
+ const existedUser = await UserModel.findByUsername(ctx.serverDB, username);
243
+ if (existedUser && existedUser.id !== ctx.userId) {
244
+ throw new TRPCError({ code: 'CONFLICT', message: 'USERNAME_TAKEN' });
245
+ }
246
+
247
+ return ctx.userModel.updateUser({ username });
248
+ }),
231
249
  });
232
250
 
233
251
  export type UserRouter = typeof userRouter;
@@ -37,6 +37,10 @@ export class UserService {
37
37
  return lambdaClient.user.updateFullName.mutate(fullName);
38
38
  };
39
39
 
40
+ updateUsername = async (username: string) => {
41
+ return lambdaClient.user.updateUsername.mutate(username);
42
+ };
43
+
40
44
  updatePreference = async (preference: Partial<UserPreference>) => {
41
45
  return lambdaClient.user.updatePreference.mutate(preference);
42
46
  };
@@ -66,7 +66,7 @@ afterEach(() => {
66
66
  useUserStore.setState({
67
67
  isLoadedAuthProviders: false,
68
68
  authProviders: [],
69
- isEmailPasswordAuth: false,
69
+ hasPasswordAccount: false,
70
70
  });
71
71
  });
72
72
 
@@ -312,7 +312,7 @@ describe('createAuthSlice', () => {
312
312
 
313
313
  expect(mockBetterAuthClient.listAccounts).toHaveBeenCalled();
314
314
  expect(result.current.isLoadedAuthProviders).toBe(true);
315
- expect(result.current.isEmailPasswordAuth).toBe(true);
315
+ expect(result.current.hasPasswordAccount).toBe(true);
316
316
  });
317
317
 
318
318
  it('should handle fetch error gracefully', async () => {
@@ -7,7 +7,7 @@ import { userService } from '@/services/user';
7
7
  import type { UserStore } from '../../store';
8
8
 
9
9
  interface AuthProvidersData {
10
- isEmailPasswordAuth: boolean;
10
+ hasPasswordAccount: boolean;
11
11
  providers: SSOProvider[];
12
12
  }
13
13
 
@@ -36,7 +36,7 @@ const fetchAuthProvidersData = async (): Promise<AuthProvidersData> => {
36
36
  const { accountInfo, listAccounts } = await import('@/libs/better-auth/auth-client');
37
37
  const result = await listAccounts();
38
38
  const accounts = result.data || [];
39
- const isEmailPasswordAuth = accounts.some((account) => account.providerId === 'credential');
39
+ const hasPasswordAccount = accounts.some((account) => account.providerId === 'credential');
40
40
  const providers = await Promise.all(
41
41
  accounts
42
42
  .filter((account) => account.providerId !== 'credential')
@@ -51,12 +51,12 @@ const fetchAuthProvidersData = async (): Promise<AuthProvidersData> => {
51
51
  };
52
52
  }),
53
53
  );
54
- return { isEmailPasswordAuth, providers };
54
+ return { hasPasswordAccount, providers };
55
55
  }
56
56
 
57
57
  // Fallback for NextAuth
58
58
  const providers = await userService.getUserSSOProviders();
59
- return { isEmailPasswordAuth: false, providers };
59
+ return { hasPasswordAccount: false, providers };
60
60
  };
61
61
 
62
62
  export const createAuthSlice: StateCreator<
@@ -73,8 +73,8 @@ export const createAuthSlice: StateCreator<
73
73
  if (get().isLoadedAuthProviders) return;
74
74
 
75
75
  try {
76
- const { isEmailPasswordAuth, providers } = await fetchAuthProvidersData();
77
- set({ authProviders: providers, isEmailPasswordAuth, isLoadedAuthProviders: true });
76
+ const { hasPasswordAccount, providers } = await fetchAuthProvidersData();
77
+ set({ authProviders: providers, hasPasswordAccount, isLoadedAuthProviders: true });
78
78
  } catch (error) {
79
79
  console.error('Failed to fetch auth providers:', error);
80
80
  set({ isLoadedAuthProviders: true });
@@ -139,8 +139,8 @@ export const createAuthSlice: StateCreator<
139
139
  },
140
140
  refreshAuthProviders: async () => {
141
141
  try {
142
- const { isEmailPasswordAuth, providers } = await fetchAuthProvidersData();
143
- set({ authProviders: providers, isEmailPasswordAuth });
142
+ const { hasPasswordAccount, providers } = await fetchAuthProvidersData();
143
+ set({ authProviders: providers, hasPasswordAccount });
144
144
  } catch (error) {
145
145
  console.error('Failed to refresh auth providers:', error);
146
146
  }
@@ -22,7 +22,7 @@ export interface UserAuthState {
22
22
  /**
23
23
  * Whether user registered with email/password (credential login)
24
24
  */
25
- isEmailPasswordAuth?: boolean;
25
+ hasPasswordAccount?: boolean;
26
26
  isLoaded?: boolean;
27
27
  isLoadedAuthProviders?: boolean;
28
28
 
@@ -57,7 +57,7 @@ const isLogin = (s: UserStore) => {
57
57
 
58
58
  export const authSelectors = {
59
59
  authProviders: (s: UserStore): SSOProvider[] => s.authProviders || [],
60
- isEmailPasswordAuth: (s: UserStore) => s.isEmailPasswordAuth ?? false,
60
+ hasPasswordAccount: (s: UserStore) => s.hasPasswordAccount ?? false,
61
61
  isLoaded: (s: UserStore) => s.isLoaded,
62
62
  isLoadedAuthProviders: (s: UserStore) => s.isLoadedAuthProviders ?? false,
63
63
  isLogin,
@@ -26,6 +26,7 @@ export interface CommonAction {
26
26
  updateAvatar: (avatar: string) => Promise<void>;
27
27
  updateFullName: (fullName: string) => Promise<void>;
28
28
  updateKeyVaultConfig: (provider: string, config: any) => Promise<void>;
29
+ updateUsername: (username: string) => Promise<void>;
29
30
  useCheckTrace: (shouldFetch: boolean) => SWRResponse;
30
31
  useInitUserState: (
31
32
  isLogin: boolean | undefined,
@@ -60,6 +61,11 @@ export const createCommonSlice: StateCreator<
60
61
  await get().setSettings({ keyVaults: { [provider]: config } });
61
62
  },
62
63
 
64
+ updateUsername: async (username) => {
65
+ await userService.updateUsername(username);
66
+ await get().refreshUserState();
67
+ },
68
+
63
69
  useCheckTrace: (shouldFetch) =>
64
70
  useSWR<boolean>(
65
71
  shouldFetch ? 'checkTrace' : null,