@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
|
@@ -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
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 {
|
|
54
|
+
return { hasPasswordAccount, providers };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// Fallback for NextAuth
|
|
58
58
|
const providers = await userService.getUserSSOProviders();
|
|
59
|
-
return {
|
|
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 {
|
|
77
|
-
set({ authProviders: providers,
|
|
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 {
|
|
143
|
-
set({ authProviders: providers,
|
|
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
|
}
|
|
@@ -57,7 +57,7 @@ const isLogin = (s: UserStore) => {
|
|
|
57
57
|
|
|
58
58
|
export const authSelectors = {
|
|
59
59
|
authProviders: (s: UserStore): SSOProvider[] => s.authProviders || [],
|
|
60
|
-
|
|
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,
|