@lobehub/lobehub 2.0.0-next.123 → 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.
- package/.cursor/rules/db-migrations.mdc +16 -1
- package/.cursor/rules/project-introduce.mdc +1 -1
- package/.cursor/rules/project-structure.mdc +20 -2
- package/.env.example +148 -65
- package/.env.example.development +6 -8
- package/AGENTS.md +1 -3
- package/CHANGELOG.md +51 -0
- package/Dockerfile +6 -6
- package/GEMINI.md +63 -0
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/changelog/v1.json +18 -0
- package/docs/development/database-schema.dbml +38 -0
- package/docs/self-hosting/advanced/auth.mdx +75 -2
- package/docs/self-hosting/advanced/auth.zh-CN.mdx +75 -2
- package/docs/self-hosting/environment-variables/auth.mdx +187 -1
- package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +187 -1
- package/locales/en-US/auth.json +93 -0
- package/locales/zh-CN/auth.json +107 -1
- package/package.json +5 -2
- package/packages/const/src/auth.ts +2 -1
- package/packages/database/migrations/0048_add_editor_data.sql +1 -0
- package/packages/database/migrations/0049_better_auth.sql +49 -0
- package/packages/database/migrations/meta/0048_snapshot.json +7913 -0
- package/packages/database/migrations/meta/0049_snapshot.json +8151 -0
- package/packages/database/migrations/meta/_journal.json +14 -0
- package/packages/database/src/core/migrations.json +19 -0
- package/packages/database/src/index.ts +1 -0
- package/packages/database/src/models/__tests__/session.test.ts +1 -2
- package/packages/database/src/models/user.ts +9 -8
- package/packages/database/src/repositories/tableViewer/index.test.ts +2 -2
- package/packages/database/src/schemas/agent.ts +1 -0
- package/packages/database/src/schemas/betterAuth.ts +63 -0
- package/packages/database/src/schemas/index.ts +1 -0
- package/packages/database/src/schemas/ragEvals.ts +1 -2
- package/packages/database/src/schemas/user.ts +3 -2
- package/packages/database/src/server/models/__tests__/user.test.ts +1 -4
- package/packages/types/src/user/preference.ts +11 -0
- package/packages/utils/src/server/__tests__/auth.test.ts +52 -0
- package/packages/utils/src/server/auth.ts +18 -1
- package/src/app/(backend)/api/auth/[...all]/route.ts +19 -0
- package/src/app/(backend)/api/auth/check-user/route.ts +62 -0
- package/src/app/(backend)/middleware/auth/index.ts +14 -0
- package/src/app/(backend)/middleware/auth/utils.test.ts +16 -0
- package/src/app/(backend)/middleware/auth/utils.ts +13 -10
- package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +1 -0
- package/src/app/[variants]/(auth)/reset-password/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/reset-password/page.tsx +209 -0
- package/src/app/[variants]/(auth)/signin/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/signin/page.tsx +448 -0
- package/src/app/[variants]/(auth)/signup/[[...signup]]/BetterAuthSignUpForm.tsx +192 -0
- package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +31 -6
- package/src/app/[variants]/(auth)/verify-email/layout.tsx +12 -0
- package/src/app/[variants]/(auth)/verify-email/page.tsx +164 -0
- package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/UserBanner.test.tsx +12 -10
- package/src/app/[variants]/(main)/(mobile)/me/(home)/__tests__/useCategory.test.tsx +13 -11
- package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/TopicContent.tsx +15 -8
- package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/index.tsx +27 -30
- package/src/app/[variants]/(main)/profile/(home)/Client.tsx +306 -52
- package/src/app/[variants]/(main)/profile/(home)/features/SSOProvidersList/index.tsx +89 -47
- package/src/auth.ts +118 -0
- package/src/components/NextAuth/AuthIcons.tsx +3 -1
- package/src/envs/auth.ts +260 -13
- package/src/envs/email.ts +37 -0
- package/src/features/AgentSetting/AgentPlugin/index.tsx +6 -2
- package/src/features/User/UserPanel/PanelContent.tsx +6 -5
- package/src/features/User/__tests__/PanelContent.test.tsx +15 -6
- package/src/features/User/__tests__/UserAvatar.test.tsx +17 -6
- package/src/features/User/__tests__/useMenu.test.tsx +14 -12
- package/src/layout/AuthProvider/BetterAuth/UserUpdater.tsx +51 -0
- package/src/layout/AuthProvider/BetterAuth/index.tsx +14 -0
- package/src/layout/AuthProvider/index.tsx +3 -0
- package/src/layout/GlobalProvider/StoreInitialization.tsx +3 -3
- package/src/libs/better-auth/auth-client.ts +34 -0
- package/src/libs/better-auth/constants.ts +13 -0
- package/src/libs/better-auth/email-templates/index.ts +3 -0
- package/src/libs/better-auth/email-templates/magic-link.ts +98 -0
- package/src/libs/better-auth/email-templates/reset-password.ts +91 -0
- package/src/libs/better-auth/email-templates/verification.ts +108 -0
- package/src/libs/better-auth/sso/helpers.ts +61 -0
- package/src/libs/better-auth/sso/index.ts +113 -0
- package/src/libs/better-auth/sso/providers/auth0.ts +33 -0
- package/src/libs/better-auth/sso/providers/authelia.ts +35 -0
- package/src/libs/better-auth/sso/providers/authentik.ts +35 -0
- package/src/libs/better-auth/sso/providers/casdoor.ts +48 -0
- package/src/libs/better-auth/sso/providers/cloudflare-zero-trust.ts +41 -0
- package/src/libs/better-auth/sso/providers/cognito.ts +45 -0
- package/src/libs/better-auth/sso/providers/feishu.ts +181 -0
- package/src/libs/better-auth/sso/providers/generic-oidc.ts +44 -0
- package/src/libs/better-auth/sso/providers/github.ts +30 -0
- package/src/libs/better-auth/sso/providers/google.ts +30 -0
- package/src/libs/better-auth/sso/providers/keycloak.ts +35 -0
- package/src/libs/better-auth/sso/providers/logto.ts +38 -0
- package/src/libs/better-auth/sso/providers/microsoft.ts +65 -0
- package/src/libs/better-auth/sso/providers/okta.ts +37 -0
- package/src/libs/better-auth/sso/providers/wechat.ts +140 -0
- package/src/libs/better-auth/sso/providers/zitadel.ts +54 -0
- package/src/libs/better-auth/sso/types.ts +25 -0
- package/src/libs/better-auth/utils/client.ts +1 -0
- package/src/libs/better-auth/utils/common.ts +20 -0
- package/src/libs/better-auth/utils/server.test.ts +61 -0
- package/src/libs/better-auth/utils/server.ts +18 -0
- package/src/libs/trpc/lambda/context.test.ts +116 -0
- package/src/libs/trpc/lambda/context.ts +27 -0
- package/src/libs/trpc/middleware/userAuth.ts +4 -2
- package/src/locales/default/auth.ts +114 -1
- package/src/proxy.ts +71 -7
- package/src/server/globalConfig/index.ts +12 -1
- package/src/server/routers/lambda/user.ts +4 -0
- package/src/server/services/email/README.md +241 -0
- package/src/server/services/email/impls/index.test.ts +39 -0
- package/src/server/services/email/impls/index.ts +32 -0
- package/src/server/services/email/impls/nodemailer/index.ts +108 -0
- package/src/server/services/email/impls/nodemailer/type.ts +31 -0
- package/src/server/services/email/impls/type.ts +61 -0
- package/src/server/services/email/index.test.ts +144 -0
- package/src/server/services/email/index.ts +40 -0
- package/src/services/user/index.test.ts +162 -2
- package/src/services/user/index.ts +6 -3
- package/src/store/aiInfra/slices/aiProvider/action.ts +4 -4
- package/src/store/user/slices/auth/action.test.ts +213 -16
- package/src/store/user/slices/auth/action.ts +86 -1
- package/src/store/user/slices/auth/initialState.ts +13 -2
- package/src/store/user/slices/auth/selectors.ts +6 -2
- package/src/store/user/slices/common/action.ts +5 -1
- package/src/app/(backend)/api/auth/[...nextauth]/route.ts +0 -3
|
@@ -1,35 +1,280 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { Skeleton } from 'antd';
|
|
5
|
-
import {
|
|
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,
|
|
253
|
+
const [isLoginWithNextAuth, isLoginWithBetterAuth] = useUserStore((s) => [
|
|
19
254
|
authSelectors.isLoginWithNextAuth(s),
|
|
20
|
-
authSelectors.
|
|
255
|
+
authSelectors.isLoginWithBetterAuth(s),
|
|
21
256
|
]);
|
|
22
|
-
const [
|
|
23
|
-
userProfileSelectors.nickName(s),
|
|
257
|
+
const [username, userProfile, isUserLoaded] = useUserStore((s) => [
|
|
24
258
|
userProfileSelectors.username(s),
|
|
25
259
|
userProfileSelectors.userProfile(s),
|
|
26
|
-
|
|
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 (
|
|
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
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
25
|
-
const
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
});
|