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

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 +25 -0
  8. package/Dockerfile +6 -6
  9. package/GEMINI.md +63 -0
  10. package/changelog/v1.json +9 -0
  11. package/docs/development/database-schema.dbml +37 -0
  12. package/docs/self-hosting/advanced/auth.mdx +75 -2
  13. package/docs/self-hosting/advanced/auth.zh-CN.mdx +75 -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,35 +1,280 @@
1
1
  'use client';
2
2
 
3
- import { Form, type FormGroupItemType, Input } from '@lobehub/ui';
4
- import { Skeleton } from 'antd';
5
- import { memo } from 'react';
3
+ import { LoadingOutlined } from '@ant-design/icons';
4
+ import { Button, Divider, Input, Skeleton, Spin, Typography, Upload } from 'antd';
5
+ import { AnimatePresence, motion } from 'framer-motion';
6
+ import { CSSProperties, ReactNode, memo, useCallback, useEffect, useState } from 'react';
6
7
  import { useTranslation } from 'react-i18next';
8
+ import { Flexbox } from 'react-layout-kit';
7
9
 
10
+ import { notification } from '@/components/AntdStaticMethods';
11
+ import { fetchErrorNotification } from '@/components/Error/fetchErrorNotification';
8
12
  import { enableAuth } from '@/const/auth';
9
- import { FORM_STYLE } from '@/const/layoutTokens';
10
- import AvatarWithUpload from '@/features/AvatarWithUpload';
11
13
  import UserAvatar from '@/features/User/UserAvatar';
14
+ import { requestPasswordReset } from '@/libs/better-auth/auth-client';
12
15
  import { useUserStore } from '@/store/user';
13
16
  import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
17
+ import { imageToBase64 } from '@/utils/imageToBase64';
18
+ import { createUploadImageHandler } from '@/utils/uploadFIle';
14
19
 
15
20
  import SSOProvidersList from './features/SSOProvidersList';
16
21
 
