@lobehub/lobehub 2.0.0-next.157 → 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 (67) hide show
  1. package/.nvmrc +1 -1
  2. package/CHANGELOG.md +50 -0
  3. package/changelog/v1.json +18 -0
  4. package/docs/development/database-schema.dbml +7 -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/0058_add_source_into_user_plugins.sql +1 -0
  44. package/packages/database/migrations/0059_add_normalized_email_indexes.sql +4 -0
  45. package/packages/database/migrations/meta/0058_snapshot.json +8432 -0
  46. package/packages/database/migrations/meta/0059_snapshot.json +8474 -0
  47. package/packages/database/migrations/meta/_journal.json +14 -0
  48. package/packages/database/src/core/migrations.json +31 -32
  49. package/packages/database/src/models/user.ts +13 -1
  50. package/packages/database/src/schemas/user.ts +39 -31
  51. package/src/app/(backend)/api/auth/check-user/route.ts +0 -6
  52. package/src/app/(backend)/api/auth/resolve-username/route.ts +52 -0
  53. package/src/app/[variants]/(auth)/signin/page.tsx +102 -14
  54. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +15 -0
  55. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +152 -12
  56. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +4 -9
  57. package/src/app/[variants]/desktopRouter.config.tsx +7 -1
  58. package/src/app/[variants]/mobileRouter.config.tsx +7 -1
  59. package/src/auth.ts +2 -0
  60. package/src/locales/default/auth.ts +17 -9
  61. package/src/server/routers/lambda/user.ts +18 -0
  62. package/src/services/user/index.ts +4 -0
  63. package/src/store/user/slices/auth/action.test.ts +2 -2
  64. package/src/store/user/slices/auth/action.ts +8 -8
  65. package/src/store/user/slices/auth/initialState.ts +1 -1
  66. package/src/store/user/slices/auth/selectors.ts +1 -1
  67. package/src/store/user/slices/common/action.ts +6 -0
@@ -406,6 +406,20 @@
406
406
  "when": 1764734167674,
407
407
  "tag": "0057_add_topic_user_memory_extract_status",
408
408
  "breakpoints": true
409
+ },
410
+ {
411
+ "idx": 58,
412
+ "version": "7",
413
+ "when": 1764842015809,
414
+ "tag": "0058_add_source_into_user_plugins",
415
+ "breakpoints": true
416
+ },
417
+ {
418
+ "idx": 59,
419
+ "version": "7",
420
+ "when": 1764858574403,
421
+ "tag": "0059_add_normalized_email_indexes",
422
+ "breakpoints": true
409
423
  }
410
424
  ],
411
425
  "version": "6"
@@ -223,10 +223,7 @@
223
223
  "hash": "9646161fa041354714f823d726af27247bcd6e60fa3be5698c0d69f337a5700b"
224
224
  },
225
225
  {
226
- "sql": [
227
- "DROP TABLE \"user_budgets\";",
228
- "\nDROP TABLE \"user_subscriptions\";"
229
- ],
226
+ "sql": ["DROP TABLE \"user_budgets\";", "\nDROP TABLE \"user_subscriptions\";"],
230
227
  "bps": true,
231
228
  "folderMillis": 1729699958471,
232
229
  "hash": "7dad43a2a25d1aec82124a4e53f8d82f8505c3073f23606c1dc5d2a4598eacf9"
@@ -298,9 +295,7 @@
298
295
  "hash": "845a692ceabbfc3caf252a97d3e19a213bc0c433df2689900135f9cfded2cf49"
299
296
  },
300
297
  {
301
- "sql": [
302
- "ALTER TABLE \"messages\" ADD COLUMN \"reasoning\" jsonb;"
303
- ],
298
+ "sql": ["ALTER TABLE \"messages\" ADD COLUMN \"reasoning\" jsonb;"],
304
299
  "bps": true,
305
300
  "folderMillis": 1737609172353,
306
301
  "hash": "2cb36ae4fcdd7b7064767e04bfbb36ae34518ff4bb1b39006f2dd394d1893868"
@@ -515,9 +510,7 @@
515
510
  "hash": "a7ccf007fd185ff922823148d1eae6fafe652fc98d2fd2793f84a84f29e93cd1"
516
511
  },
