@lobehub/lobehub 2.0.0-next.124 → 2.0.0-next.126

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 (117) hide show
  1. package/.cursor/rules/db-migrations.mdc +16 -1
  2. package/.cursor/rules/project-introduce.mdc +1 -1
  3. package/.cursor/rules/project-structure.mdc +20 -2
  4. package/.env.example +148 -65
  5. package/.env.example.development +6 -8
  6. package/AGENTS.md +1 -3
  7. package/CHANGELOG.md +50 -0
  8. package/Dockerfile +7 -5
  9. package/GEMINI.md +63 -0
  10. package/changelog/v1.json +18 -0
  11. package/docs/development/database-schema.dbml +37 -0
  12. package/docs/self-hosting/advanced/auth.mdx +82 -2
  13. package/docs/self-hosting/advanced/auth.zh-CN.mdx +82 -2
  14. package/docs/self-hosting/environment-variables/auth.mdx +187 -1
  15. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
  16. package/locales/en-US/auth.json +93 -0
  17. package/locales/zh-CN/auth.json +107 -1
  18. package/package.json +5 -2
  19. package/packages/const/src/auth.ts +2 -1
  20. package/packages/database/migrations/0049_better_auth.sql +49 -0
  21. package/packages/database/migrations/meta/0048_snapshot.json +312 -932
  22. package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
  23. package/packages/database/migrations/meta/_journal.json +8 -1
  24. package/packages/database/src/core/migrations.json +13 -0
  25. package/packages/database/src/index.ts +1 -0
  26. package/packages/database/src/models/__tests__/session.test.ts +1 -2
  27. package/packages/database/src/models/user.ts +9 -8
  28. package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
  29. package/packages/database/src/schemas/betterAuth.ts +63 -0
  30. package/packages/database/src/schemas/index.ts +1 -0
  31. package/packages/database/src/schemas/ragEvals.ts +1 -2
  32. package/packages/database/src/schemas/user.ts +3 -2
  33. package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
  34. package/packages/types/src/user/preference.ts +11 -0
  35. package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
  36. package/packages/utils/src/server/auth.ts +18 -1
  37. package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
  38. package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
  39. package/src/app/(backend)/middleware/auth/index.ts +14 -0
  40. package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
  41. package/src/app/(backend)/middleware/auth/utils.ts +13 -10
  42. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
  43. package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
  44. package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
  45. package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
  46. package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
  47. package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
  48. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
  49. package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
  50. package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
  51. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
  52. package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
  53. package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
  54. package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
  55. package/src/auth.ts +118 -0
  56. package/src/components/NextAuth/AuthIcons.tsx +3 -1
  57. package/src/envs/auth.ts +260 -13
  58. package/src/envs/email.ts +37 -0
  59. package/src/features/User/UserPanel/PanelContent.tsx +6 -5
  60. package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
  61. package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
  62. package/src/features/User/__tests__/useMenu.test.tsx +14 -12
  63. package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
  64. package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
  65. package/src/layout/AuthProvider/index.tsx +3 -0
  66. package/src/libs/better-auth/auth-client.ts +34 -0
  67. package/src/libs/better-auth/constants.ts +13 -0
  68. package/src/libs/better-auth/email-templates/index.ts +3 -0
  69. package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
  70. package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
  71. package/src/libs/better-auth/email-templates/verification.ts +108 -0
  72. package/src/libs/better-auth/sso/helpers.ts +61 -0
  73. package/src/libs/better-auth/sso/index.ts +113 -0
  74. package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
  75. package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
  76. package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
  77. package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
  78. package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
  79. package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
  80. package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
  81. package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
  82. package/src/libs/better-auth/sso/providers/github.ts +30 -0
  83. package/src/libs/better-auth/sso/providers/google.ts +30 -0
  84. package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
  85. package/src/libs/better-auth/sso/providers/logto.ts +38 -0
  86. package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
  87. package/src/libs/better-auth/sso/providers/okta.ts +37 -0
  88. package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
  89. package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
  90. package/src/libs/better-auth/sso/types.ts +25 -0
  91. package/src/libs/better-auth/utils/client.ts +1 -0
  92. package/src/libs/better-auth/utils/common.ts +20 -0
  93. package/src/libs/better-auth/utils/server.test.ts +61 -0
  94. package/src/libs/better-auth/utils/server.ts +18 -0
  95. package/src/libs/trpc/lambda/context.test.ts +116 -0
  96. package/src/libs/trpc/lambda/context.ts +27 -0
  97. package/src/libs/trpc/middleware/userAuth.ts +4 -2
  98. package/src/locales/default/auth.ts +114 -1
  99. package/src/proxy.ts +71 -7
  100. package/src/server/globalConfig/index.ts +12 -1
  101. package/src/server/routers/lambda/user.ts +4 -0
  102. package/src/server/services/email/README.md +241 -0
  103. package/src/server/services/email/impls/index.test.ts +39 -0
  104. package/src/server/services/email/impls/index.ts +32 -0
  105. package/src/server/services/email/impls/nodemailer/index.ts +108 -0
  106. package/src/server/services/email/impls/nodemailer/type.ts +31 -0
  107. package/src/server/services/email/impls/type.ts +61 -0
  108. package/src/server/services/email/index.test.ts +144 -0
  109. package/src/server/services/email/index.ts +40 -0
  110. package/src/services/user/index.test.ts +162 -2
  111. package/src/services/user/index.ts +6 -3
  112. package/src/store/user/slices/auth/action.test.ts +213 -16
  113. package/src/store/user/slices/auth/action.ts +86 -1
  114. package/src/store/user/slices/auth/initialState.ts +13 -2
  115. package/src/store/user/slices/auth/selectors.ts +6 -2
  116. package/src/store/user/slices/common/action.ts +5 -1
  117. package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
