@lobehub/chat 1.61.4 → 1.61.6

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 (40) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/docs/self-hosting/advanced/auth/next-auth/casdoor.mdx +2 -1
  4. package/docs/self-hosting/advanced/auth/next-auth/casdoor.zh-CN.mdx +2 -1
  5. package/locales/ar/auth.json +10 -1
  6. package/locales/bg-BG/auth.json +10 -1
  7. package/locales/de-DE/auth.json +10 -1
  8. package/locales/en-US/auth.json +10 -1
  9. package/locales/es-ES/auth.json +10 -1
  10. package/locales/fa-IR/auth.json +10 -1
  11. package/locales/fr-FR/auth.json +10 -1
  12. package/locales/it-IT/auth.json +10 -1
  13. package/locales/ja-JP/auth.json +10 -1
  14. package/locales/ko-KR/auth.json +10 -1
  15. package/locales/nl-NL/auth.json +10 -1
  16. package/locales/pl-PL/auth.json +10 -1
  17. package/locales/pt-BR/auth.json +10 -1
  18. package/locales/ru-RU/auth.json +10 -1
  19. package/locales/tr-TR/auth.json +10 -1
  20. package/locales/vi-VN/auth.json +10 -1
  21. package/locales/zh-CN/auth.json +9 -0
  22. package/locales/zh-TW/auth.json +10 -1
  23. package/package.json +1 -1
  24. package/src/app/(backend)/api/webhooks/casdoor/route.ts +5 -7
  25. package/src/app/(backend)/api/webhooks/casdoor/validateRequest.ts +7 -4
  26. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +9 -0
  27. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/AuthIcons.tsx +37 -0
  28. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +93 -0
  29. package/src/database/server/models/user.ts +24 -1
  30. package/src/locales/default/auth.ts +10 -0
  31. package/src/server/globalConfig/index.test.ts +81 -0
  32. package/src/server/routers/lambda/user.test.ts +305 -0
  33. package/src/server/routers/lambda/user.ts +32 -2
  34. package/src/server/services/nextAuthUser/index.ts +2 -2
  35. package/src/services/user/_deprecated.ts +9 -0
  36. package/src/services/user/client.ts +9 -0
  37. package/src/services/user/server.ts +11 -0
  38. package/src/services/user/type.ts +3 -0
  39. package/src/types/user/index.ts +5 -0
  40. package/src/utils/errorResponse.test.ts +37 -1