517
512
  {
518
- "sql": [
519
- "ALTER TABLE \"ai_providers\" ADD COLUMN \"config\" jsonb;"
520
- ],
513
+ "sql": ["ALTER TABLE \"ai_providers\" ADD COLUMN \"config\" jsonb;"],
521
514
  "bps": true,
522
515
  "folderMillis": 1749309388370,
523
516
  "hash": "39cea379f08ee4cb944875c0b67f7791387b508c2d47958bb4cd501ed1ef33eb"
@@ -635,9 +628,7 @@
635
628
  "hash": "1ba9b1f74ea13348da98d6fcdad7867ab4316ed565bf75d84d160c526cdac14b"
636
629
  },
637
630
  {
638
- "sql": [
639
- "ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"virtual\" boolean DEFAULT false;"
640
- ],
631
+ "sql": ["ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"virtual\" boolean DEFAULT false;"],
641
632
  "bps": true,
642
633
  "folderMillis": 1759116400580,
643
634
  "hash": "433ddae88e785f2db734e49a4c115eee93e60afe389f7919d66e5ba9aa159a37"
@@ -687,17 +678,13 @@
687
678
  "hash": "4bdc6505797d7a33b622498c138cfd47f637239f6905e1c484cd01d9d5f21d6b"
688
679
  },
689
680
  {
690
- "sql": [
691
- "ALTER TABLE \"user_settings\" ADD COLUMN IF NOT EXISTS \"image\" jsonb;"
692
- ],
681
+ "sql": ["ALTER TABLE \"user_settings\" ADD COLUMN IF NOT EXISTS \"image\" jsonb;"],
693
682
  "bps": true,
694
683
  "folderMillis": 1760108430562,
695
684
  "hash": "ce09b301abb80f6563abc2f526bdd20b4f69bae430f09ba2179b9e3bfec43067"
696
685
  },
697
686
  {
698
- "sql": [
699
- "ALTER TABLE \"documents\" ADD COLUMN IF NOT EXISTS \"editor_data\" jsonb;"
700
- ],
687
+ "sql": ["ALTER TABLE \"documents\" ADD COLUMN IF NOT EXISTS \"editor_data\" jsonb;"],
701
688
  "bps": true,
702
689
  "folderMillis": 1761554153406,
703
690
  "hash": "bf2f21293e90e11cf60a784cf3ec219eafa95f7545d7d2f9d1449c0b0949599a"
@@ -777,17 +764,13 @@
777
764
  "hash": "923ccbdf46c32be9a981dabd348e6923b4a365444241e9b8cc174bf5b914cbc5"
778
765
  },
779
766
  {
780
- "sql": [
781
- "ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"market_identifier\" text;\n"
782
- ],
767
+ "sql": ["ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"market_identifier\" text;\n"],
783
768
  "bps": true,
784
769
  "folderMillis": 1762870034882,
785
770
  "hash": "4178aacb4b8892b7fd15d29209bbf9b1d1f9d7c406ba796f27542c0bcd919680"
786
771
  },
787
772
  {
788
- "sql": [
789
- "ALTER TABLE \"message_plugins\" ADD COLUMN IF NOT EXISTS \"intervention\" jsonb;\n"
790
- ],
773
+ "sql": ["ALTER TABLE \"message_plugins\" ADD COLUMN IF NOT EXISTS \"intervention\" jsonb;\n"],
791
774
  "bps": true,
792
775
  "folderMillis": 1762911968658,
793
776
  "hash": "552a032cc0e595277232e70b5f9338658585bafe9481ae8346a5f322b673a68b"
@@ -816,9 +799,7 @@
816
799
  "hash": "f823b521f4d25e5dc5ab238b372727d2d2d7f0aed27b5eabc8a9608ce4e50568"
817
800
  },
818
801
  {
819
- "sql": [
820
- "ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"editor_data\" jsonb;"
821
- ],
802
+ "sql": ["ALTER TABLE \"agents\" ADD COLUMN IF NOT EXISTS \"editor_data\" jsonb;"],
822
803
  "bps": true,
823
804
  "folderMillis": 1764215503726,
824
805
  "hash": "4188893a9083b3c7baebdbad0dd3f9d9400ede7584ca2394f5c64305dc9ec7b0"
@@ -859,9 +840,7 @@
859
840
  "hash": "2c103eee82bdf329944fb622dd9c2b9f20df80eb54f23eb9254d2285de413099"
860
841
  },