22
+ interface ProfileRowProps {
23
+ action?: ReactNode;
24
+ children: ReactNode;
25
+ label: string;
26
+ }
27
+
28
+ const rowStyle: CSSProperties = {
29
+ minHeight: 48,
30
+ padding: '16px 0',
31
+ };
32
+
33
+ const labelStyle: CSSProperties = {
34
+ flexShrink: 0,
35
+ width: 160,
36
+ };
37
+
38
+ const ProfileRow = memo<ProfileRowProps>(({ label, children, action }) => (
39
+ <Flexbox align="center" gap={24} horizontal justify="space-between" style={rowStyle}>
40
+ <Flexbox align="center" gap={24} horizontal style={{ flex: 1 }}>
41
+ <Typography.Text style={labelStyle}>{label}</Typography.Text>
42
+ <Flexbox style={{ flex: 1 }}>{children}</Flexbox>
43
+ </Flexbox>
44
+ {action && <Flexbox>{action}</Flexbox>}
45
+ </Flexbox>
46
+ ));
47
+
48
+ const AvatarRow = memo(() => {
49
+ const { t } = useTranslation('auth');
50
+ const isLogin = useUserStore(authSelectors.isLogin);
51
+ const updateAvatar = useUserStore((s) => s.updateAvatar);
52
+ const [uploading, setUploading] = useState(false);
53
+
54
+ const handleUploadAvatar = useCallback(
55
+ createUploadImageHandler(async (avatar) => {
56
+ try {
57
+ setUploading(true);
58
+ const img = new Image();
59
+ img.src = avatar;
60
+
61
+ await new Promise((resolve, reject) => {
62
+ img.addEventListener('load', resolve);
63
+ img.addEventListener('error', reject);
64
+ });
65
+
66
+ const webpBase64 = imageToBase64({ img, size: 256 });
67
+ await updateAvatar(webpBase64);
68
+ setUploading(false);
69
+ } catch (error) {
70
+ console.error('Failed to upload avatar:', error);
71
+ setUploading(false);
72
+
73
+ fetchErrorNotification.error({
74
+ errorMessage: error instanceof Error ? error.message : String(error),
75
+ status: 500,
76
+ });
77
+ }
78
+ }),
79
+ [updateAvatar],
80
+ );
81
+
82
+ const canUpload = !enableAuth || isLogin;
83
+
84
+ return (
85
+ <Flexbox align="center" gap={24} horizontal justify="space-between" style={rowStyle}>
86
+ <Flexbox align="center" gap={24} horizontal style={{ flex: 1 }}>
87
+ <Typography.Text style={labelStyle}>{t('profile.avatar')}</Typography.Text>
88
+ <Flexbox style={{ flex: 1 }}>
89
+ {canUpload ? (
90
+ <Spin indicator={<LoadingOutlined spin />} spinning={uploading}>
91
+ <Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
92
+ <UserAvatar clickable size={40} />
93
+ </Upload>
94
+ </Spin>
95
+ ) : (
96
+ <UserAvatar size={40} />
97
+ )}
98
+ </Flexbox>
99
+ </Flexbox>
100
+ {canUpload && (
101
+ <Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
102
+ <Typography.Text style={{ cursor: 'pointer', fontSize: 13 }}>
103
+ {t('profile.updateAvatar')}
104
+ </Typography.Text>
105
+ </Upload>
106
+ )}
107
+ </Flexbox>
108
+ );
109
+ });
110
+
111
+ const FullNameRow = memo(() => {
112
+ const { t } = useTranslation('auth');
113
+ const fullName = useUserStore(userProfileSelectors.fullName);
114
+ const updateFullName = useUserStore((s) => s.updateFullName);
115
+ const [isEditing, setIsEditing] = useState(false);
116
+ const [editValue, setEditValue] = useState('');
117
+ const [saving, setSaving] = useState(false);
118
+
119
+ const handleStartEdit = () => {
120
+ setEditValue(fullName || '');
121
+ setIsEditing(true);
122
+ };
123
+
124
+ const handleCancel = () => {
125
+ setIsEditing(false);
126
+ setEditValue('');
127
+ };
128
+
129
+ const handleSave = useCallback(async () => {
130
+ if (!editValue.trim()) return;
131
+
132
+ try {
133
+ setSaving(true);
134
+ await updateFullName(editValue.trim());
135
+ setIsEditing(false);
136
+ } catch (error) {
137
+ console.error('Failed to update fullName:', error);
138
+ fetchErrorNotification.error({
139
+ errorMessage: error instanceof Error ? error.message : String(error),
140
+ status: 500,
141
+ });
142
+ } finally {
143
+ setSaving(false);
144
+ }
145
+ }, [editValue, updateFullName]);
146
+
147
+ return (
148
+ <Flexbox gap={24} horizontal style={rowStyle}>
149
+ <Typography.Text style={labelStyle}>{t('profile.fullName')}</Typography.Text>
150
+ <Flexbox style={{ flex: 1 }}>
151
+ <AnimatePresence mode="wait">
152
+ {isEditing ? (
153
+ <motion.div
154
+ animate={{ opacity: 1, y: 0 }}
155
+ exit={{ opacity: 0, y: -10 }}
156
+ initial={{ opacity: 0, y: -10 }}
157
+ key="editing"
158
+ transition={{ duration: 0.2 }}
159
+ >
160
+ <Flexbox gap={12}>
161
+ <Typography.Text strong>{t('profile.fullNameInputHint')}</Typography.Text>
162
+ <Input
163
+ autoFocus
164
+ onChange={(e) => setEditValue(e.target.value)}
165
+ onPressEnter={handleSave}
166
+ placeholder={t('profile.fullName')}
167
+ value={editValue}
168
+ />
169
+ <Flexbox gap={8} horizontal justify="flex-end">
170
+ <Button disabled={saving} onClick={handleCancel} size="small">
171
+ {t('profile.cancel')}
172
+ </Button>
173
+ <Button loading={saving} onClick={handleSave} size="small" type="primary">
174
+ {t('profile.save')}
175
+ </Button>
176
+ </Flexbox>
177
+ </Flexbox>
178
+ </motion.div>
179
+ ) : (
180
+ <motion.div
181
+ animate={{ opacity: 1 }}
182
+ exit={{ opacity: 0 }}
183
+ initial={{ opacity: 0 }}
184
+ key="display"
185
+ transition={{ duration: 0.2 }}
186
+ >
187
+ <Flexbox align="center" horizontal justify="space-between">
188
+ <Typography.Text>{fullName || '--'}</Typography.Text>
189
+ <Typography.Text
190
+ onClick={handleStartEdit}
191
+ style={{ cursor: 'pointer', fontSize: 13 }}
192
+ >
193
+ {t('profile.updateFullName')}
194
+ </Typography.Text>
195
+ </Flexbox>
196
+ </motion.div>
197
+ )}
198
+ </AnimatePresence>
199
+ </Flexbox>
200
+ </Flexbox>
201
+ );
202
+ });
203
+
204
+ const PasswordRow = memo(() => {
205
+ const { t } = useTranslation('auth');
206
+ const userProfile = useUserStore(userProfileSelectors.userProfile);
207
+ const [sending, setSending] = useState(false);
208
+
209
+ const handleChangePassword = useCallback(async () => {
210
+ if (!userProfile?.email) return;
211
+
212
+ try {
213
+ setSending(true);
214
+ await requestPasswordReset({
215
+ email: userProfile.email,
216
+ redirectTo: `/reset-password?email=${encodeURIComponent(userProfile.email)}`,
217
+ });
218
+ notification.success({
219
+ message: t('profile.resetPasswordSent'),
220
+ });
221
+ } catch (error) {
222
+ console.error('Failed to send reset password email:', error);
223
+ notification.error({
224
+ message: t('profile.resetPasswordError'),
225
+ });
226
+ } finally {
227
+ setSending(false);
228
+ }
229
+ }, [userProfile?.email, t]);
230
+
231
+ return (
232
+ <ProfileRow
233
+ action={
234
+ <Typography.Text
235
+ onClick={sending ? undefined : handleChangePassword}
236
+ style={{
237
+ cursor: sending ? 'default' : 'pointer',
238
+ fontSize: 13,
239
+ opacity: sending ? 0.5 : 1,
240
+ }}
241
+ >
242
+ {t('profile.changePassword')}
243
+ </Typography.Text>
244
+ }
245
+ label={t('profile.password')}
246
+ >
247
+ <Typography.Text>••••••</Typography.Text>
248
+ </ProfileRow>
249
+ );
250
+ });
251
+
17
252
  const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