@@ -1,8 +1,168 @@
1
- import { describe } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { testService } from '~test-utils';
3
3
 
4
- import { UserService } from './index';
4
+ import { UserService, userService } from './index';
5
+
6
+ const mockLambdaClient = vi.hoisted(() => ({
7
+ user: {
8
+ getUserRegistrationDuration: { query: vi.fn() },
9
+ getUserState: { query: vi.fn() },
10
+ getUserSSOProviders: { query: vi.fn() },
11
+ unlinkSSOProvider: { mutate: vi.fn() },
12
+ makeUserOnboarded: { mutate: vi.fn() },
13
+ updateAvatar: { mutate: vi.fn() },
14
+ updateFullName: { mutate: vi.fn() },
15
+ updatePreference: { mutate: vi.fn() },
16
+ updateGuide: { mutate: vi.fn() },
17
+ updateSettings: { mutate: vi.fn() },
18
+ resetSettings: { mutate: vi.fn() },
19
+ },
20
+ }));
21
+
22
+ vi.mock('@/libs/trpc/client', () => ({
23
+ lambdaClient: mockLambdaClient,
24
+ }));
5
25
 
6
26
  describe('UserService', () => {
7
27
  testService(UserService);
28
+
29
+ describe('getUserRegistrationDuration', () => {
30
+ it('should call lambdaClient.user.getUserRegistrationDuration.query', async () => {
31
+ const mockResult = { createdAt: '2024-01-01', duration: 100, updatedAt: '2024-01-02' };
32
+ mockLambdaClient.user.getUserRegistrationDuration.query.mockResolvedValueOnce(mockResult);
33
+
34
+ const result = await userService.getUserRegistrationDuration();
35
+
36
+ expect(mockLambdaClient.user.getUserRegistrationDuration.query).toHaveBeenCalled();
37
+ expect(result).toEqual(mockResult);
38
+ });
39
+ });
40
+
41
+ describe('getUserState', () => {
42
+ it('should call lambdaClient.user.getUserState.query', async () => {
43
+ const mockState = { isOnboarded: true, preference: {}, settings: {} };
44
+ mockLambdaClient.user.getUserState.query.mockResolvedValueOnce(mockState);
45
+
46
+ const result = await userService.getUserState();
47
+
48
+ expect(mockLambdaClient.user.getUserState.query).toHaveBeenCalled();
49
+ expect(result).toEqual(mockState);
50
+ });
51
+ });
52
+
53
+ describe('getUserSSOProviders', () => {
54
+ it('should call lambdaClient.user.getUserSSOProviders.query', async () => {
55
+ const mockProviders = [
56
+ { provider: 'github', email: 'test@example.com', providerAccountId: '123' },
57
+ ];
58
+ mockLambdaClient.user.getUserSSOProviders.query.mockResolvedValueOnce(mockProviders);
59
+
60
+ const result = await userService.getUserSSOProviders();
61
+
62
+ expect(mockLambdaClient.user.getUserSSOProviders.query).toHaveBeenCalled();
63
+ expect(result).toEqual(mockProviders);
64
+ });
65
+ });
66
+
67
+ describe('unlinkSSOProvider', () => {
68
+ it('should call lambdaClient.user.unlinkSSOProvider.mutate with correct params', async () => {
69
+ mockLambdaClient.user.unlinkSSOProvider.mutate.mockResolvedValueOnce({ success: true });
70
+
71
+ await userService.unlinkSSOProvider('github', 'account-123');
72
+
73
+ expect(mockLambdaClient.user.unlinkSSOProvider.mutate).toHaveBeenCalledWith({
74
+ provider: 'github',
75
+ providerAccountId: 'account-123',
76
+ });
77
+ });
78
+ });
79
+
80
+ describe('makeUserOnboarded', () => {
81
+ it('should call lambdaClient.user.makeUserOnboarded.mutate', async () => {
82
+ mockLambdaClient.user.makeUserOnboarded.mutate.mockResolvedValueOnce({ success: true });
83
+
84
+ await userService.makeUserOnboarded();
85
+
86
+ expect(mockLambdaClient.user.makeUserOnboarded.mutate).toHaveBeenCalled();
87
+ });
88
+ });
89
+
90
+ describe('updateAvatar', () => {
91
+ it('should call lambdaClient.user.updateAvatar.mutate with avatar string', async () => {
92
+ mockLambdaClient.user.updateAvatar.mutate.mockResolvedValueOnce({ success: true });
93
+
94
+ await userService.updateAvatar('https://example.com/avatar.png');
95
+
96
+ expect(mockLambdaClient.user.updateAvatar.mutate).toHaveBeenCalledWith(
97
+ 'https://example.com/avatar.png',
98
+ );
99
+ });
100
+ });
101
+
102
+ describe('updateFullName', () => {
103
+ it('should call lambdaClient.user.updateFullName.mutate with fullName string', async () => {
104
+ mockLambdaClient.user.updateFullName.mutate.mockResolvedValueOnce({ success: true });
105
+
106
+ await userService.updateFullName('John Doe');
107
+
108
+ expect(mockLambdaClient.user.updateFullName.mutate).toHaveBeenCalledWith('John Doe');
109
+ });
110
+ });
111
+
112
+ describe('updatePreference', () => {
113
+ it('should call lambdaClient.user.updatePreference.mutate with preference object', async () => {
114
+ const preference = { hideSyncAlert: true };
115
+ mockLambdaClient.user.updatePreference.mutate.mockResolvedValueOnce({ success: true });
116
+
117
+ await userService.updatePreference(preference);
118
+
119
+ expect(mockLambdaClient.user.updatePreference.mutate).toHaveBeenCalledWith(preference);
120
+ });
121
+ });
122
+
123
+ describe('updateGuide', () => {
124
+ it('should call lambdaClient.user.updateGuide.mutate with guide object', async () => {
125
+ const guide = { moveSettingsToAvatar: true };
126
+ mockLambdaClient.user.updateGuide.mutate.mockResolvedValueOnce({ success: true });
127
+
128
+ await userService.updateGuide(guide);
129
+
130
+ expect(mockLambdaClient.user.updateGuide.mutate).toHaveBeenCalledWith(guide);
131
+ });
132
+ });
133
+
134
+ describe('updateUserSettings', () => {
135
+ it('should call lambdaClient.user.updateSettings.mutate with settings', async () => {
136
+ const settings = { general: { fontSize: 14 } };
137
+ mockLambdaClient.user.updateSettings.mutate.mockResolvedValueOnce({ success: true });
138
+
139
+ await userService.updateUserSettings(settings);
140
+
141
+ expect(mockLambdaClient.user.updateSettings.mutate).toHaveBeenCalledWith(settings, {
142
+ signal: undefined,
143
+ });
144
+ });
145
+
146
+ it('should pass abort signal when provided', async () => {
147
+ const settings = { general: { fontSize: 16 } };
148
+ const abortController = new AbortController();
149
+ mockLambdaClient.user.updateSettings.mutate.mockResolvedValueOnce({ success: true });
150
+
151
+ await userService.updateUserSettings(settings, abortController.signal);
152
+
153
+ expect(mockLambdaClient.user.updateSettings.mutate).toHaveBeenCalledWith(settings, {
154
+ signal: abortController.signal,
155
+ });
156
+ });
157
+ });
158
+
159
+ describe('resetUserSettings', () => {
160
+ it('should call lambdaClient.user.resetSettings.mutate', async () => {
161
+ mockLambdaClient.user.resetSettings.mutate.mockResolvedValueOnce({ success: true });
162
+
163
+ await userService.resetUserSettings();
164
+
165
+ expect(mockLambdaClient.user.resetSettings.mutate).toHaveBeenCalled();
166
+ });
167
+ });
8
168
  });