861
842
  {
862
- "sql": [
863
- "ALTER TABLE \"user_settings\" ADD COLUMN IF NOT EXISTS \"market\" jsonb;"
864
- ],
843
+ "sql": ["ALTER TABLE \"user_settings\" ADD COLUMN IF NOT EXISTS \"market\" jsonb;"],
865
844
  "bps": true,
866
845
  "folderMillis": 1764335703306,
867
846
  "hash": "28c0d738c0b1fdf5fd871363be1a1477b4accbabdc140fe8dc6e9b339aae2c89"
@@ -939,5 +918,25 @@
939
918
  "bps": true,
940
919
  "folderMillis": 1764734167674,
941
920
  "hash": "89c134be2948d3afc360d6bac11dea0c6fd5c902bf6093ed077033adb920fd02"
921
+ },
922
+ {
923
+ "sql": [
924
+ "ALTER TABLE \"user_installed_plugins\" ADD COLUMN IF NOT EXISTS \"source\" varchar(255);"
925
+ ],
926
+ "bps": true,
927
+ "folderMillis": 1764842015809,
928
+ "hash": "276514dc101fffc66794156f4ba644599a4ae5997a68b9e7f54452ee374bfa61"
929
+ },
930
+ {
931
+ "sql": [
932
+ "ALTER TABLE \"users\" ADD COLUMN IF NOT EXISTS \"normalized_email\" text;",
933
+ "\nCREATE INDEX IF NOT EXISTS \"users_email_idx\" ON \"users\" USING btree (\"email\");",
934
+ "\nCREATE INDEX IF NOT EXISTS \"users_username_idx\" ON \"users\" USING btree (\"username\");",
935
+ "\nCREATE UNIQUE INDEX IF NOT EXISTS \"users_normalized_email_unique_idx\" ON \"users\" USING btree (\"normalized_email\");",
936
+ "\n"
937
+ ],
938
+ "bps": true,
939
+ "folderMillis": 1764858574403,
940
+ "hash": "7838f9938b370867470e5e11807855253d23b11c2ac6aa9e90687844a356c949"
942
941
  }