18
- const [isLoginWithNextAuth, isLogin] = useUserStore((s) => [
253
+ const [isLoginWithNextAuth, isLoginWithBetterAuth] = useUserStore((s) => [
19
254
  authSelectors.isLoginWithNextAuth(s),
20
- authSelectors.isLogin(s),
255
+ authSelectors.isLoginWithBetterAuth(s),
21
256
  ]);
22
- const [nickname, username, userProfile, loading] = useUserStore((s) => [
23
- userProfileSelectors.nickName(s),
257
+ const [username, userProfile, isUserLoaded] = useUserStore((s) => [
24
258
  userProfileSelectors.username(s),
25
259
  userProfileSelectors.userProfile(s),
26
- !s.isLoaded,
260
+ s.isLoaded,
27
261
  ]);
262
+ const isEmailPasswordAuth = useUserStore(authSelectors.isEmailPasswordAuth);
263
+ const isLoadedAuthProviders = useUserStore(authSelectors.isLoadedAuthProviders);
264
+ const fetchAuthProviders = useUserStore((s) => s.fetchAuthProviders);
265
+
266
+ const isLoginWithAuth = isLoginWithNextAuth || isLoginWithBetterAuth;
267
+ const isLoading = !isUserLoaded || (isLoginWithAuth && !isLoadedAuthProviders);
268
+
269
+ useEffect(() => {
270
+ if (isLoginWithAuth) {
271
+ fetchAuthProviders();
272
+ }
273
+ }, [isLoginWithAuth, fetchAuthProviders]);
28
274
 
29
- const [form] = Form.useForm();
30
275
  const { t } = useTranslation('auth');
31
276
 
32
- if (loading)
277
+ if (isLoading)
33
278
  return (
34
279
  <Skeleton
35
280
  active
@@ -39,47 +284,56 @@ const Client = memo<{ mobile?: boolean }>(({ mobile }) => {
39
284
  />
40
285
  );
41
286
 
42
- const profile: FormGroupItemType = {
43
- children: [
44
- {
45
- children: enableAuth && !isLogin ? <UserAvatar /> : <AvatarWithUpload />,
46
- label: t('profile.avatar'),
47
- layout: 'horizontal',
48
- minWidth: undefined,
49
- },
50
- {
51
- children: <Input disabled />,
52
- label: t('profile.username'),
53
- name: 'username',
54
- },
55
- {
56
- children: <Input disabled />,
57
- hidden: !isLoginWithNextAuth || !userProfile?.email,
58
- label: t('profile.email'),
59
- name: 'email',
60
- },
61
- {
62
- children: <SSOProvidersList />,
63
- hidden: !isLoginWithNextAuth,
64
- label: t('profile.sso.providers'),
65
- layout: 'vertical',
66
- minWidth: undefined,
67
- },
68
- ],
69
- title: t('tab.profile'),
70
- };
71
287
  return (
72
- <Form
73
- form={form}
74
- initialValues={{
75
- email: userProfile?.email || '--',
76
- username: nickname || username,
77
- }}
78
- items={[profile]}
79
- itemsType={'group'}
80
- variant={'borderless'}
81
- {...FORM_STYLE}
82
- />
288
+ <Flexbox gap={0} paddingInline={mobile ? 16 : 0}>
289
+ <Typography.Title level={4} style={{ marginBottom: 32 }}>
290
+ {t('profile.title')}
291
+ </Typography.Title>
292
+
293
+ <Divider style={{ marginBlock: 0 }} />
294
+
295
+ {/* Avatar Row - Editable */}
296
+ <AvatarRow />
297
+
298
+ <Divider style={{ margin: 0 }} />
299
+
300
+ {/* Full Name Row - Editable */}
301
+ <FullNameRow />
302
+
303
+ <Divider style={{ margin: 0 }} />
304
+
305
+ {/* Username Row - Read Only */}
306
+ <ProfileRow label={t('profile.username')}>
307
+ <Typography.Text>{username || '--'}</Typography.Text>
308
+ </ProfileRow>
309
+
310
+ <Divider style={{ margin: 0 }} />
311
+
312
+ {/* Password Row - Only for Better Auth users with credential login */}
313
+ {isLoginWithBetterAuth && isEmailPasswordAuth && (
314
+ <>
315
+ <PasswordRow />
316
+ <Divider style={{ margin: 0 }} />
317
+ </>
318
+ )}
319
+
320
+ {/* Email Row - Read Only */}
321
+ {isLoginWithAuth && userProfile?.email && (
322
+ <>
323
+ <ProfileRow label={t('profile.email')}>
324
+ <Typography.Text>{userProfile.email}</Typography.Text>
325
+ </ProfileRow>
326
+ <Divider style={{ margin: 0 }} />
327
+ </>
328
+ )}
329
+
330
+ {/* SSO Providers Row */}
331
+ {isLoginWithAuth && (
332
+ <ProfileRow label={t('profile.sso.providers')}>
333
+ <SSOProvidersList />
334
+ </ProfileRow>
335
+ )}
336
+ </Flexbox>
83
337
  );
84
338
  });
85
339
 
@@ -1,38 +1,48 @@
1
- import { ActionIcon, CopyButton, List } from '@lobehub/ui';
2
- import { RotateCw, Unlink } from 'lucide-react';
3
- import { CSSProperties, memo, useState } from 'react';
1
+ import { ActionIcon } from '@lobehub/ui';
2
+ import { Dropdown, type MenuProps, Typography } from 'antd';
3
+ import { ArrowRight, Plus, Unlink } from 'lucide-react';
4
+ import { CSSProperties, memo, useMemo } from 'react';
4
5
  import { useTranslation } from 'react-i18next';
5
6
  import { Flexbox } from 'react-layout-kit';
6
7
 
7
8
  import { modal, notification } from '@/components/AntdStaticMethods';
8
9
  import AuthIcons from '@/components/NextAuth/AuthIcons';
9
- import { useOnlyFetchOnceSWR } from '@/libs/swr';
10
+ import { linkSocial, unlinkAccount } from '@/libs/better-auth/auth-client';
10
11
  import { userService } from '@/services/user';
12
+ import { useServerConfigStore } from '@/store/serverConfig';
13
+ import { serverConfigSelectors } from '@/store/serverConfig/selectors';
11
14
  import { useUserStore } from '@/store/user';
12
- import { userProfileSelectors } from '@/store/user/selectors';
13
-
14
- const { Item } = List;
15
+ import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
15
16
 
16
17
  const providerNameStyle: CSSProperties = {
17
18
  textTransform: 'capitalize',
18
19
  };
19
20
 
20
21
  export const SSOProvidersList = memo(() => {
21
- const [userProfile] = useUserStore((s) => [userProfileSelectors.userProfile(s)]);
22
+ const userProfile = useUserStore(userProfileSelectors.userProfile);
23
+ const isLoginWithBetterAuth = useUserStore(authSelectors.isLoginWithBetterAuth);
24
+ const providers = useUserStore(authSelectors.authProviders);
25
+ const isEmailPasswordAuth = useUserStore(authSelectors.isEmailPasswordAuth);
26
+ const refreshAuthProviders = useUserStore((s) => s.refreshAuthProviders);
27
+ const oAuthSSOProviders = useServerConfigStore(serverConfigSelectors.oAuthSSOProviders);
22
28
  const { t } = useTranslation('auth');
23
29
 
24
- const [allowUnlink, setAllowUnlink] = useState<boolean>(false);
25
- const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
30
+ // Allow unlink if user has multiple SSO providers OR has email/password login
31
+ const allowUnlink = providers.length > 1 || isEmailPasswordAuth;
32
+
33
+ // Get linked provider IDs for filtering
34
+ const linkedProviderIds = useMemo(() => {
35
+ return new Set(providers.map((item) => item.provider));
36
+ }, [providers]);
26
37
 
27
- const { data, isLoading, mutate } = useOnlyFetchOnceSWR('profile-sso-providers', async () => {
28
- const list = await userService.getUserSSOProviders();
29
- setAllowUnlink(list?.length > 1);
30
- return list;
31
- });
38
+ // Get available providers for linking (filter out already linked)
39
+ const availableProviders = useMemo(() => {
40
+ return (oAuthSSOProviders || []).filter((provider) => !linkedProviderIds.has(provider));
41
+ }, [oAuthSSOProviders, linkedProviderIds]);
32
42
 
33
43
  const handleUnlinkSSO = async (provider: string, providerAccountId: string) => {
34
- if (data?.length === 1 || !data) {
35
- // At least one SSO provider should be linked
44
+ // Prevent unlink if this is the only login method
45
+ if (!allowUnlink) {
36
46
  notification.error({
37
47
  message: t('profile.sso.unlink.forbidden'),
38
48
  });
@@ -48,43 +58,75 @@ export const SSOProvidersList = memo(() => {
48
58
  danger: true,
49
59
  },
50
60
  onOk: async () => {
51
- await userService.unlinkSSOProvider(provider, providerAccountId);
52
- mutate();
61
+ if (isLoginWithBetterAuth) {
62
+ // Use better-auth native API
63
+ await unlinkAccount({ providerId: provider });
64
+ } else {
65
+ // Fallback for NextAuth
66
+ await userService.unlinkSSOProvider(provider, providerAccountId);
67
+ }
68
+ refreshAuthProviders();
53
69
  },
54
70
  title: <span style={providerNameStyle}>{t('profile.sso.unlink.title', { provider })}</span>,
55
71
  });
56
72
  };
57
73
 
58
- return isLoading ? (
59
- <Flexbox align={'center'} gap={4} horizontal>
60
- <ActionIcon icon={RotateCw} spin />
61
- {t('profile.sso.loading')}
62
- </Flexbox>
63
- ) : (
64
- <Flexbox>
65
- {data?.map((item, index) => (
66
- <Item
67
- actions={
68
- <Flexbox gap={4} horizontal>
69
- <CopyButton content={item.providerAccountId} size={'small'} />
70
- <ActionIcon
71
- disabled={!allowUnlink}
72
- icon={Unlink}
73
- onClick={() => handleUnlinkSSO(item.provider, item.providerAccountId)}
74
- size={'small'}
75
- />
76
- </Flexbox>
77
- }
78
- avatar={AuthIcons(item.provider)}
79
- date={item.expires_at}
80
- description={item.providerAccountId}
74
+ const handleLinkSSO = async (provider: string) => {
75
+ if (isLoginWithBetterAuth) {
76
+ // Use better-auth native linkSocial API
77
+ await linkSocial({
78
+ callbackURL: '/profile',
79
+ provider: provider as any,
80
+ });
81
+ }
82
+ };
83
+
84
+ // Dropdown menu items for linking new providers
85
+ const linkMenuItems: MenuProps['items'] = availableProviders.map((provider) => ({
86
+ icon: AuthIcons(provider, 16),
87
+ key: provider,
88
+ label: <span style={providerNameStyle}>{provider}</span>,
89
+ onClick: () => handleLinkSSO(provider),
90
+ }));
91
+
92
+ return (
93
+ <Flexbox gap={8}>
94
+ {providers.map((item) => (
95
+ <Flexbox
96
+ align={'center'}
97
+ gap={8}
98
+ horizontal
99
+ justify={'space-between'}
81
100
  key={[item.provider, item.providerAccountId].join('-')}
82
- onMouseEnter={() => setHoveredIndex(index)}
83
- onMouseLeave={() => setHoveredIndex(null)}
84
- showAction={hoveredIndex === index}
85
- title={<span style={providerNameStyle}>{item.provider}</span>}
86
- />
101
+ >
102
+ <Flexbox align={'center'} gap={6} horizontal style={{ fontSize: 12 }}>
103
+ {AuthIcons(item.provider, 16)}
104
+ <span style={providerNameStyle}>{item.provider}</span>
105
+ {item.email && (
106
+ <Typography.Text style={{ fontSize: 11 }} type="secondary">
107
+ · {item.email}
108
+ </Typography.Text>
109
+ )}
110
+ </Flexbox>
111
+ <ActionIcon
112
+ disabled={!allowUnlink}
113
+ icon={Unlink}
114
+ onClick={() => handleUnlinkSSO(item.provider, item.providerAccountId)}
115
+ size={'small'}
116
+ />
117
+ </Flexbox>
87
118
  ))}
119
+
120
+ {/* Link Account Button - Only show for Better-Auth users with available providers */}
121
+ {isLoginWithBetterAuth && availableProviders.length > 0 && (
122
+ <Dropdown menu={{ items: linkMenuItems, style: { maxWidth: '200px' } }} trigger={['click']}>
123
+ <Flexbox align={'center'} gap={6} horizontal style={{ cursor: 'pointer', fontSize: 12 }}>
124
+ <Plus size={14} />
125
+ <span>{t('profile.sso.link.button')}</span>
126
+ <ArrowRight size={14} />
127
+ </Flexbox>
128
+ </Dropdown>
129
+ )}
88
130
  </Flexbox>
89
131
  );
90
132
  });
package/src/auth.ts ADDED
@@ -0,0 +1,118 @@
1
+ /* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
2
+ import { serverDB } from '@lobechat/database';
3
+ import { betterAuth } from 'better-auth';
4
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
5
+ import { genericOAuth, magicLink } from 'better-auth/plugins';
6
+
7
+ import { authEnv } from '@/envs/auth';
8
+ import {
9
+ getMagicLinkEmailTemplate,
10
+ getResetPasswordEmailTemplate,
11
+ getVerificationEmailTemplate,
12
+ } from '@/libs/better-auth/email-templates';
13
+ import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
14
+ import { EmailService } from '@/server/services/email';
15
+
16
+ // Email verification link expiration time (in seconds)
17
+ // Default is 1 hour (3600 seconds) as per Better Auth documentation
18
+ const VERIFICATION_LINK_EXPIRES_IN = 3600;
19
+ const MAGIC_LINK_EXPIRES_IN = 900;
20
+ const enableMagicLink = authEnv.NEXT_PUBLIC_ENABLE_MAGIC_LINK;
21
+
22
+ const { socialProviders, genericOAuthProviders } = initBetterAuthSSOProviders();
23
+
24
+ export const auth = betterAuth({
25
+ account: {
26
+ accountLinking: {
27
+ allowDifferentEmails: true,
28
+ enabled: true,
29
+ },
30
+ },
31
+
32
+ // Use renamed env vars (fallback to next-auth vars is handled in src/envs/auth.ts)
33
+ baseURL: authEnv.NEXT_PUBLIC_AUTH_URL,
34
+ secret: authEnv.AUTH_SECRET,
35
+
36
+ database: drizzleAdapter(serverDB, {
37
+ provider: 'pg',
38
+ }),
39
+
40
+ emailAndPassword: {
41
+ autoSignIn: true,
42
+ enabled: true,
43
+ maxPasswordLength: 64,
44
+ minPasswordLength: 8,
45
+ requireEmailVerification: authEnv.NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION,
46
+
47
+ sendResetPassword: async ({ user, url }) => {
48
+ const template = getResetPasswordEmailTemplate({ url });
49
+
50
+ const emailService = new EmailService();
51
+ await emailService.sendMail({
52
+ to: user.email,
53
+ ...template,
54
+ });
55
+ },
56
+ },
57
+ emailVerification: {
58
+ autoSignInAfterVerification: true,
59
+ expiresIn: VERIFICATION_LINK_EXPIRES_IN,
60
+ sendVerificationEmail: async ({ user, url }) => {
61
+ const template = getVerificationEmailTemplate({
62
+ expiresInSeconds: VERIFICATION_LINK_EXPIRES_IN,
63
+ url,
64
+ userName: user.name,
65
+ });
66
+
67
+ const emailService = new EmailService();
68
+ await emailService.sendMail({
69
+ to: user.email,
70
+ ...template,
71
+ });
72
+ },
73
+ },
74
+
75
+ plugins: [
76
+ ...(genericOAuthProviders.length > 0
77
+ ? [
78
+ genericOAuth({
79
+ config: genericOAuthProviders,
80
+ }),
81
+ ]
82
+ : []),
83
+ ...(enableMagicLink
84
+ ? [
85
+ magicLink({
86
+ expiresIn: MAGIC_LINK_EXPIRES_IN,
87
+ sendMagicLink: async ({ email, url }) => {
88
+ const template = getMagicLinkEmailTemplate({
89
+ expiresInSeconds: MAGIC_LINK_EXPIRES_IN,
90
+ url,
91
+ });
92
+
93
+ const emailService = new EmailService();
94
+ await emailService.sendMail({
95
+ to: email,
96
+ ...template,
97
+ });
98
+ },
99
+ }),
100
+ ]
101
+ : []),
102
+ ],
103
+ socialProviders,
104
+
105
+ user: {
106
+ additionalFields: {
107
+ fullName: {
108
+ required: false,
109
+ type: 'string',
110
+ },
111
+ },
112
+ fields: {
113
+ image: 'avatar',
114
+ name: 'username',
115
+ },
116
+ modelName: 'users',
117
+ },
118
+ });