@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
@@ -0,0 +1,209 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@lobehub/ui';
4
+ import { LobeHub } from '@lobehub/ui/brand';
5
+ import { Form, Input } from 'antd';
6
+ import { createStyles, useTheme } from 'antd-style';
7
+ import { ArrowLeft, KeyRound, Lock } from 'lucide-react';
8
+ import Link from 'next/link';
9
+ import { useRouter, useSearchParams } from 'next/navigation';
10
+ import { useState } from 'react';
11
+ import { useTranslation } from 'react-i18next';
12
+ import { Center, Flexbox } from 'react-layout-kit';
13
+
14
+ import { message } from '@/components/AntdStaticMethods';
15
+ import { resetPassword } from '@/libs/better-auth/auth-client';
16
+
17
+ const useStyles = createStyles(({ css, token }) => ({
18
+ backLink: css`
19
+ display: inline-flex;
20
+ gap: 6px;
21
+ align-items: center;
22
+
23
+ font-size: 14px;
24
+ color: ${token.colorTextSecondary};
25
+ text-decoration: none;
26
+
27
+ transition: color 0.2s ease;
28
+
29
+ &:hover {
30
+ color: ${token.colorText};
31
+ }
32
+ `,
33
+ container: css`
34
+ max-width: 400px;
35
+ padding: 2rem;
36
+ text-align: center;
37
+ `,
38
+ description: css`
39
+ font-size: 14px;
40
+ line-height: 1.6;
41
+ color: ${token.colorTextSecondary};
42
+ `,
43
+ email: css`
44
+ font-weight: 500;
45
+ color: ${token.colorText};
46
+ `,
47
+ iconWrapper: css`
48
+ display: inline-flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+
52
+ width: 80px;
53
+ height: 80px;
54
+ border-radius: 50%;
55
+
56
+ background: linear-gradient(
57
+ 135deg,
58
+ ${token.colorPrimaryBg} 0%,
59
+ ${token.colorPrimaryBgHover} 100%
60
+ );
61
+ `,
62
+ title: css`
63
+ margin-block: 0;
64
+ font-size: 24px;
65
+ font-weight: 600;
66
+ color: ${token.colorTextHeading};
67
+ `,
68
+ }));
69
+
70
+ interface ResetPasswordFormValues {
71
+ confirmPassword: string;
72
+ newPassword: string;
73
+ }
74
+
75
+ export default function ResetPasswordPage() {
76
+ const { styles } = useStyles();
77
+ const theme = useTheme();
78
+ const { t } = useTranslation('auth');
79
+ const router = useRouter();
80
+ const searchParams = useSearchParams();
81
+ const token = searchParams.get('token');
82
+ const email = searchParams.get('email');
83
+ const [form] = Form.useForm();
84
+ const [loading, setLoading] = useState(false);
85
+
86
+ const handleResetPassword = async (values: ResetPasswordFormValues) => {
87
+ if (!token) {
88
+ message.error(t('betterAuth.resetPassword.invalidToken'));
89
+ return;
90
+ }
91
+
92
+ setLoading(true);
93
+ try {
94
+ const result = await resetPassword({
95
+ newPassword: values.newPassword,
96
+ token,
97
+ });
98
+
99
+ if (result.error) {
100
+ message.error(result.error.message || t('betterAuth.resetPassword.error'));
101
+ return;
102
+ }
103
+
104
+ message.success(t('betterAuth.resetPassword.success'));
105
+ router.push(email ? `/signin?email=${encodeURIComponent(email)}` : '/signin');
106
+ } catch (error) {
107
+ console.error('Reset password error:', error);
108
+ message.error(t('betterAuth.resetPassword.error'));
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ };
113
+
114
+ // Show error if no token
115
+ if (!token) {
116
+ return (
117
+ <Center style={{ minHeight: '100vh' }}>
118
+ <Flexbox align="center" className={styles.container} gap={24}>
119
+ <LobeHub size={56} />
120
+ <h1 className={styles.title}>{t('betterAuth.resetPassword.title')}</h1>
121
+ <p className={styles.description}>{t('betterAuth.resetPassword.invalidToken')}</p>
122
+ <Link className={styles.backLink} href="/signin">
123
+ <ArrowLeft size={16} />
124
+ {t('betterAuth.resetPassword.backToSignIn')}
125
+ </Link>
126
+ </Flexbox>
127
+ </Center>
128
+ );
129
+ }
130
+
131
+ return (
132
+ <Center style={{ minHeight: '100vh' }}>
133
+ <Flexbox align="center" className={styles.container} gap={24}>
134
+ <LobeHub size={56} />
135
+
136
+ <h1 className={styles.title}>{t('betterAuth.resetPassword.title')}</h1>
137
+
138
+ <div className={styles.iconWrapper}>
139
+ <KeyRound color={theme.colorPrimary} size={36} strokeWidth={1.5} />
140
+ </div>
141
+
142
+ <p className={styles.description}>
143
+ {t('betterAuth.resetPassword.description')}
144
+ {email && (
145
+ <>
146
+ <br />
147
+ <span className={styles.email}>{email}</span>
148
+ </>
149
+ )}
150
+ </p>
151
+
152
+ <Form
153
+ form={form}
154
+ layout="vertical"
155
+ onFinish={handleResetPassword}
156
+ style={{ textAlign: 'left', width: '100%' }}
157
+ >
158
+ <Form.Item
159
+ name="newPassword"
160
+ rules={[
161
+ { message: t('betterAuth.errors.passwordRequired'), required: true },
162
+ { message: t('betterAuth.errors.passwordMinLength'), min: 8 },
163
+ { max: 64, message: t('betterAuth.errors.passwordMaxLength') },
164
+ ]}
165
+ >
166
+ <Input.Password
167
+ placeholder={t('betterAuth.resetPassword.newPasswordPlaceholder')}
168
+ prefix={<Lock size={16} />}
169
+ size="large"
170
+ />
171
+ </Form.Item>
172
+
173
+ <Form.Item
174
+ dependencies={['newPassword']}
175
+ name="confirmPassword"
176
+ rules={[
177
+ { message: t('betterAuth.resetPassword.confirmPasswordRequired'), required: true },
178
+ ({ getFieldValue }) => ({
179
+ validator(_, value) {
180
+ if (!value || getFieldValue('newPassword') === value) {
181
+ return Promise.resolve();
182
+ }
183
+ return Promise.reject(new Error(t('betterAuth.resetPassword.passwordMismatch')));
184
+ },
185
+ }),
186
+ ]}
187
+ >
188
+ <Input.Password
189
+ placeholder={t('betterAuth.resetPassword.confirmPasswordPlaceholder')}
190
+ prefix={<Lock size={16} />}
191
+ size="large"
192
+ />
193
+ </Form.Item>
194
+
195
+ <Form.Item style={{ marginBottom: 0 }}>
196
+ <Button block htmlType="submit" loading={loading} size="large" type="primary">
197
+ {t('betterAuth.resetPassword.submit')}
198
+ </Button>
199
+ </Form.Item>
200
+ </Form>
201
+
202
+ <Link className={styles.backLink} href="/signin">
203
+ <ArrowLeft size={16} />
204
+ {t('betterAuth.resetPassword.backToSignIn')}
205
+ </Link>
206
+ </Flexbox>
207
+ </Center>
208
+ );
209
+ }
@@ -0,0 +1,12 @@
1
+ import { notFound } from 'next/navigation';
2
+ import { PropsWithChildren } from 'react';
3
+
4
+ import { enableBetterAuth } from '@/const/auth';
5
+
6
+ const Layout = ({ children }: PropsWithChildren) => {
7
+ if (!enableBetterAuth) return notFound();
8
+
9
+ return children;
10
+ };
11
+
12
+ export default Layout;
@@ -0,0 +1,448 @@
1
+ 'use client';
2
+
3
+ import { ActionIcon, Button } from '@lobehub/ui';
4
+ import { LobeHub } from '@lobehub/ui/brand';
5
+ import { Form, Input, type InputRef } from 'antd';
6
+ import { createStyles, useTheme } from 'antd-style';
7
+ import { ChevronLeft, ChevronRight, Lock, Mail } from 'lucide-react';
8
+ import { useRouter, useSearchParams } from 'next/navigation';
9
+ import { useEffect, useRef, useState } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { Flexbox } from 'react-layout-kit';
12
+
13
+ import { message } from '@/components/AntdStaticMethods';
14
+ import AuthIcons from '@/components/NextAuth/AuthIcons';
15
+ import { getAuthConfig } from '@/envs/auth';
16
+ import { requestPasswordReset, signIn } from '@/libs/better-auth/auth-client';
17
+ import { isBuiltinProvider, normalizeProviderId } from '@/libs/better-auth/utils/client';
18
+ import { useUserStore } from '@/store/user';
19
+
20
+ const useStyles = createStyles(({ css, token }) => ({
21
+ backButton: css`
22
+ cursor: pointer;
23
+ font-size: 14px;
24
+ color: ${token.colorPrimary};
25
+
26
+ &:hover {
27
+ color: ${token.colorPrimaryHover};
28
+ }
29
+ `,
30
+ card: css`
31
+ padding-block: 2.5rem;
32
+ padding-inline: 2rem;
33
+ `,
34
+ container: css`
35
+ width: 360px;
36
+ border: 1px solid ${token.colorBorder};
37
+ border-radius: ${token.borderRadiusLG}px;
38
+ `,
39
+ divider: css`
40
+ flex: 1;
41
+ height: 1px;
42
+ background: ${token.colorBorder};
43
+ `,
44
+ dividerText: css`
45
+ font-size: 14px;
46
+ color: ${token.colorTextSecondary};
47
+ `,
48
+ emailDisplay: css`
49
+ font-size: 14px;
50
+ color: ${token.colorTextSecondary};
51
+ text-align: center;
52
+ `,
53
+ footer: css`
54
+ padding: 1rem;
55
+ border-block-start: 1px solid ${token.colorBorder};
56
+
57
+ font-size: 14px;
58
+ color: ${token.colorTextDescription};
59
+ text-align: center;
60
+
61
+ background: ${token.colorBgElevated};
62
+ `,
63
+ subtitle: css`
64
+ margin-block-start: 0.5rem;
65
+ font-size: 14px;
66
+ color: ${token.colorTextSecondary};
67
+ text-align: center;
68
+ `,
69
+ title: css`
70
+ margin-block-start: 1rem;
71
+
72
+ font-size: 24px;
73
+ font-weight: 600;
74
+ color: ${token.colorTextHeading};
75
+ text-align: center;
76
+ `,
77
+ }));
78
+
79
+ type Step = 'email' | 'password';
80
+
81
+ interface SignInFormValues {
82
+ email: string;
83
+ password: string;
84
+ }
85
+
86
+ export default function SignInPage() {
87
+ const { styles } = useStyles();
88
+ const theme = useTheme();
89
+ const { t } = useTranslation('auth');
90
+ const router = useRouter();
91
+ const searchParams = useSearchParams();
92
+ const { NEXT_PUBLIC_ENABLE_MAGIC_LINK: enableMagicLink } = getAuthConfig();
93
+ const [form] = Form.useForm();
94
+ const [loading, setLoading] = useState(false);
95
+ const [socialLoading, setSocialLoading] = useState<string | null>(null);
96
+ const [step, setStep] = useState<Step>('email');
97
+ const [email, setEmail] = useState('');
98
+ const emailInputRef = useRef<InputRef>(null);
99
+ const passwordInputRef = useRef<InputRef>(null);
100
+ const oAuthSSOProviders = useUserStore((s) => s.oAuthSSOProviders || []);
101
+
102
+ // Auto-focus input when step changes
103
+ useEffect(() => {
104
+ if (step === 'email') {
105
+ emailInputRef.current?.focus();
106
+ } else if (step === 'password') {
107
+ passwordInputRef.current?.focus();
108
+ }
109
+ }, [step]);
110
+
111
+ // Pre-fill email from URL params
112
+ useEffect(() => {
113
+ const emailParam = searchParams.get('email');
114
+ if (emailParam) {
115
+ form.setFieldValue('email', emailParam);
116
+ }
117
+ }, [searchParams, form]);
118
+
119
+ const handleSendMagicLink = async (targetEmail?: string) => {
120
+ try {
121
+ const emailValue =
122
+ targetEmail ||
123
+ (await form
124
+ .validateFields(['email'])
125
+ .then((v) => v.email as string)
126
+ .catch(() => null));
127
+
128
+ if (!emailValue) {
129
+ return;
130
+ }
131
+
132
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
133
+ const { error } = await signIn.magicLink({
134
+ callbackURL: callbackUrl,
135
+ email: emailValue,
136
+ });
137
+
138
+ if (error) {
139
+ message.error(error.message || t('betterAuth.signin.magicLinkError'));
140
+ return;
141
+ }
142
+
143
+ message.success(t('betterAuth.signin.magicLinkSent'));
144
+ } catch (error) {
145
+ // validation errors are surfaced by antd form; only log unexpected errors
146
+ if (!(error as any)?.errorFields) {
147
+ console.error('Magic link error:', error);
148
+ message.error(t('betterAuth.signin.magicLinkError'));
149
+ }
150
+ } finally {
151
+ // no-op
152
+ }
153
+ };
154
+
155
+ // Check if user exists
156
+ const handleCheckUser = async (values: Pick<SignInFormValues, 'email'>) => {
157
+ setLoading(true);
158
+ try {
159
+ const response = await fetch('/api/auth/check-user', {
160
+ body: JSON.stringify({ email: values.email }),
161
+ headers: { 'Content-Type': 'application/json' },
162
+ method: 'POST',
163
+ });
164
+
165
+ const data = await response.json();
166
+
167
+ if (!data.exists) {
168
+ // User not found, redirect to signup page with email pre-filled
169
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
170
+ router.push(
171
+ `/signup?email=${encodeURIComponent(values.email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
172
+ );
173
+ return;
174
+ }
175
+
176
+ setEmail(values.email);
177
+
178
+ if (enableMagicLink) {
179
+ await handleSendMagicLink(values.email);
180
+ return;
181
+ }
182
+
183
+ if (data.hasPassword) {
184
+ setStep('password');
185
+ return;
186
+ }
187
+
188
+ message.info(t('betterAuth.signin.socialOnlyHint'));
189
+ } catch (error) {
190
+ console.error('Error checking user:', error);
191
+ message.error(t('betterAuth.signin.error'));
192
+ } finally {
193
+ setLoading(false);
194
+ }
195
+ };
196
+
197
+ // Sign in with email and password
198
+ const handleSignIn = async (values: SignInFormValues) => {
199
+ setLoading(true);
200
+ try {
201
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
202
+
203
+ const result = await signIn.email(
204
+ {
205
+ callbackURL: callbackUrl,
206
+ email: email,
207
+ password: values.password,
208
+ },
209
+ {
210
+ onError: (ctx) => {
211
+ console.error('Sign in error:', ctx.error);
212
+ // Check if error is due to unverified email (403 status)
213
+ if (ctx.error.status === 403) {
214
+ // Redirect to verify-email page instead of showing error
215
+ router.push(
216
+ `/verify-email?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`,
217
+ );
218
+ return;
219
+ }
220
+ },
221
+ onSuccess: () => {
222
+ router.push(callbackUrl);
223
+ },
224
+ },
225
+ );
226
+
227
+ // Only show error if not already handled in onError callback
228
+ if (result.error && result.error.status !== 403) {
229
+ message.error(result.error.message || t('betterAuth.signin.error'));
230
+ return;
231
+ }
232
+ } catch (error) {
233
+ console.error('Sign in error:', error);
234
+ message.error(t('betterAuth.signin.error'));
235
+ } finally {
236
+ setLoading(false);
237
+ }
238
+ };
239
+
240
+ const handleBackToEmail = () => {
241
+ setStep('email');
242
+ setEmail('');
243
+ };
244
+
245
+ const handleGoToSignup = () => {
246
+ const currentEmail = form.getFieldValue('email');
247
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
248
+ const params = new URLSearchParams();
249
+ if (currentEmail) {
250
+ params.set('email', currentEmail);
251
+ }
252
+ params.set('callbackUrl', callbackUrl);
253
+ router.push(`/signup?${params.toString()}`);
254
+ };
255
+
256
+ const getProviderLabel = (provider: string) => {
257
+ const normalized = normalizeProviderId(provider);
258
+ const normalizedKey = normalized
259
+ .replaceAll(/(^|[_-])([a-z])/g, (_, __, c) => c.toUpperCase())
260
+ .replaceAll(/[^\dA-Za-z]/g, '');
261
+ const key = `betterAuth.signin.continueWith${normalizedKey}`;
262
+ return t(key, { defaultValue: `Continue with ${normalized}` });
263
+ };
264
+
265
+ const handleSocialSignIn = async (provider: string) => {
266
+ setSocialLoading(provider);
267
+ const normalizedProvider = normalizeProviderId(provider);
268
+
269
+ try {
270
+ const callbackUrl = searchParams.get('callbackUrl') || '/';
271
+ const result = isBuiltinProvider(normalizedProvider)
272
+ ? await signIn.social({
273
+ callbackURL: callbackUrl,
274
+ provider: normalizedProvider,
275
+ })
276
+ : await signIn.oauth2({
277
+ callbackURL: callbackUrl,
278
+ providerId: normalizedProvider,
279
+ });
280
+
281
+ if (result?.error) {
282
+ throw result.error;
283
+ }
284
+ } catch (error) {
285
+ console.error(`${normalizedProvider} sign in error:`, error);
286
+ message.error(t('betterAuth.signin.socialError'));
287
+ } finally {
288
+ setSocialLoading(null);
289
+ }
290
+ };
291
+
292
+ return (
293
+ <Flexbox align="center" justify="center" style={{ minHeight: '100vh' }}>
294
+ <div className={styles.container}>
295
+ <div className={styles.card}>
296
+ <Flexbox align="center" gap={8} justify="center">
297
+ <LobeHub size={48} />
298
+ </Flexbox>
299
+
300
+ <h1 className={styles.title}>{t('betterAuth.signin.emailStep.title')}</h1>
301
+
302
+ {step === 'email' && (
303
+ <>
304
+ <p className={styles.subtitle}>{t('betterAuth.signin.emailStep.subtitle')}</p>
305
+
306
+ {/* Social Login Section */}
307
+ {oAuthSSOProviders.length > 0 && (
308
+ <Flexbox gap={12} style={{ marginTop: '2rem' }}>
309
+ {oAuthSSOProviders.map((provider) => (
310
+ <Button
311
+ block
312
+ icon={AuthIcons(provider, 16)}
313
+ key={provider}
314
+ loading={socialLoading === provider}
315
+ onClick={() => handleSocialSignIn(provider)}
316
+ size="large"
317
+ >
318
+ {getProviderLabel(provider)}
319
+ </Button>
320
+ ))}
321
+
322
+ {/* Divider */}
323
+ <Flexbox align="center" gap={12} horizontal>
324
+ <div className={styles.divider} />
325
+ <span className={styles.dividerText}>
326
+ {t('betterAuth.signin.orContinueWith')}
327
+ </span>
328
+ <div className={styles.divider} />
329
+ </Flexbox>
330
+ </Flexbox>
331
+ )}
332
+
333
+ <Form
334
+ form={form}
335
+ layout="vertical"
336
+ onFinish={handleCheckUser}
337
+ style={{ marginTop: '0.5rem' }}
338
+ >
339
+ <Form.Item
340
+ name="email"
341
+ rules={[
342
+ { message: t('betterAuth.errors.emailRequired'), required: true },
343
+ { message: t('betterAuth.errors.emailInvalid'), type: 'email' },
344
+ ]}
345
+ style={{ marginBottom: 0 }}
346
+ >
347
+ <Input
348
+ placeholder={t('betterAuth.signin.emailPlaceholder')}
349
+ prefix={<Mail size={16} />}
350
+ ref={emailInputRef}
351
+ size="large"
352
+ suffix={
353
+ <ActionIcon
354
+ active
355
+ icon={ChevronRight}
356
+ loading={loading}
357
+ onClick={() => form.submit()}
358
+ size={{ blockSize: 32, size: 16 }}
359
+ style={{ color: theme.colorPrimary }}
360
+ title={t('betterAuth.signin.nextStep')}
361
+ />
362
+ }
363
+ />
364
+ </Form.Item>
365
+ </Form>
366
+ </>
367
+ )}
368
+
369
+ {step === 'password' && (
370
+ <>
371
+ <p className={styles.emailDisplay}>{email}</p>
372
+ <div
373
+ className={styles.backButton}
374
+ onClick={handleBackToEmail}
375
+ style={{ marginTop: '0.5rem', textAlign: 'center' }}
376
+ >
377
+ <ChevronLeft size={14} style={{ display: 'inline', verticalAlign: 'middle' }} />
378
+ <span style={{ marginLeft: '0.25rem' }}>{t('betterAuth.signin.backToEmail')}</span>
379
+ </div>
380
+ <p className={styles.subtitle} style={{ marginTop: '1rem' }}>
381
+ {t('betterAuth.signin.passwordStep.subtitle')}
382
+ </p>
383
+
384
+ <Form
385
+ form={form}
386
+ layout="vertical"
387
+ onFinish={handleSignIn}
388
+ style={{ marginTop: '1.5rem' }}
389
+ >
390
+ <Form.Item
391
+ name="password"
392
+ rules={[{ message: t('betterAuth.errors.passwordRequired'), required: true }]}
393
+ style={{ marginBottom: 0 }}
394
+ >
395
+ <Input.Password
396
+ placeholder={t('betterAuth.signin.passwordPlaceholder')}
397
+ prefix={<Lock size={16} />}
398
+ ref={passwordInputRef}
399
+ size="large"
400
+ suffix={
401
+ <ActionIcon
402
+ active
403
+ icon={ChevronRight}
404
+ loading={loading}
405
+ onClick={() => form.submit()}
406
+ size={{ blockSize: 32, size: 16 }}
407
+ style={{ color: theme.colorPrimary }}
408
+ title={t('betterAuth.signin.submit')}
409
+ />
410
+ }
411
+ />
412
+ </Form.Item>
413
+ </Form>
414
+
415
+ <div
416
+ className={styles.backButton}
417
+ onClick={async () => {
418
+ try {
419
+ await requestPasswordReset({
420
+ email,
421
+ redirectTo: `/reset-password?email=${encodeURIComponent(email)}`,
422
+ });
423
+ message.success(t('betterAuth.signin.forgotPasswordSent'));
424
+ } catch {
425
+ message.error(t('betterAuth.signin.forgotPasswordError'));
426
+ }
427
+ }}
428
+ style={{ marginTop: '1rem', textAlign: 'center' }}
429
+ >
430
+ {t('betterAuth.signin.forgotPassword')}
431
+ </div>
432
+ </>
433
+ )}
434
+ </div>
435
+
436
+ <div className={styles.footer}>
437
+ {t('betterAuth.signin.noAccount')}{' '}
438
+ <a
439
+ onClick={handleGoToSignup}
440
+ style={{ color: 'inherit', cursor: 'pointer', textDecoration: 'underline' }}
441
+ >
442
+ {t('betterAuth.signin.signupLink')}
443
+ </a>
444
+ </div>
445
+ </div>
446
+ </Flexbox>
447
+ );
448
+ }