943
- ]
942
+ ]
@@ -199,7 +199,7 @@ export class UserModel {
199
199
  * Normalize unique user fields so empty strings become null, keeping unique constraints safe.
200
200
  */
201
201
  private static normalizeUniqueUserFields = <
202
- T extends { email?: string | null; phone?: string | null },
202
+ T extends { email?: string | null; phone?: string | null; username?: string | null },
203
203
  >(
204
204
  value: T,
205
205
  ) => {
@@ -207,11 +207,16 @@ export class UserModel {
207
207
  typeof value.email === 'string' && value.email.trim() === '' ? null : value.email;
208
208
  const normalizedPhone =
209
209
  typeof value.phone === 'string' && value.phone.trim() === '' ? null : value.phone;
210
+ const normalizedUsername =
211
+ typeof value.username === 'string' && value.username.trim() === ''
212
+ ? null
213
+ : value.username?.trim();
210
214
 
211
215
  return {
212
216
  ...value,
213
217
  ...(value.email !== undefined ? { email: normalizedEmail } : {}),
214
218
  ...(value.phone !== undefined ? { phone: normalizedPhone } : {}),
219
+ ...(value.username !== undefined ? { username: normalizedUsername } : {}),
215
220
  };
216
221
  };
217
222
 
@@ -241,6 +246,13 @@ export class UserModel {
241
246
  return db.query.users.findFirst({ where: eq(users.id, id) });
242
247
  };
243
248
 
249
+ static findByUsername = async (db: LobeChatDatabase, username: string) => {
250
+ const normalizedUsername = username.trim();
251
+ if (!normalizedUsername) return null;
252
+
253
+ return db.query.users.findFirst({ where: eq(users.username, normalizedUsername) });
254
+ };
255
+
244
256
  static findByEmail = async (db: LobeChatDatabase, email: string) => {
245
257
  return db.query.users.findFirst({ where: eq(users.email, email) });
246
258
  };
@@ -2,46 +2,54 @@
2
2
  import { DEFAULT_PREFERENCE } from '@lobechat/const';
3
3
  import type { CustomPluginParams } from '@lobechat/types';
4
4
  import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
5
- import { boolean, jsonb, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
5
+ import { boolean, index, jsonb, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
6
6
 
7
- import { timestamps, timestamptz } from './_helpers';
7
+ import { timestamps, timestamptz, varchar255 } from './_helpers';
8
8
 
9
- export const users = pgTable('users', {
10
- id: text('id').primaryKey().notNull(),
11
- username: text('username').unique(),
12
- email: text('email').unique(),
9
+ export const users = pgTable(
10
+ 'users',
11
+ {
12
+ id: text('id').primaryKey().notNull(),
13
+ username: text('username').unique(),
14
+ email: text('email').unique(),
15
+ normalizedEmail: text('normalized_email').unique(),
13
16
 
14
- avatar: text('avatar'),
15
- phone: text('phone').unique(),
16
- firstName: text('first_name'),
17
- lastName: text('last_name'),
18
- fullName: text('full_name'),
17
+ avatar: text('avatar'),
18
+ phone: text('phone').unique(),
19
+ firstName: text('first_name'),
20
+ lastName: text('last_name'),
21
+ fullName: text('full_name'),
19
22
 
20
- isOnboarded: boolean('is_onboarded').default(false),
21
- // Time user was created in Clerk
22
- clerkCreatedAt: timestamptz('clerk_created_at'),
23
+ isOnboarded: boolean('is_onboarded').default(false),
24
+ // Time user was created in Clerk
25
+ clerkCreatedAt: timestamptz('clerk_created_at'),
23
26
 
24
- // Required by better-auth
25
- emailVerified: boolean('email_verified').default(false).notNull(),
26
- // Required by nextauth, all null allowed
27
- emailVerifiedAt: timestamptz('email_verified_at'),
27
+ // Required by better-auth
28
+ emailVerified: boolean('email_verified').default(false).notNull(),
29
+ // Required by nextauth, all null allowed
30
+ emailVerifiedAt: timestamptz('email_verified_at'),
28
31
 
29
- preference: jsonb('preference').$defaultFn(() => DEFAULT_PREFERENCE),
32
+ preference: jsonb('preference').$defaultFn(() => DEFAULT_PREFERENCE),
30
33
 
31
- // better-auth admin
32
- role: text('role'),
33
- banned: boolean('banned').default(false),
34
- banReason: text('ban_reason'),
35
- banExpires: timestamptz('ban_expires'),
34
+ // better-auth admin
35
+ role: text('role'),
36
+ banned: boolean('banned').default(false),
37
+ banReason: text('ban_reason'),
38
+ banExpires: timestamptz('ban_expires'),
36
39
 
37
- // better-auth two-factor
38
- twoFactorEnabled: boolean('two_factor_enabled').default(false),
40
+ // better-auth two-factor
41
+ twoFactorEnabled: boolean('two_factor_enabled').default(false),
39
42
 
40
- // better-auth phone number
41
- phoneNumberVerified: boolean('phone_number_verified'),
43
+ // better-auth phone number
44
+ phoneNumberVerified: boolean('phone_number_verified'),
42
45
 
43
- ...timestamps,
44
- });
46
+ ...timestamps,
47
+ },
48
+ (table) => ({
49
+ emailIdx: index('users_email_idx').on(table.email),
50
+ usernameIdx: index('users_username_idx').on(table.username),
51
+ }),
52
+ );
45
53
 
46
54
  export type NewUser = typeof users.$inferInsert;
47
55
  export type UserItem = typeof users.$inferSelect;
@@ -76,7 +84,7 @@ export const userInstalledPlugins = pgTable(
76
84
  manifest: jsonb('manifest').$type<LobeChatPluginManifest>(),
77
85
  settings: jsonb('settings'),
78
86
  customParams: jsonb('custom_params').$type<CustomPluginParams>(),
79
-
87
+ source: varchar255('source'),
80
88
  ...timestamps,
81
89
  },
82
90
  (self) => ({
@@ -6,10 +6,8 @@ import { users } from '@/database/schemas/user';
6
6
  import { serverDB } from '@/database/server';
7
7
 
8
8
  export interface CheckUserResponseData {
9
- emailVerified?: boolean;
10
9
  exists: boolean;
11
10
  hasPassword?: boolean;
12
- providers?: string[];
13
11
  }
14
12
 
15
13
  /**
@@ -47,18 +45,14 @@ export async function POST(req: NextRequest) {
47
45
  })
48
46
  .from(account)
49
47
  .where(and(eq(account.userId, user.id)));
50
-
51
- const providers = Array.from(new Set(accounts.map((a) => a.providerId).filter(Boolean)));
52
48
  const hasPassword = accounts.some(
53
49
  (a) =>
54
50
  a.providerId === 'credential' && typeof a.password === 'string' && a.password.length > 0,
55
51
  );
56
52
 
57
53
  return NextResponse.json({
58
- emailVerified: user.emailVerified,
59
54
  exists: true,
60
55
  hasPassword,
61
- providers,
62
56
  } satisfies CheckUserResponseData);
63
57
  } catch (error) {
64
58
  console.error('Error checking user existence:', error);
@@ -0,0 +1,52 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+
4
+ import { users } from '@/database/schemas/user';
5
+ import { serverDB } from '@/database/server';
6
+
7
+ export interface ResolveUsernameResponseData {
8
+ email?: string | null;
9
+ exists: boolean;
10
+ }
11
+
12
+ /**
13
+ * Resolve a username to the associated email address.
14
+ * @param req - POST request with { username: string }
15
+ * @returns { exists: boolean, email?: string | null }
16
+ */
17
+ export async function POST(req: NextRequest) {
18
+ try {
19
+ const body = await req.json();
20
+ const { username } = body;
21
+
22
+ if (!username || typeof username !== 'string') {
23
+ return NextResponse.json({ error: 'Username is required', exists: false }, { status: 400 });
24
+ }
25
+
26
+ const normalizedUsername = username.trim();
27
+
28
+ if (!normalizedUsername) {
29
+ return NextResponse.json({ error: 'Username is required', exists: false }, { status: 400 });
30
+ }
31
+
32
+ const [user] = await serverDB
33
+ .select({ email: users.email })
34
+ .from(users)
35
+ .where(eq(users.username, normalizedUsername))
36
+ .limit(1);
37
+
38
+ if (!user || !user.email) {
39
+ return NextResponse.json({ exists: false } satisfies ResolveUsernameResponseData);
40
+ }
41
+
42
+ return NextResponse.json({
43
+ email: user.email,
44
+ exists: true,
45
+ } satisfies ResolveUsernameResponseData);
46
+ } catch (error) {
47
+ console.error('Error resolving username to email:', error);
48
+ return NextResponse.json({ error: 'Internal server error', exists: false }, { status: 500 });
49
+ }
50
+ }
51
+
52
+ export const runtime = 'nodejs';
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
11
11
  import { Flexbox } from 'react-layout-kit';
12
12
 
13
13
  import type { CheckUserResponseData } from '@/app/(backend)/api/auth/check-user/route';
14
+ import type { ResolveUsernameResponseData } from '@/app/(backend)/api/auth/resolve-username/route';
14
15
  import { message } from '@/components/AntdStaticMethods';
15
16
  import AuthIcons from '@/components/NextAuth/AuthIcons';
16
17
  import { getAuthConfig } from '@/envs/auth';
@@ -18,7 +19,7 @@ import { requestPasswordReset, signIn } from '@/libs/better-auth/auth-client';
18
19
  import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client';
19
20
  import { useServerConfigStore } from '@/store/serverConfig';
20
21
 
21
- const useStyles = createStyles(({ css, token }) => ({
22
+ const useStyles = createStyles(({ css, token, responsive }) => ({
22
23
  backButton: css`
23
24
  cursor: pointer;
24
25
  font-size: 14px;
@@ -29,10 +30,13 @@ const useStyles = createStyles(({ css, token }) => ({
29
30
  }
30
31
  `,
31
32
  card: css`
33
+ display: flex;
34
+ flex-direction: column;
32
35
  padding-block: 2.5rem;
33
36
  padding-inline: 2rem;
34
37
  `,
35
38
  container: css`
39
+ overflow: hidden;
36
40
  width: 360px;
37
41
  border: 1px solid ${token.colorBorder};
38
42
  border-radius: ${token.borderRadiusLG}px;
@@ -42,6 +46,11 @@ const useStyles = createStyles(({ css, token }) => ({
42
46
  height: 1px;
43
47
  background: ${token.colorBorder};
44
48
  `,
49
+ dividerRow: css`
50
+ ${responsive.mobile} {
51
+ order: -1;
52
+ }
53
+ `,
45
54
  dividerText: css`
46
55
  font-size: 14px;
47
56
  color: ${token.colorTextSecondary};
@@ -51,6 +60,13 @@ const useStyles = createStyles(({ css, token }) => ({
51
60
  color: ${token.colorTextSecondary};
52
61
  text-align: center;
53
62
  `,
63
+ emailForm: css`
64
+ margin-block-start: 0.5rem;
65
+
66
+ ${responsive.mobile} {
67
+ margin-block: 0;
68
+ }
69
+ `,
54
70
  footer: css`
55
71
  padding: 1rem;
56
72
  border-block-start: 1px solid ${token.colorBorder};
@@ -61,6 +77,12 @@ const useStyles = createStyles(({ css, token }) => ({
61
77
 
62
78
  background: ${token.colorBgElevated};
63
79
  `,
80
+ socialSection: css`
81
+ ${responsive.mobile} {
82
+ order: 1;
83
+ margin-block-start: 1rem;
84
+ }
85
+ `,
64
86
  subtitle: css`
65
87
  margin-block-start: 0.5rem;
66
88
  font-size: 14px;
@@ -77,13 +99,22 @@ const useStyles = createStyles(({ css, token }) => ({
77
99
  `,
78
100
  }));
79
101
 
102
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
103
+ const USERNAME_REGEX = /^\w+$/;
104
+
80
105
  type Step = 'email' | 'password';
106
+ type IdentifierType = 'email' | 'username';
81
107
 
82
108
  interface SignInFormValues {
83
109
  email: string;
84
110
  password: string;
85
111
  }
86
112
 
113
+ interface ResolvedEmailResult {
114
+ email: string;
115
+ identifierType: IdentifierType;
116
+ }
117
+
87
118
  export default function SignInPage() {
88
119
  const { styles } = useStyles();
89
120
  const theme = useTheme();
@@ -154,12 +185,56 @@ export default function SignInPage() {
154
185
  }
155
186
  };
156
187
 
188
+ const resolveEmailFromIdentifier = async (
189
+ identifier: string,
190
+ ): Promise<ResolvedEmailResult | null> => {
191
+ const trimmedIdentifier = identifier.trim();
192
+
193
+ if (!trimmedIdentifier) return null;
194
+
195
+ const isEmailIdentifier = EMAIL_REGEX.test(trimmedIdentifier);
196
+
197
+ if (isEmailIdentifier) {
198
+ return { email: trimmedIdentifier.toLowerCase(), identifierType: 'email' };
199
+ }
200
+
201
+ if (!USERNAME_REGEX.test(trimmedIdentifier)) {
202
+ message.error(t('betterAuth.errors.emailInvalid'));
203
+ return null;
204
+ }
205
+
206
+ try {
207
+ const response = await fetch('/api/auth/resolve-username', {
208
+ body: JSON.stringify({ username: trimmedIdentifier }),
209
+ headers: { 'Content-Type': 'application/json' },
210
+ method: 'POST',
211
+ });
212
+
213
+ const data: ResolveUsernameResponseData = await response.json();
214
+
215
+ if (!response.ok || !data.exists || !data.email) {
216
+ message.error(t('betterAuth.errors.usernameNotRegistered'));
217
+ return null;
218
+ }
219
+
220
+ return { email: data.email, identifierType: 'username' };
221
+ } catch (error) {
222
+ console.error('Error resolving username:', error);
223
+ message.error(t('betterAuth.signin.error'));
224
+ return null;
225
+ }
226
+ };
227
+
157
228
  // Check if user exists
158
229
  const handleCheckUser = async (values: Pick<SignInFormValues, 'email'>) => {
159
230
  setLoading(true);
160
231
  try {
232
+ const resolvedEmail = await resolveEmailFromIdentifier(values.email);
233
+ if (!resolvedEmail) return;
234
+ const { email: targetEmail, identifierType } = resolvedEmail;
235
+
161
236
  const response = await fetch('/api/auth/check-user', {
162
- body: JSON.stringify({ email: values.email }),
237
+ body: JSON.stringify({ email: targetEmail }),
163
238
  headers: { 'Content-Type': 'application/json' },
164
239
  method: 'POST',
165
240
  });
@@ -167,14 +242,18 @@ export default function SignInPage() {
167
242
  const data: CheckUserResponseData = await response.json();
168
243
 
169
244
  if (!data.exists) {
170
- // User not found, redirect to signup page with email pre-filled
245
+ if (identifierType === 'username') {
246
+ message.error(t('betterAuth.errors.usernameNotRegistered'));
247
+ return;
248
+ }
249
+
171
250
  const callbackUrl = searchParams.get('callbackUrl') || '/';
172
251
  router.push(
173
- `/signup?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
252
+ `/signup?email=${encodeURIComponent(targetEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
174
253
  );
175
254
  return;
176
255
  }
177
- setEmail(values.email);
256
+ setEmail(targetEmail);
178
257
 
179
258
  if (data.hasPassword) {
180
259
  setStep('password');
@@ -182,7 +261,7 @@ export default function SignInPage() {
182
261
  }
183
262
 
184
263
  if (enableMagicLink) {
185
- await handleSendMagicLink(values.email);
264
+ await handleSendMagicLink(targetEmail);
186
265
  return;
187
266
  }
188
267
 
@@ -302,13 +381,11 @@ export default function SignInPage() {
302
381
 
303
382
  {step === 'email' && (
304
383
  <>
305
- <p className={styles.subtitle}>{t('betterAuth.signin.emailStep.subtitle')}</p>
306
-
307
384
  {/* Social Login Section Skeleton */}
308
385
  {!serverConfigInit && (
309
- <Flexbox gap={12} style={{ marginTop: '2rem' }}>
386
+ <Flexbox className={styles.socialSection} gap={12}>
310
387
  <Skeleton.Button active block size="large" />
311
- <Flexbox align="center" gap={12} horizontal>
388
+ <Flexbox align="center" className={styles.dividerRow} gap={12} horizontal>
312
389
  <div className={styles.divider} />
313
390
  <Skeleton.Input active size="small" style={{ minWidth: 80, width: 80 }} />
314
391
  <div className={styles.divider} />
@@ -318,7 +395,7 @@ export default function SignInPage() {
318
395
 
319
396
  {/* Social Login Section */}
320
397
  {serverConfigInit && oAuthSSOProviders.length > 0 && (
321
- <Flexbox gap={12} style={{ marginTop: '2rem' }}>
398
+ <Flexbox className={styles.socialSection} gap={12}>
322
399
  {oAuthSSOProviders.map((provider) => (
323
400
  <Button
324
401
  block
@@ -333,7 +410,7 @@ export default function SignInPage() {
333
410
  ))}
334
411
 
335
412
  {/* Divider */}
336
- <Flexbox align="center" gap={12} horizontal>
413
+ <Flexbox align="center" className={styles.dividerRow} gap={12} horizontal>
337
414
  <div className={styles.divider} />
338
415
  <span className={styles.dividerText}>
339
416
  {t('betterAuth.signin.orContinueWith')}
@@ -344,16 +421,27 @@ export default function SignInPage() {
344
421
  )}
345
422
 
346
423
  <Form
424
+ className={styles.emailForm}
347
425
  form={form}
348
426
  layout="vertical"
349
427
  onFinish={handleCheckUser}
350
- style={{ marginTop: '0.5rem' }}
351
428
  >
352
429
  <Form.Item
353
430
  name="email"
354
431
  rules={[
355
432
  { message: t('betterAuth.errors.emailRequired'), required: true },
356
- { message: t('betterAuth.errors.emailInvalid'), type: 'email' },
433
+ {
434
+ validator: (_, value) => {
435
+ if (!value) return Promise.resolve();
436
+
437
+ const trimmedValue = (value as string).trim();
438
+ if (EMAIL_REGEX.test(trimmedValue) || USERNAME_REGEX.test(trimmedValue)) {
439
+ return Promise.resolve();
440
+ }
441
+
442
+ return Promise.reject(new Error(t('betterAuth.errors.emailInvalid')));
443
+ },
444
+ },
357
445
  ]}
358
446
  style={{ marginBottom: 0 }}
359
447
  >
@@ -88,6 +88,21 @@ export default function BetterAuthSignUpForm() {
88
88
  });
89
89
 
90
90
  if (error) {
91
+ const isEmailDuplicate =
92
+ error.code === 'FAILED_TO_CREATE_USER' &&
93
+ // Postgres unique constraint violation
94
+ (error as any)?.details?.cause?.code === '23505';
95
+
96
+ if (isEmailDuplicate) {
97
+ message.error(t('betterAuth.errors.emailExists'));
98
+ return;
99
+ }
100
+
101
+ if (error.code === 'INVALID_EMAIL') {
102
+ message.error(t('betterAuth.errors.emailInvalid'));
103
+ return;
104
+ }
105
+
91
106
  message.error(error.message || t('betterAuth.signup.error'));
92
107
  return;
93
108
  }