@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
@@ -413,6 +413,13 @@
413
413
  "when": 1764842015809,
414
414
  "tag": "0058_add_source_into_user_plugins",
415
415
  "breakpoints": true
416
+ },
417
+ {
418
+ "idx": 59,
419
+ "version": "7",
420
+ "when": 1764858574403,
421
+ "tag": "0059_add_normalized_email_indexes",
422
+ "breakpoints": true
416
423
  }
417
424
  ],
418
425
  "version": "6"
@@ -926,5 +926,17 @@
926
926
  "bps": true,
927
927
  "folderMillis": 1764842015809,
928
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"
929
941
  }
930
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
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;
@@ -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
  }
@@ -3,7 +3,15 @@
3
3
  import { LoadingOutlined } from '@ant-design/icons';
4
4
  import { Button, Divider, Input, Skeleton, Spin, Typography, Upload } from 'antd';
5
5
  import { AnimatePresence, motion } from 'framer-motion';
6
- import { CSSProperties, ReactNode, memo, useCallback, useEffect, useState } from 'react';
6
+ import {
7
+ CSSProperties,
8
+ ChangeEvent,
9
+ ReactNode,
10
+ memo,
11
+ useCallback,
12
+ useEffect,
13
+ useState,
14
+ } from 'react';
7
15
  import { useTranslation } from 'react-i18next';
8
16
  import { Flexbox } from 'react-layout-kit';
9
17
 
@@ -201,9 +209,145 @@ const FullNameRow = memo(() => {
201
209
  );
202
210
  });
203
211
 