@@ -1,8 +1,7 @@
1
- import type { AdapterAccount } from 'next-auth/adapters';
2
1
  import type { PartialDeep } from 'type-fest';
3
2
 
4
3
  import { lambdaClient } from '@/libs/trpc/client';
5
- import { UserGuide, UserInitializationState, UserPreference } from '@/types/user';
4
+ import { SSOProvider, UserGuide, UserInitializationState, UserPreference } from '@/types/user';
6
5
  import { UserSettings } from '@/types/user/settings';
7
6
 
8
7
  export class UserService {
@@ -18,7 +17,7 @@ export class UserService {
18
17
  return lambdaClient.user.getUserState.query();
19
18
  };
20
19
 
21
- getUserSSOProviders = async (): Promise<AdapterAccount[]> => {
20
+ getUserSSOProviders = async (): Promise<SSOProvider[]> => {
22
21
  return lambdaClient.user.getUserSSOProviders.query();
23
22
  };
24
23
 
@@ -34,6 +33,10 @@ export class UserService {
34
33
  return lambdaClient.user.updateAvatar.mutate(avatar);
35
34
  };
36
35
 
36
+ updateFullName = async (fullName: string) => {
37
+ return lambdaClient.user.updateFullName.mutate(fullName);
38
+ };
39
+
37
40
  updatePreference = async (preference: Partial<UserPreference>) => {
38
41
  return lambdaClient.user.updatePreference.mutate(preference);
39
42
  };
@@ -14,26 +14,60 @@ vi.mock('swr', async (importOriginal) => {
14
14
  };
15
15
  });
16
16
 
17
- // 定义一个变量来存储 enableAuth 的值
18
- let enableClerk = false;
19
-
20
- let enableNextAuth = false;
17
+ // Use vi.hoisted to ensure variables exist before vi.mock factory executes
18
+ const { enableClerk, enableNextAuth, enableBetterAuth, enableAuth } = vi.hoisted(() => ({
19
+ enableClerk: { value: false },
20
+ enableNextAuth: { value: false },
21
+ enableBetterAuth: { value: false },
22
+ enableAuth: { value: true },
23
+ }));
21
24
 
22
- // 模拟 @/const/auth 模块
23
25
  vi.mock('@/const/auth', () => ({
24
26
  get enableClerk() {
25
- return enableClerk;
27
+ return enableClerk.value;
26
28
  },
27
29
  get enableNextAuth() {
28
- return enableNextAuth;
30
+ return enableNextAuth.value;
31
+ },
32
+ get enableBetterAuth() {
33
+ return enableBetterAuth.value;
34
+ },
35
+ get enableAuth() {
36
+ return enableAuth.value;
29
37
  },
30
38
  }));
31
39
 
40
+ const mockUserService = vi.hoisted(() => ({
41
+ getUserSSOProviders: vi.fn().mockResolvedValue([]),
42
+ }));
43
+
44
+ vi.mock('@/services/user', () => ({
45
+ userService: mockUserService,
46
+ }));
47
+
48
+ const mockBetterAuthClient = vi.hoisted(() => ({
49
+ listAccounts: vi.fn().mockResolvedValue({ data: [] }),
50
+ accountInfo: vi.fn().mockResolvedValue({ data: { user: {} } }),
51
+ signOut: vi.fn().mockResolvedValue({}),
52
+ }));
53
+
54
+ vi.mock('@/libs/better-auth/auth-client', () => mockBetterAuthClient);
55
+
32
56
  afterEach(() => {
33
57
  vi.restoreAllMocks();
34
-
35
- enableNextAuth = false;
36
- enableClerk = false;
58
+ vi.clearAllMocks();
59
+
60
+ enableNextAuth.value = false;
61
+ enableClerk.value = false;
62
+ enableBetterAuth.value = false;
63
+ enableAuth.value = true;
64
+
65
+ // Reset store state
66
+ useUserStore.setState({
67
+ isLoadedAuthProviders: false,
68
+ authProviders: [],
69
+ isEmailPasswordAuth: false,
70
+ });
37
71
  });
38
72
 
39
73
  /**
@@ -61,7 +95,7 @@ describe('createAuthSlice', () => {
61
95
 
62
96
  describe('logout', () => {
63
97
  it('should call clerkSignOut when Clerk is enabled', async () => {
64
- enableClerk = true;
98
+ enableClerk.value = true;
65
99
 
66
100
  const clerkSignOutMock = vi.fn();
67
101
  useUserStore.setState({ clerkSignOut: clerkSignOutMock });
@@ -89,7 +123,7 @@ describe('createAuthSlice', () => {
89
123
  });
90
124
 
91
125
  it('should call next-auth signOut when NextAuth is enabled', async () => {
92
- enableNextAuth = true;
126
+ enableNextAuth.value = true;
93
127
 
94
128
  const { result } = renderHook(() => useUserStore());
95
129
 
@@ -100,7 +134,7 @@ describe('createAuthSlice', () => {
100
134
  const { signOut } = await import('next-auth/react');
101
135
 
102
136
  expect(signOut).toHaveBeenCalled();
103
- enableNextAuth = false;
137
+ enableNextAuth.value = false;
104
138
  });
105
139
 
106
140
  it('should not call next-auth signOut when NextAuth is disabled', async () => {
@@ -118,7 +152,7 @@ describe('createAuthSlice', () => {
118
152
 
119
153
  describe('openLogin', () => {
120
154
  it('should call clerkSignIn when Clerk is enabled', async () => {
121
- enableClerk = true;
155
+ enableClerk.value = true;
122
156
  const clerkSignInMock = vi.fn();
123
157
  useUserStore.setState({ clerkSignIn: clerkSignInMock });
124
158
 
@@ -144,7 +178,7 @@ describe('createAuthSlice', () => {
144
178
  });
145
179
 
146
180
  it('should call next-auth signIn when NextAuth is enabled', async () => {
147
- enableNextAuth = true;
181
+ enableNextAuth.value = true;
148
182
 
149
183
  const { result } = renderHook(() => useUserStore());
150
184
 
@@ -155,7 +189,7 @@ describe('createAuthSlice', () => {
155
189
  const { signIn } = await import('next-auth/react');
156
190
 
157
191
  expect(signIn).toHaveBeenCalled();
158
- enableNextAuth = false;
192
+ enableNextAuth.value = false;
159
193
  });
160
194
  it('should not call next-auth signIn when NextAuth is disabled', async () => {
161
195
  const { result } = renderHook(() => useUserStore());
@@ -168,5 +202,168 @@ describe('createAuthSlice', () => {
168
202
 
169
203
  expect(signIn).not.toHaveBeenCalled();
170
204
  });
205
+
206
+ it('should redirect to signin page when BetterAuth is enabled', async () => {
207
+ enableBetterAuth.value = true;
208
+
209
+ const originalLocation = window.location;
210
+ Object.defineProperty(window, 'location', {
211
+ configurable: true,
212
+ value: { ...originalLocation, href: '', toString: () => 'http://localhost/chat' },
213
+ writable: true,
214
+ });
215
+
216
+ const { result } = renderHook(() => useUserStore());
217
+
218
+ await act(async () => {
219
+ await result.current.openLogin();
220
+ });
221
+
222
+ expect(window.location.href).toContain('/signin');
223
+ expect(window.location.href).toContain('callbackUrl');
224
+
225
+ Object.defineProperty(window, 'location', {
226
+ configurable: true,
227
+ value: originalLocation,
228
+ writable: true,
229
+ });
230
+ });
231
+
232
+ it('should call signIn with single provider when only one OAuth provider available', async () => {
233
+ enableNextAuth.value = true;
234
+ useUserStore.setState({ oAuthSSOProviders: ['github'] });
235
+
236
+ const { result } = renderHook(() => useUserStore());
237
+
238
+ await act(async () => {
239
+ await result.current.openLogin();
240
+ });
241
+
242
+ const { signIn } = await import('next-auth/react');
243
+
244
+ expect(signIn).toHaveBeenCalledWith('github');
245
+ });
246
+ });
247
+
248
+ describe('enableAuth', () => {
249
+ it('should return true when auth is enabled', () => {
250
+ enableAuth.value = true;
251
+ const { result } = renderHook(() => useUserStore());
252
+
253
+ expect(result.current.enableAuth()).toBe(true);
254
+ });
255
+
256
+ it('should return false when auth is disabled', () => {
257
+ enableAuth.value = false;
258
+ const { result } = renderHook(() => useUserStore());
259
+
260
+ expect(result.current.enableAuth()).toBe(false);
261
+ });
262
+ });
263
+
264
+ describe('fetchAuthProviders', () => {
265
+ it('should skip fetching if already loaded', async () => {
266
+ useUserStore.setState({ isLoadedAuthProviders: true });
267
+
268
+ const { result } = renderHook(() => useUserStore());
269
+
270
+ await act(async () => {
271
+ await result.current.fetchAuthProviders();
272
+ });
273
+
274
+ expect(mockUserService.getUserSSOProviders).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it('should fetch providers from NextAuth when BetterAuth is disabled', async () => {
278
+ enableBetterAuth.value = false;
279
+ const mockProviders = [
280
+ { provider: 'github', email: 'test@example.com', providerAccountId: '123' },
281
+ ];
282
+ mockUserService.getUserSSOProviders.mockResolvedValueOnce(mockProviders);
283
+
284
+ const { result } = renderHook(() => useUserStore());
285
+
286
+ await act(async () => {
287
+ await result.current.fetchAuthProviders();
288
+ });
289
+
290
+ expect(mockUserService.getUserSSOProviders).toHaveBeenCalled();
291
+ expect(result.current.isLoadedAuthProviders).toBe(true);
292
+ expect(result.current.authProviders).toEqual(mockProviders);
293
+ });
294
+
295
+ it('should fetch providers from BetterAuth when enabled', async () => {
296
+ enableBetterAuth.value = true;
297
+ mockBetterAuthClient.listAccounts.mockResolvedValueOnce({
298
+ data: [
299
+ { providerId: 'github', accountId: 'gh-123' },
300
+ { providerId: 'credential', accountId: 'cred-1' },
301
+ ],
302
+ });
303
+ mockBetterAuthClient.accountInfo.mockResolvedValueOnce({
304
+ data: { user: { email: 'test@github.com' } },
305
+ });
306
+
307
+ const { result } = renderHook(() => useUserStore());
308
+
309
+ await act(async () => {
310
+ await result.current.fetchAuthProviders();
311
+ });
312
+
313
+ expect(mockBetterAuthClient.listAccounts).toHaveBeenCalled();
314
+ expect(result.current.isLoadedAuthProviders).toBe(true);
315
+ expect(result.current.isEmailPasswordAuth).toBe(true);
316
+ });
317
+
318
+ it('should handle fetch error gracefully', async () => {
319
+ enableBetterAuth.value = false;
320
+ mockUserService.getUserSSOProviders.mockRejectedValueOnce(new Error('Network error'));
321
+
322
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
323
+
324
+ const { result } = renderHook(() => useUserStore());
325
+
326
+ await act(async () => {
327
+ await result.current.fetchAuthProviders();
328
+ });
329
+
330
+ expect(result.current.isLoadedAuthProviders).toBe(true);
331
+ consoleSpy.mockRestore();
332
+ });
333
+ });
334
+
335
+ describe('refreshAuthProviders', () => {
336
+ it('should refresh providers from NextAuth', async () => {
337
+ enableBetterAuth.value = false;
338
+ const mockProviders = [
339
+ { provider: 'google', email: 'user@gmail.com', providerAccountId: 'g-1' },
340
+ ];
341
+ mockUserService.getUserSSOProviders.mockResolvedValueOnce(mockProviders);
342
+
343
+ const { result } = renderHook(() => useUserStore());
344
+
345
+ await act(async () => {
346
+ await result.current.refreshAuthProviders();
347
+ });
348
+
349
+ expect(mockUserService.getUserSSOProviders).toHaveBeenCalled();
350
+ expect(result.current.authProviders).toEqual(mockProviders);
351
+ });
352
+
353
+ it('should handle refresh error gracefully', async () => {
354
+ enableBetterAuth.value = false;
355
+ mockUserService.getUserSSOProviders.mockRejectedValueOnce(new Error('Refresh failed'));
356
+
357
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
358
+
359
+ const { result } = renderHook(() => useUserStore());
360
+
361
+ await act(async () => {
362
+ await result.current.refreshAuthProviders();
363
+ });
364
+
365
+ // Should not throw
366
+ consoleSpy.mockRestore();
367
+ });
171
368
  });
172
369
  });
@@ -1,11 +1,22 @@
1
+ import { SSOProvider } from '@lobechat/types';
1
2
  import { StateCreator } from 'zustand/vanilla';
2
3
 
3
- import { enableAuth, enableClerk, enableNextAuth } from '@/const/auth';
4
+ import { enableAuth, enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
5
+ import { userService } from '@/services/user';
4
6
 
5
7
  import type { UserStore } from '../../store';
6
8
 
9
+ interface AuthProvidersData {
10
+ isEmailPasswordAuth: boolean;
11
+ providers: SSOProvider[];
12
+ }
13
+
7
14
  export interface UserAuthAction {
8
15
  enableAuth: () => boolean;
16
+ /**
17
+ * Fetch auth providers (SSO accounts) for the current user
18
+ */
19
+ fetchAuthProviders: () => Promise<void>;
9
20
  /**
10
21
  * universal logout method
11
22
  */
@@ -14,8 +25,40 @@ export interface UserAuthAction {
14
25
  * universal login method
15
26
  */
16
27
  openLogin: () => Promise<void>;
28
+ /**
29
+ * Refresh auth providers after link/unlink
30
+ */
31
+ refreshAuthProviders: () => Promise<void>;
17
32
  }
18
33
 
34
+ const fetchAuthProvidersData = async (): Promise<AuthProvidersData> => {
35
+ if (enableBetterAuth) {
36
+ const { accountInfo, listAccounts } = await import('@/libs/better-auth/auth-client');
37
+ const result = await listAccounts();
38
+ const accounts = result.data || [];
39
+ const isEmailPasswordAuth = accounts.some((account) => account.providerId === 'credential');
40
+ const providers = await Promise.all(
41
+ accounts
42
+ .filter((account) => account.providerId !== 'credential')
43
+ .map(async (account) => {
44
+ const info = await accountInfo({
45
+ query: { accountId: account.accountId },
46
+ });
47
+ return {
48
+ email: info.data?.user?.email ?? undefined,
49
+ provider: account.providerId,
50
+ providerAccountId: account.accountId,
51
+ };
52
+ }),
53
+ );
54
+ return { isEmailPasswordAuth, providers };
55
+ }
56
+
57
+ // Fallback for NextAuth
58
+ const providers = await userService.getUserSSOProviders();
59
+ return { isEmailPasswordAuth: false, providers };
60
+ };
61
+
19
62
  export const createAuthSlice: StateCreator<
20
63
  UserStore,
21
64
  [['zustand/devtools', never]],
@@ -25,6 +68,18 @@ export const createAuthSlice: StateCreator<
25
68
  enableAuth: () => {
26
69
  return enableAuth;
27
70
  },
71
+ fetchAuthProviders: async () => {
72
+ // Skip if already loaded
73
+ if (get().isLoadedAuthProviders) return;
74
+
75
+ try {
76
+ const { isEmailPasswordAuth, providers } = await fetchAuthProvidersData();
77
+ set({ authProviders: providers, isEmailPasswordAuth, isLoadedAuthProviders: true });
78
+ } catch (error) {
79
+ console.error('Failed to fetch auth providers:', error);
80
+ set({ isLoadedAuthProviders: true });
81
+ }
82
+ },
28
83
  logout: async () => {
29
84
  if (enableClerk) {
30
85
  get().clerkSignOut?.({ redirectUrl: location.toString() });
@@ -32,6 +87,21 @@ export const createAuthSlice: StateCreator<
32
87
  return;
33
88
  }
34
89
 
90
+ if (enableBetterAuth) {
91
+ const { signOut } = await import('@/libs/better-auth/auth-client');
92
+ await signOut({
93
+ fetchOptions: {
94
+ onSuccess: () => {
95
+ // Use window.location.href to trigger a full page reload
96
+ // This ensures all client-side state (React, Zustand, cache) is cleared
97
+ window.location.href = '/signin';
98
+ },
99
+ },
100
+ });
101
+
102
+ return;
103
+ }
104
+
35
105
  if (enableNextAuth) {
36
106
  const { signOut } = await import('next-auth/react');
37
107
  signOut();
@@ -49,6 +119,13 @@ export const createAuthSlice: StateCreator<
49
119
  return;
50
120
  }
51
121
 
122
+ if (enableBetterAuth) {
123
+ const currentUrl = location.toString();
124
+ window.location.href = `/signin?callbackUrl=${encodeURIComponent(currentUrl)}`;
125
+
126
+ return;
127
+ }
128
+
52
129
  if (enableNextAuth) {
53
130
  const { signIn } = await import('next-auth/react');
54
131
  // Check if only one provider is available
@@ -60,4 +137,12 @@ export const createAuthSlice: StateCreator<
60
137
  signIn();
61
138
  }
62
139
  },
140
+ refreshAuthProviders: async () => {
141
+ try {
142
+ const { isEmailPasswordAuth, providers } = await fetchAuthProvidersData();
143
+ set({ authProviders: providers, isEmailPasswordAuth });
144
+ } catch (error) {
145
+ console.error('Failed to refresh auth providers:', error);
146
+ }
147
+ },
63
148
  });
