@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.
- package/.nvmrc +1 -1
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/docs/development/database-schema.dbml +6 -0
- package/locales/ar/auth.json +11 -2
- package/locales/ar/models.json +25 -13
- package/locales/bg-BG/auth.json +11 -2
- package/locales/bg-BG/models.json +25 -13
- package/locales/de-DE/auth.json +11 -2
- package/locales/de-DE/models.json +25 -13
- package/locales/en-US/auth.json +18 -9
- package/locales/en-US/models.json +25 -13
- package/locales/es-ES/auth.json +11 -2
- package/locales/es-ES/models.json +25 -13
- package/locales/fa-IR/auth.json +11 -2
- package/locales/fa-IR/models.json +25 -13
- package/locales/fr-FR/auth.json +11 -2
- package/locales/fr-FR/models.json +25 -13
- package/locales/it-IT/auth.json +11 -2
- package/locales/it-IT/models.json +25 -13
- package/locales/ja-JP/auth.json +11 -2
- package/locales/ja-JP/models.json +25 -13
- package/locales/ko-KR/auth.json +11 -2
- package/locales/ko-KR/models.json +25 -13
- package/locales/nl-NL/auth.json +11 -2
- package/locales/nl-NL/models.json +25 -13
- package/locales/pl-PL/auth.json +11 -2
- package/locales/pl-PL/models.json +25 -13
- package/locales/pt-BR/auth.json +11 -2
- package/locales/pt-BR/models.json +25 -13
- package/locales/ru-RU/auth.json +11 -2
- package/locales/ru-RU/models.json +25 -13
- package/locales/tr-TR/auth.json +11 -2
- package/locales/tr-TR/models.json +25 -13
- package/locales/vi-VN/auth.json +11 -2
- package/locales/vi-VN/models.json +25 -13
- package/locales/zh-CN/auth.json +18 -9
- package/locales/zh-CN/models.json +25 -13
- package/locales/zh-TW/auth.json +11 -2
- package/locales/zh-TW/models.json +25 -13
- package/next.config.ts +1 -1
- package/package.json +2 -1
- package/packages/database/migrations/0059_add_normalized_email_indexes.sql +4 -0
- package/packages/database/migrations/meta/0059_snapshot.json +8474 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/core/migrations.json +12 -0
- package/packages/database/src/models/user.ts +13 -1
- package/packages/database/src/schemas/user.ts +37 -29
- package/src/app/(backend)/api/auth/resolve-username/route.ts +52 -0
- package/src/app/[variants]/(auth)/signin/page.tsx +102 -14
- package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +15 -0
- package/src/app/[variants]/(main)/profile/(home)/Client.tsx +152 -12
- package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +4 -9
- package/src/app/[variants]/desktopRouter.config.tsx +7 -1
- package/src/app/[variants]/mobileRouter.config.tsx +7 -1
- package/src/auth.ts +2 -0
- package/src/locales/default/auth.ts +17 -9
- package/src/server/routers/lambda/user.ts +18 -0
- package/src/services/user/index.ts +4 -0
- package/src/store/user/slices/auth/action.test.ts +2 -2
- package/src/store/user/slices/auth/action.ts +8 -8
- package/src/store/user/slices/auth/initialState.ts +1 -1
- package/src/store/user/slices/auth/selectors.ts +1 -1
- 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(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
isOnboarded: boolean('is_onboarded').default(false),
|
|
24
|
+
// Time user was created in Clerk
|
|
25
|
+
clerkCreatedAt: timestamptz('clerk_created_at'),
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
+
preference: jsonb('preference').$defaultFn(() => DEFAULT_PREFERENCE),
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
40
|
+
// better-auth two-factor
|
|
41
|
+
twoFactorEnabled: boolean('two_factor_enabled').default(false),
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
// better-auth phone number
|
|
44
|
+
phoneNumberVerified: boolean('phone_number_verified'),
|
|
42
45
|
|
|
43
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
252
|
+
`/signup?email=${encodeURIComponent(targetEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
|
|
174
253
|
);
|
|
175
254
|
return;
|
|
176
255
|
}
|
|
177
|
-
setEmail(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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 {
|
|
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
|
|
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 [
|
|
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 -
|
|
306
|
-
<
|
|
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 -
|
|
313
|
-
{isLoginWithBetterAuth &&
|
|
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 }} />
|