212
+ const UsernameRow = memo(() => {
213
+ const { t } = useTranslation('auth');
214
+ const username = useUserStore(userProfileSelectors.username);
215
+ const updateUsername = useUserStore((s) => s.updateUsername);
216
+ const [isEditing, setIsEditing] = useState(false);
217
+ const [editValue, setEditValue] = useState('');
218
+ const [saving, setSaving] = useState(false);
219
+ const [error, setError] = useState('');
220
+
221
+ const usernameRegex = /^\w+$/;
222
+
223
+ const handleStartEdit = () => {
224
+ setEditValue(username || '');
225
+ setError('');
226
+ setIsEditing(true);
227
+ };
228
+
229
+ const handleCancel = () => {
230
+ setIsEditing(false);
231
+ setEditValue('');
232
+ setError('');
233
+ };
234
+
235
+ const validateUsername = (value: string): string => {
236
+ const trimmed = value.trim();
237
+ if (!trimmed) return t('profile.usernameRequired');
238
+ if (!usernameRegex.test(trimmed)) return t('profile.usernameRule');
239
+ return '';
240
+ };
241
+
242
+ const handleSave = useCallback(async () => {
243
+ const validationError = validateUsername(editValue);
244
+ if (validationError) {
245
+ setError(validationError);
246
+ return;
247
+ }
248
+
249
+ try {
250
+ setSaving(true);
251
+ setError('');
252
+ await updateUsername(editValue.trim());
253
+ setIsEditing(false);
254
+ } catch (err: any) {
255
+ console.error('Failed to update username:', err);
256
+ // Handle duplicate username error
257
+ if (err?.data?.code === 'CONFLICT' || err?.message === 'USERNAME_TAKEN') {
258
+ setError(t('profile.usernameDuplicate'));
259
+ } else {
260
+ setError(t('profile.usernameUpdateFailed'));
261
+ }
262
+ } finally {
263
+ setSaving(false);
264
+ }
265
+ }, [editValue, updateUsername, t]);
266
+
267
+ const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
268
+ const value = e.target.value;
269
+ setEditValue(value);
270
+
271
+ if (!value.trim()) {
272
+ setError('');
273
+ return;
274
+ }
275
+
276
+ if (!usernameRegex.test(value)) {
277
+ setError(t('profile.usernameRule'));
278
+ return;
279
+ }
280
+
281
+ setError('');
282
+ };
283
+
284
+ return (
285
+ <Flexbox gap={24} horizontal style={rowStyle}>
286
+ <Typography.Text style={labelStyle}>{t('profile.username')}</Typography.Text>
287
+ <Flexbox style={{ flex: 1 }}>
288
+ <AnimatePresence mode="wait">
289
+ {isEditing ? (
290
+ <motion.div
291
+ animate={{ opacity: 1, y: 0 }}
292
+ exit={{ opacity: 0, y: -10 }}
293
+ initial={{ opacity: 0, y: -10 }}
294
+ key="editing"
295
+ transition={{ duration: 0.2 }}
296
+ >
297
+ <Flexbox gap={12}>
298
+ <Typography.Text strong>{t('profile.usernameInputHint')}</Typography.Text>
299
+ <Input
300
+ autoFocus
301
+ onChange={handleInputChange}
302
+ onPressEnter={handleSave}
303
+ placeholder={t('profile.usernamePlaceholder')}
304
+ status={error ? 'error' : undefined}
305
+ value={editValue}
306
+ />
307
+ {error && (
308
+ <Typography.Text style={{ fontSize: 12 }} type="danger">
309
+ {error}
310
+ </Typography.Text>
311
+ )}
312
+ <Flexbox gap={8} horizontal justify="flex-end">
313
+ <Button disabled={saving} onClick={handleCancel} size="small">
314
+ {t('profile.cancel')}
315
+ </Button>
316
+ <Button loading={saving} onClick={handleSave} size="small" type="primary">
317
+ {t('profile.save')}
318
+ </Button>
319
+ </Flexbox>
320
+ </Flexbox>
321
+ </motion.div>
322
+ ) : (
323
+ <motion.div
324
+ animate={{ opacity: 1 }}
325
+ exit={{ opacity: 0 }}
326
+ initial={{ opacity: 0 }}
327
+ key="display"
328
+ transition={{ duration: 0.2 }}
329
+ >
330
+ <Flexbox align="center" horizontal justify="space-between">
331
+ <Typography.Text>{username || '--'}</Typography.Text>
332
+ <Typography.Text
333
+ onClick={handleStartEdit}
334
+ style={{ cursor: 'pointer', fontSize: 13 }}
335
+ >
336
+ {t('profile.updateUsername')}
337
+ </Typography.Text>
338
+ </Flexbox>
339
+ </motion.div>
340
+ )}
341
+ </AnimatePresence>
342
+ </Flexbox>
343
+ </Flexbox>
344
+ );
345
+ });
346
+
204
347
  const PasswordRow = memo(() => {
205
348
  const { t } = useTranslation('auth');
206
349
  const userProfile = useUserStore(userProfileSelectors.userProfile);
350
+ const hasPasswordAccount = useUserStore(authSelectors.hasPasswordAccount);
207
351
  const [sending, setSending] = useState(false);
208
352
 
209
353
  const handleChangePassword = useCallback(async () => {
@@ -239,12 +383,12 @@ const PasswordRow = memo(() => {
239
383
  opacity: sending ? 0.5 : 1,
240
384
  }}
241
385
  >
242
- {t('profile.changePassword')}
386
+ {hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
243
387
  </Typography.Text>
244
388
  }
245
389
  label={t('profile.password')}
246
390
  >
247
- <Typography.Text>••••••</Typography.Text>
391
+ <Typography.Text>{hasPasswordAccount ? '••••••' : '--'}</Typography.Text>
248
392
  </ProfileRow>
249
393
  );
250
394
  });
@@ -254,12 +398,10 @@ const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
254
398
  authSelectors.isLoginWithNextAuth(s),
255
399
  authSelectors.isLoginWithBetterAuth(s),
256
400
  ]);
257
- const [username, userProfile, isUserLoaded] = useUserStore((s) => [
258
- userProfileSelectors.username(s),
401
+ const [userProfile, isUserLoaded] = useUserStore((s) => [
259
402
  userProfileSelectors.userProfile(s),
260
403
  s.isLoaded,
261
404
  ]);
262
- const isEmailPasswordAuth = useUserStore(authSelectors.isEmailPasswordAuth);
263
405
  const isLoadedAuthProviders = useUserStore(authSelectors.isLoadedAuthProviders);
264
406
  const fetchAuthProviders = useUserStore((s) => s.fetchAuthProviders);
265
407
 
@@ -302,15 +444,13 @@ const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
302
444
 
303
445
  <Divider style={{ margin: 0 }} />
304
446
 
305
- {/* Username Row - Read Only */}
306
- <ProfileRow label={t('profile.username')}>
307
- <Typography.Text>{username || '--'}</Typography.Text>
308
- </ProfileRow>
447
+ {/* Username Row - Editable */}
448
+ <UsernameRow />
309
449
 
310
450
  <Divider style={{ margin: 0 }} />
311
451
 
312
- {/* Password Row - Only for Better Auth users with credential login */}
313
- {isLoginWithBetterAuth && isEmailPasswordAuth && (
452
+ {/* Password Row - For Better Auth users to change or set password */}
453
+ {isLoginWithBetterAuth && (
314
454
  <>
315
455
  <PasswordRow />
316
456
  <Divider style={{ margin: 0 }} />