@@ -0,0 +1,93 @@
1
+ import { ActionIcon, CopyButton, List } from '@lobehub/ui';
2
+ import { RotateCw, Unlink } from 'lucide-react';
3
+ import { CSSProperties, memo, useState } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { Flexbox } from 'react-layout-kit';
6
+
7
+ import { modal, notification } from '@/components/AntdStaticMethods';
8
+ import { useOnlyFetchOnceSWR } from '@/libs/swr';
9
+ import { userService } from '@/services/user';
10
+ import { useUserStore } from '@/store/user';
11
+ import { userProfileSelectors } from '@/store/user/selectors';
12
+
13
+ import AuthIcons from './AuthIcons';
14
+
15
+ const { Item } = List;
16
+
17
+ const providerNameStyle: CSSProperties = {
18
+ textTransform: 'capitalize',
19
+ };
20
+
21
+ export const SSOProvidersList = memo(() => {
22
+ const [userProfile] = useUserStore((s) => [userProfileSelectors.userProfile(s)]);
23
+ const { t } = useTranslation('auth');
24
+
25
+ const [allowUnlink, setAllowUnlink] = useState<boolean>(false);
26
+ const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
27
+
28
+ const { data, isLoading, mutate } = useOnlyFetchOnceSWR('profile-sso-providers', async () => {
29
+ const list = await userService.getUserSSOProviders();
30
+ setAllowUnlink(list?.length > 1);
31
+ return list;
32
+ });
33
+
34
+ const handleUnlinkSSO = async (provider: string, providerAccountId: string) => {
35
+ if (data?.length === 1 || !data) {
36
+ // At least one SSO provider should be linked
37
+ notification.error({
38
+ message: t('profile.sso.unlink.forbidden'),
39
+ });
40
+ return;
41
+ }
42
+ modal.confirm({
43
+ content: t('profile.sso.unlink.description', {
44
+ email: userProfile?.email || 'None',
45
+ provider,
46
+ providerAccountId,
47
+ }),
48
+ okButtonProps: {
49
+ danger: true,
50
+ },
51
+ onOk: async () => {
52
+ await userService.unlinkSSOProvider(provider, providerAccountId);
53
+ mutate();
54
+ },
55
+ title: <span style={providerNameStyle}>{t('profile.sso.unlink.title', { provider })}</span>,
56
+ });
57
+ };
58
+
59
+ return isLoading ? (
60
+ <Flexbox align={'center'} gap={4} horizontal>
61
+ <ActionIcon icon={RotateCw} spin />
62
+ {t('profile.sso.loading')}
63
+ </Flexbox>
64
+ ) : (
65
+ <Flexbox>
66
+ {data?.map((item, index) => (
67
+ <Item
68
+ actions={
69
+ <Flexbox gap={4} horizontal>
70
+ <CopyButton content={item.providerAccountId} size={'small'} />
71
+ <ActionIcon
72
+ disable={!allowUnlink}
73
+ icon={Unlink}
74
+ onClick={() => handleUnlinkSSO(item.provider, item.providerAccountId)}
75
+ size={'small'}
76
+ />
77
+ </Flexbox>
78
+ }
79
+ avatar={AuthIcons(item.provider)}
80
+ date={item.expires_at}
81
+ description={item.providerAccountId}
82
+ key={index}
83
+ onMouseEnter={() => setHoveredIndex(index)}
84
+ onMouseLeave={() => setHoveredIndex(null)}
85
+ showAction={hoveredIndex === index}
86
+ title={<span style={providerNameStyle}>{item.provider}</span>}
87
+ />
88
+ ))}
89
+ </Flexbox>
90
+ );
91
+ });
92
+
93
+ export default SSOProvidersList;
@@ -1,6 +1,7 @@
1
1
  import { TRPCError } from '@trpc/server';
2
2
  import dayjs from 'dayjs';
3
3
  import { eq } from 'drizzle-orm/expressions';
4
+ import type { AdapterAccount } from 'next-auth/adapters';
4
5
  import { DeepPartial } from 'utility-types';
5
6
 
6
7
  import { LobeChatDatabase } from '@/database/type';
@@ -9,7 +10,14 @@ import { UserKeyVaults, UserSettings } from '@/types/user/settings';
9
10
  import { merge } from '@/utils/merge';
10
11
  import { today } from '@/utils/time';
11
12
 
12
- import { NewUser, UserItem, UserSettingsItem, userSettings, users } from '../../schemas';
13
+ import {
14
+ NewUser,
15
+ UserItem,
16
+ UserSettingsItem,
17
+ nextauthAccounts,
18
+ userSettings,
19
+ users,
20
+ } from '../../schemas';
13
21
 
14
22
  type DecryptUserKeyVaults = (
15
23
  encryptKeyVaultsStr: string | null,
@@ -96,6 +104,21 @@ export class UserModel {
96
104
  };
97
105
  };
98
106
 
107
+ getUserSSOProviders = async () => {
108
+ const result = await this.db
109
+ .select({
110
+ expiresAt: nextauthAccounts.expires_at,
111
+ provider: nextauthAccounts.provider,
112
+ providerAccountId: nextauthAccounts.providerAccountId,
113
+ scope: nextauthAccounts.scope,
114
+ type: nextauthAccounts.type,
115
+ userId: nextauthAccounts.userId,
116
+ })
117
+ .from(nextauthAccounts)
118
+ .where(eq(nextauthAccounts.userId, this.userId));
119
+ return result as unknown as AdapterAccount[];
120
+ };
121
+
99
122
  getUserSettings = async () => {
100
123
  return this.db.query.userSettings.findFirst({ where: eq(userSettings.id, this.userId) });
101
124
  };