@@ -6,17 +6,25 @@ import {
6
6
  UserProfileProps,
7
7
  UserResource,
8
8
  } from '@clerk/types';
9
+ import { SSOProvider } from '@lobechat/types';
9
10
 
11
+ import { enableClerk } from '@/const/auth';
10
12
  import { LobeUser } from '@/types/user';
11
13
 
12
14
  export interface UserAuthState {
15
+ authProviders?: SSOProvider[];
13
16
  clerkOpenUserProfile?: (props?: UserProfileProps) => void;
14
-
15
17
  clerkSession?: SignedInSessionResource;
18
+
16
19
  clerkSignIn?: (props?: SignInProps) => void;
17
20
  clerkSignOut?: SignOut;
18
21
  clerkUser?: UserResource;
22
+ /**
23
+ * Whether user registered with email/password (credential login)
24
+ */
25
+ isEmailPasswordAuth?: boolean;
19
26
  isLoaded?: boolean;
27
+ isLoadedAuthProviders?: boolean;
20
28
 
21
29
  isSignedIn?: boolean;
22
30
  nextSession?: Session;
@@ -25,4 +33,7 @@ export interface UserAuthState {
25
33
  user?: LobeUser;
26
34
  }
27
35
 
28
- export const initialAuthState: UserAuthState = {};
36
+ export const initialAuthState: UserAuthState = {
37
+ // Clerk doesn't need to fetch auth providers
38
+ isLoadedAuthProviders: enableClerk ? true : undefined,
39
+ };
@@ -1,8 +1,8 @@
1
1
  import { BRANDING_NAME, isDesktop } from '@lobechat/const';
2
- import { LobeUser } from '@lobechat/types';
2
+ import { LobeUser, SSOProvider } from '@lobechat/types';
3
3
  import { t } from 'i18next';
4
4
 
5
- import { enableAuth, enableClerk, enableNextAuth } from '@/const/auth';
5
+ import { enableAuth, enableBetterAuth, enableClerk, enableNextAuth } from '@/const/auth';
6
6
  import type { UserStore } from '@/store/user';
7
7
 
8
8
  const DEFAULT_USERNAME = BRANDING_NAME;
@@ -56,9 +56,13 @@ const isLogin = (s: UserStore) => {
56
56
  };
57
57
 
58
58
  export const authSelectors = {
59
+ authProviders: (s: UserStore): SSOProvider[] => s.authProviders || [],
60
+ isEmailPasswordAuth: (s: UserStore) => s.isEmailPasswordAuth ?? false,
59
61
  isLoaded: (s: UserStore) => s.isLoaded,
62
+ isLoadedAuthProviders: (s: UserStore) => s.isLoadedAuthProviders ?? false,
60
63
  isLogin,
61
64
  isLoginWithAuth: (s: UserStore) => s.isSignedIn,
65
+ isLoginWithBetterAuth: (s: UserStore): boolean => (s.isSignedIn && enableBetterAuth) || false,
62
66
  isLoginWithClerk: (s: UserStore): boolean => (s.isSignedIn && enableClerk) || false,
63
67
  isLoginWithNextAuth: (s: UserStore): boolean => (s.isSignedIn && !!enableNextAuth) || false,
64
68
  };