@@ -34,6 +34,16 @@ export default {
34
34
  profile: {
35
35
  avatar: '头像',
36
36
  email: '电子邮件地址',
37
+ sso: {
38
+ loading: '正在加载已绑定的第三方账户',
39
+ providers: '连接的帐户',
40
+ unlink: {
41
+ description:
42
+ '解绑后,您将无法使用 {{provider}} 账户“{{providerAccountId}}”登录。如果您需要重新绑定 {{provider}} 账户到当前账户,请确保 {{provider}} 账户的邮件地址为 {{email}} ,我们会在登陆时为你自动绑定到当前登录账户。',
43
+ forbidden: '您至少需要保留一个第三方账户绑定。',
44
+ title: '是否解绑该第三方账户 {{provider}} ?',
45
+ },
46
+ },
37
47
  username: '用户名',
38
48
  },
39
49
  signout: '退出登录',
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { getAppConfig } from '@/config/app';
4
+ import { knowledgeEnv } from '@/config/knowledge';
5
+ import { SystemEmbeddingConfig } from '@/types/knowledgeBase';
6
+ import { FilesConfigItem } from '@/types/user/settings/filesConfig';
7
+
8
+ import { getServerDefaultAgentConfig, getServerDefaultFilesConfig } from './index';
9
+ import { parseAgentConfig } from './parseDefaultAgent';
10
+ import { parseFilesConfig } from './parseFilesConfig';
11
+
12
+ vi.mock('@/config/app', () => ({
13
+ getAppConfig: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('@/config/knowledge', () => ({
17
+ knowledgeEnv: {
18
+ DEFAULT_FILES_CONFIG: 'test_config',
19
+ },
20
+ }));
21
+
22
+ vi.mock('./parseDefaultAgent', () => ({
23
+ parseAgentConfig: vi.fn(),
24
+ }));
25
+
26
+ vi.mock('./parseFilesConfig', () => ({
27
+ parseFilesConfig: vi.fn(),
28
+ }));
29
+
30
+ describe('getServerDefaultAgentConfig', () => {
31
+ it('should return parsed agent config', () => {
32
+ const mockConfig = { key: 'value' };
33
+ vi.mocked(getAppConfig).mockReturnValue({
34
+ DEFAULT_AGENT_CONFIG: 'test_agent_config',
35
+ } as any);
36
+ vi.mocked(parseAgentConfig).mockReturnValue(mockConfig);
37
+
38
+ const result = getServerDefaultAgentConfig();
39
+
40
+ expect(parseAgentConfig).toHaveBeenCalledWith('test_agent_config');
41
+ expect(result).toEqual(mockConfig);
42
+ });
43
+
44
+ it('should return empty object if parseAgentConfig returns undefined', () => {
45
+ vi.mocked(getAppConfig).mockReturnValue({
46
+ DEFAULT_AGENT_CONFIG: 'test_agent_config',
47
+ } as any);
48
+ vi.mocked(parseAgentConfig).mockReturnValue(undefined);
49
+
50
+ const result = getServerDefaultAgentConfig();
51
+
52
+ expect(result).toEqual({});
53
+ });
54
+ });
55
+
56
+ describe('getServerDefaultFilesConfig', () => {
57
+ it('should return parsed files config', () => {
58
+ const mockEmbeddingModel: FilesConfigItem = {
59
+ model: 'test-model',
60
+ provider: 'test-provider',
61
+ };
62
+
63
+ const mockRerankerModel: FilesConfigItem = {
64
+ model: 'test-reranker',
65
+ provider: 'test-provider',
66
+ };
67
+
68
+ const mockConfig: SystemEmbeddingConfig = {
69
+ embeddingModel: mockEmbeddingModel,
70
+ queryMode: 'hybrid',
71
+ rerankerModel: mockRerankerModel,
72
+ };
73
+
74
+ vi.mocked(parseFilesConfig).mockReturnValue(mockConfig);
75
+
76
+ const result = getServerDefaultFilesConfig();
77
+
78
+ expect(parseFilesConfig).toHaveBeenCalledWith('test_config');
79
+ expect(result).toEqual(mockConfig);
80
+ });
81
+ });
@@ -0,0 +1,305 @@
1
+ // @vitest-environment node
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { enableClerk } from '@/const/auth';
5
+ import { serverDB } from '@/database/server';
6
+ import { MessageModel } from '@/database/server/models/message';
7
+ import { SessionModel } from '@/database/server/models/session';
8
+ import { UserModel, UserNotFoundError } from '@/database/server/models/user';
9
+ import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
10
+ import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
11
+ import { UserService } from '@/server/services/user';
12
+
13
+ import { userRouter } from './user';
14
+
15
+ // Mock modules
16
+ vi.mock('@clerk/nextjs/server', () => ({
17
+ currentUser: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('@/database/server', () => ({
21
+ serverDB: {},
22
+ }));
23
+
24
+ vi.mock('@/database/server/models/message');
25
+ vi.mock('@/database/server/models/session');
26
+ vi.mock('@/database/server/models/user');
27
+ vi.mock('@/libs/next-auth/adapter');
28
+ vi.mock('@/server/modules/KeyVaultsEncrypt');
29
+ vi.mock('@/server/services/user');
30
+ vi.mock('@/const/auth', () => ({
31
+ enableClerk: true,
32
+ }));
33
+
34
+ describe('userRouter', () => {
35
+ const mockUserId = 'test-user-id';
36
+ const mockCtx = {
37
+ userId: mockUserId,
38
+ };
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ describe('getUserRegistrationDuration', () => {
45
+ it('should return registration duration', async () => {
46
+ const mockDuration = { duration: 100, createdAt: '2023-01-01', updatedAt: '2023-01-02' };
47
+ vi.mocked(UserModel).mockImplementation(
48
+ () =>
49
+ ({
50
+ getUserRegistrationDuration: vi.fn().mockResolvedValue(mockDuration),
51
+ }) as any,
52
+ );
53
+
54
+ const result = await userRouter.createCaller({ ...mockCtx }).getUserRegistrationDuration();
55
+
56
+ expect(result).toEqual(mockDuration);
57
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
58
+ });
59
+ });
60
+
61
+ describe('getUserSSOProviders', () => {
62
+ it('should return SSO providers', async () => {
63
+ const mockProviders = [
64
+ {
65
+ provider: 'google',
66
+ providerAccountId: '123',
67
+ userId: 'user-1',
68
+ type: 'oauth',
69
+ },
70
+ ];
71
+ vi.mocked(UserModel).mockImplementation(
72
+ () =>
73
+ ({
74
+ getUserSSOProviders: vi.fn().mockResolvedValue(mockProviders),
75
+ }) as any,
76
+ );
77
+
78
+ const result = await userRouter.createCaller({ ...mockCtx }).getUserSSOProviders();
79
+
80
+ expect(result).toEqual(mockProviders);
81
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
82
+ });
83
+ });
84
+
85
+ describe('getUserState', () => {
86
+ it('should return user state', async () => {
87
+ const mockState = {
88
+ isOnboarded: true,
89
+ preference: { telemetry: true },
90
+ settings: {},
91
+ userId: mockUserId,
92
+ };
93
+
94
+ vi.mocked(UserModel).mockImplementation(
95
+ () =>
96
+ ({
97
+ getUserState: vi.fn().mockResolvedValue(mockState),
98
+ }) as any,
99
+ );
100
+
101
+ vi.mocked(MessageModel).mockImplementation(
102
+ () =>
103
+ ({
104
+ hasMoreThanN: vi.fn().mockResolvedValue(true),
105
+ }) as any,
106
+ );
107
+
108
+ vi.mocked(SessionModel).mockImplementation(
109
+ () =>
110
+ ({
111
+ hasMoreThanN: vi.fn().mockResolvedValue(true),
112
+ }) as any,
113
+ );
114
+
115
+ const result = await userRouter.createCaller({ ...mockCtx }).getUserState();
116
+
117
+ expect(result).toEqual({
118
+ isOnboard: true,
119
+ preference: { telemetry: true },
120
+ settings: {},
121
+ hasConversation: true,
122
+ canEnablePWAGuide: true,
123
+ canEnableTrace: true,
124
+ userId: mockUserId,
125
+ });
126
+ });
127
+
128
+ it('should create new user when user not found (clerk enabled)', async () => {
129
+ const mockClerkUser = {
130
+ id: mockUserId,
131
+ createdAt: new Date(),
132
+ emailAddresses: [{ id: 'email-1', emailAddress: 'test@example.com' }],
133
+ firstName: 'Test',
134
+ lastName: 'User',
135
+ imageUrl: 'avatar.jpg',
136
+ phoneNumbers: [],
137
+ primaryEmailAddressId: 'email-1',
138
+ primaryPhoneNumberId: null,
139
+ username: 'testuser',
140
+ };
141
+
142
+ const { currentUser } = await import('@clerk/nextjs/server');
143
+ vi.mocked(currentUser).mockResolvedValue(mockClerkUser as any);
144
+
145
+ vi.mocked(UserService).mockImplementation(
146
+ () =>
147
+ ({
148
+ createUser: vi.fn().mockResolvedValue({ success: true }),
149
+ }) as any,
150
+ );
151
+
152
+ vi.mocked(UserModel).mockImplementation(
153
+ () =>
154
+ ({
155
+ getUserState: vi
156
+ .fn()
157
+ .mockRejectedValueOnce(new UserNotFoundError())
158
+ .mockResolvedValueOnce({
159
+ isOnboarded: false,
160
+ preference: { telemetry: null },
161
+ settings: {},
162
+ }),
163
+ }) as any,
164
+ );
165
+
166
+ vi.mocked(MessageModel).mockImplementation(
167
+ () =>
168
+ ({
169
+ hasMoreThanN: vi.fn().mockResolvedValue(false),
170
+ }) as any,
171
+ );
172
+
173
+ vi.mocked(SessionModel).mockImplementation(
174
+ () =>
175
+ ({
176
+ hasMoreThanN: vi.fn().mockResolvedValue(false),
177
+ }) as any,
178
+ );
179
+
180
+ const result = await userRouter.createCaller({ ...mockCtx } as any).getUserState();
181
+
182
+ expect(result).toEqual({
183
+ isOnboard: true,
184
+ preference: { telemetry: null },
185
+ settings: {},
186
+ hasConversation: false,
187
+ canEnablePWAGuide: false,
188
+ canEnableTrace: false,
189
+ userId: mockUserId,
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('makeUserOnboarded', () => {
195
+ it('should update user onboarded status', async () => {
196
+ vi.mocked(UserModel).mockImplementation(
197
+ () =>
198
+ ({
199
+ updateUser: vi.fn().mockResolvedValue({ rowCount: 1 }),
200
+ }) as any,
201
+ );
202
+
203
+ await userRouter.createCaller({ ...mockCtx }).makeUserOnboarded();
204
+
205
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
206
+ });
207
+ });
208
+
209
+ describe('unlinkSSOProvider', () => {
210
+ it('should unlink SSO provider successfully', async () => {
211
+ const mockInput = {
212
+ provider: 'google',
213
+ providerAccountId: '123',
214
+ };
215
+
216
+ const mockAccount = {
217
+ userId: mockUserId,
218
+ provider: 'google',
219
+ providerAccountId: '123',
220
+ type: 'oauth',
221
+ };
222
+
223
+ vi.mocked(LobeNextAuthDbAdapter).mockReturnValue({
224
+ getAccount: vi.fn().mockResolvedValue(mockAccount),
225
+ unlinkAccount: vi.fn().mockResolvedValue(undefined),
226
+ } as any);
227
+
228
+ await expect(
229
+ userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput),
230
+ ).resolves.not.toThrow();
231
+ });
232
+
233
+ it('should throw error if account does not exist', async () => {
234
+ const mockInput = {
235
+ provider: 'google',
236
+ providerAccountId: '123',
237
+ };
238
+
239
+ vi.mocked(LobeNextAuthDbAdapter).mockReturnValue({
240
+ getAccount: vi.fn().mockResolvedValue(null),
241
+ unlinkAccount: vi.fn(),
242
+ } as any);
243
+
244
+ await expect(
245
+ userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput),
246
+ ).rejects.toThrow('The account does not exist');
247
+ });
248
+
249
+ it('should throw error if adapter methods are not implemented', async () => {
250
+ const mockInput = {
251
+ provider: 'google',
252
+ providerAccountId: '123',
253
+ };
254
+
255
+ vi.mocked(LobeNextAuthDbAdapter).mockReturnValue({} as any);
256
+
257
+ await expect(
258
+ userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput),
259
+ ).rejects.toThrow('The method in LobeNextAuthDbAdapter `unlinkAccount` is not implemented');
260
+ });
261
+ });
262
+
263
+ describe('updateSettings', () => {
264
+ it('should update settings with encrypted key vaults', async () => {
265
+ const mockSettings = {
266
+ keyVaults: { openai: { key: 'test-key' } },
267
+ general: { language: 'en-US' },
268
+ };
269
+
270
+ const mockEncryptedVaults = 'encrypted-data';
271
+ const mockGateKeeper = {
272
+ encrypt: vi.fn().mockResolvedValue(mockEncryptedVaults),
273
+ };
274
+
275
+ vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValue(mockGateKeeper as any);
276
+ vi.mocked(UserModel).mockImplementation(
277
+ () =>
278
+ ({
279
+ updateSetting: vi.fn().mockResolvedValue({ rowCount: 1 }),
280
+ }) as any,
281
+ );
282
+
283
+ await userRouter.createCaller({ ...mockCtx }).updateSettings(mockSettings);
284
+
285
+ expect(mockGateKeeper.encrypt).toHaveBeenCalledWith(JSON.stringify(mockSettings.keyVaults));
286
+ });
287
+
288
+ it('should update settings without key vaults', async () => {
289
+ const mockSettings = {
290
+ general: { language: 'en-US' },
291
+ };
292
+
293
+ vi.mocked(UserModel).mockImplementation(
294
+ () =>
295
+ ({
296
+ updateSetting: vi.fn().mockResolvedValue({ rowCount: 1 }),
297
+ }) as any,
298
+ );
299
+
300
+ await userRouter.createCaller({ ...mockCtx }).updateSettings(mockSettings);
301
+
302
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
303
+ });
304
+ });
305
+ });
@@ -7,15 +7,24 @@ import { serverDB } from '@/database/server';
7
7
  import { MessageModel } from '@/database/server/models/message';
8
8
  import { SessionModel } from '@/database/server/models/session';
9
9
  import { UserModel, UserNotFoundError } from '@/database/server/models/user';
10
+ import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
10
11
  import { authedProcedure, router } from '@/libs/trpc';
11
12
  import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
12
13
  import { UserService } from '@/server/services/user';
13
- import { UserGuideSchema, UserInitializationState, UserPreference } from '@/types/user';
14
+ import {
15
+ NextAuthAccountSchame,
16
+ UserGuideSchema,
17
+ UserInitializationState,
18
+ UserPreference,
19
+ } from '@/types/user';
14
20
  import { UserSettings } from '@/types/user/settings';
15
21
 
16
22
  const userProcedure = authedProcedure.use(async (opts) => {
17
23
  return opts.next({
18
- ctx: { userModel: new UserModel(serverDB, opts.ctx.userId) },
24
+ ctx: {
25
+ nextAuthDbAdapter: LobeNextAuthDbAdapter(serverDB),
26
+ userModel: new UserModel(serverDB, opts.ctx.userId),
27
+ },
19
28
  });
20
29
  });
21
30
 
@@ -24,6 +33,10 @@ export const userRouter = router({
24
33
  return ctx.userModel.getUserRegistrationDuration();
25
34
  }),
26
35
 
36
+ getUserSSOProviders: userProcedure.query(async ({ ctx }) => {
37
+ return ctx.userModel.getUserSSOProviders();
38
+ }),
39
+
27
40
  getUserState: userProcedure.query(async ({ ctx }): Promise<UserInitializationState> => {
28
41
  let state: Awaited<ReturnType<UserModel['getUserState']>> | undefined;
29
42
 
@@ -92,6 +105,23 @@ export const userRouter = router({
92
105
  return ctx.userModel.deleteSetting();
93
106
  }),
94
107
 
108
+ unlinkSSOProvider: userProcedure.input(NextAuthAccountSchame).mutation(async ({ ctx, input }) => {
109
+ const { provider, providerAccountId } = input;
110
+ if (
111
+ ctx.nextAuthDbAdapter?.unlinkAccount &&
112
+ typeof ctx.nextAuthDbAdapter.unlinkAccount === 'function' &&
113
+ ctx.nextAuthDbAdapter?.getAccount &&
114
+ typeof ctx.nextAuthDbAdapter.getAccount === 'function'
115
+ ) {
116
+ const account = await ctx.nextAuthDbAdapter.getAccount(providerAccountId, provider);
117
+ // The userId can either get from ctx.nextAuth?.id or ctx.userId
118
+ if (!account || account.userId !== ctx.userId) throw new Error('The account does not exist');
119
+ await ctx.nextAuthDbAdapter.unlinkAccount({ provider, providerAccountId });
120
+ } else {
121
+ throw new Error('The method in LobeNextAuthDbAdapter `unlinkAccount` is not implemented');
122
+ }
123
+ }),
124
+
95
125
  updateGuide: userProcedure.input(UserGuideSchema).mutation(async ({ ctx, input }) => {
96
126
  return ctx.userModel.updateGuide(input);
97
127
  }),
@@ -17,7 +17,7 @@ export class NextAuthUserService {
17
17
  { providerAccountId, provider }: { provider: string; providerAccountId: string },
18
18
  data: Partial<UserItem>,
19
19
  ) => {
20
- pino.info('updating user due to webhook');
20
+ pino.info(`updating user "${JSON.stringify({ provider, providerAccountId })}" due to webhook`);
21
21
  // 1. Find User by account
22
22
  // @ts-expect-error: Already impl in `LobeNextauthDbAdapter`
23
23
  const user = await this.adapter.getUserByAccount({
@@ -37,7 +37,7 @@ export class NextAuthUserService {
37
37
  });
38
38
  } else {
39
39
  pino.warn(
40
- `[${provider}]: Webhooks handler user update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`,
40
+ `[${provider}]: Webhooks handler user "${JSON.stringify({ provider, providerAccountId })}" update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`,
41
41
  );
42
42
  }
43
43
  return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
@@ -37,6 +37,15 @@ export class ClientService implements IUserService {
37
37
  };
38
38
  }
39
39
 
40
+ getUserSSOProviders = async () => {
41
+ // Account not exist on next-auth in client mode, no need to implement this method
42
+ return [];
43
+ };
44
+
45
+ unlinkSSOProvider = async () => {
46
+ // Account not exist on next-auth in client mode, no need to implement this method
47
+ };
48
+
40
49
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
41
50
  updateUserSettings = async (patch: DeepPartial<UserSettings>, _?: any) => {
42
51
  return UserModel.updateSettings(patch);
@@ -54,6 +54,15 @@ export class ClientService extends BaseClientService implements IUserService {
54
54
  };
55
55
  };
56
56
 
57
+ getUserSSOProviders: IUserService['getUserSSOProviders'] = async () => {
58
+ // Account not exist on next-auth in client mode, no need to implement this method
59
+ return [];
60
+ };
61
+
62
+ unlinkSSOProvider: IUserService['unlinkSSOProvider'] = async () => {
63
+ // Account not exist on next-auth in client mode, no need to implement this method
64
+ };
65
+
57
66
  updateUserSettings: IUserService['updateUserSettings'] = async (value) => {
58
67
  const { keyVaults, ...res } = value;
59
68
 
@@ -10,6 +10,17 @@ export class ServerService implements IUserService {
10
10
  return lambdaClient.user.getUserState.query();
11
11
  };
12
12
 
13
+ getUserSSOProviders: IUserService['getUserSSOProviders'] = async () => {
14
+ return lambdaClient.user.getUserSSOProviders.query();
15
+ };
16
+
17
+ unlinkSSOProvider: IUserService['unlinkSSOProvider'] = async (
18
+ provider: string,
19
+ providerAccountId: string,
20
+ ) => {
21
+ return lambdaClient.user.unlinkSSOProvider.mutate({ provider, providerAccountId });
22
+ };
23
+
13
24
  makeUserOnboarded = async () => {
14
25
  return lambdaClient.user.makeUserOnboarded.mutate();
15
26
  };
@@ -1,3 +1,4 @@
1
+ import type { AdapterAccount } from 'next-auth/adapters';
1
2
  import { DeepPartial } from 'utility-types';
2
3
 
3
4
  import { UserGuide, UserInitializationState, UserPreference } from '@/types/user';
@@ -9,8 +10,10 @@ export interface IUserService {
9
10
  duration: number;
10
11
  updatedAt: string;
11
12
  }>;
13
+ getUserSSOProviders: () => Promise<AdapterAccount[]>;
12
14
  getUserState: () => Promise<UserInitializationState>;
13
15
  resetUserSettings: () => Promise<any>;
16
+ unlinkSSOProvider: (provider: string, providerAccountId: string) => Promise<any>;
14
17
  updateGuide: (guide: Partial<UserGuide>) => Promise<any>;
15
18
  updatePreference: (preference: Partial<UserPreference>) => Promise<any>;
16
19
  updateUserSettings: (value: DeepPartial<UserSettings>, signal?: AbortSignal) => Promise<any>;