diff --git a/pkg/api/http/controller/groups/user.py b/pkg/api/http/controller/groups/user.py index 498efaa4..3ad1335b 100644 --- a/pkg/api/http/controller/groups/user.py +++ b/pkg/api/http/controller/groups/user.py @@ -1,5 +1,6 @@ import quart import argon2 +import asyncio from .. import group @@ -40,3 +41,29 @@ class UserRouterGroup(group.RouterGroup): token = await self.ap.user_service.generate_jwt_token(user_email) return self.success(data={'token': token}) + + @self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE) + async def _() -> str: + json_data = await quart.request.json + + user_email = json_data['user'] + recovery_key = json_data['recovery_key'] + new_password = json_data['new_password'] + + # hard sleep 3s for security + await asyncio.sleep(3) + + if not await self.ap.user_service.is_initialized(): + return self.http_status(400, -1, 'system not initialized') + + user_obj = await self.ap.user_service.get_user_by_email(user_email) + + if user_obj is None: + return self.http_status(400, -1, 'user not found') + + if recovery_key != self.ap.instance_config.data['system']['recovery_key']: + return self.http_status(403, -1, 'invalid recovery key') + + await self.ap.user_service.reset_password(user_email, new_password) + + return self.success(data={'user': user_email}) diff --git a/pkg/api/http/service/user.py b/pkg/api/http/service/user.py index 782aad75..c724bfcf 100644 --- a/pkg/api/http/service/user.py +++ b/pkg/api/http/service/user.py @@ -73,3 +73,12 @@ class UserService: jwt_secret = self.ap.instance_config.data['system']['jwt']['secret'] return jwt.decode(token, jwt_secret, algorithms=['HS256'])['user'] + + async def reset_password(self, user_email: str, new_password: str) -> None: + ph = argon2.PasswordHasher() + + hashed_password = ph.hash(new_password) + + await self.ap.persistence_mgr.execute_async( + sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) + ) diff --git a/pkg/core/stages/genkeys.py b/pkg/core/stages/genkeys.py index c24ebd70..50e7cf7b 100644 --- a/pkg/core/stages/genkeys.py +++ b/pkg/core/stages/genkeys.py @@ -15,3 +15,10 @@ class GenKeysStage(stage.BootingStage): if not ap.instance_config.data['system']['jwt']['secret']: ap.instance_config.data['system']['jwt']['secret'] = secrets.token_hex(16) await ap.instance_config.dump_config() + + if 'recovery_key' not in ap.instance_config.data['system']: + ap.instance_config.data['system']['recovery_key'] = '' + + if not ap.instance_config.data['system']['recovery_key']: + ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper() + await ap.instance_config.dump_config() diff --git a/templates/config.yaml b/templates/config.yaml index 109cd8d7..d347af77 100644 --- a/templates/config.yaml +++ b/templates/config.yaml @@ -15,6 +15,7 @@ proxy: http: '' https: '' system: + recovery_key: '' jwt: expire: 604800 secret: '' diff --git a/web/package.json b/web/package.json index 17516ac4..458e4132 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "clsx": "^2.1.1", "i18next": "^25.1.2", "i18next-browser-languagedetector": "^8.1.0", + "input-otp": "^1.4.2", "lodash": "^4.17.21", "lucide-react": "^0.507.0", "next": "15.2.4", diff --git a/web/src/app/infra/http/HttpClient.ts b/web/src/app/infra/http/HttpClient.ts index a86cdbe8..5193703b 100644 --- a/web/src/app/infra/http/HttpClient.ts +++ b/web/src/app/infra/http/HttpClient.ts @@ -492,6 +492,18 @@ class HttpClient { public checkUserToken(): Promise { return this.get('/api/v1/user/check-token'); } + + public resetPassword( + user: string, + recoveryKey: string, + newPassword: string, + ): Promise<{ user: string }> { + return this.post('/api/v1/user/reset-password', { + user, + recovery_key: recoveryKey, + new_password: newPassword, + }); + } } const getBaseURL = (): string => { diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 9d4b3a17..d55e3fd3 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -34,6 +34,7 @@ import langbotIcon from '@/app/assets/langbot-logo.webp'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import i18n from '@/i18n'; +import Link from 'next/link'; const formSchema = (t: (key: string) => string) => z.object({ @@ -209,7 +210,16 @@ export default function Login() { name="password" render={({ field }) => ( - {t('common.password')} +
+ {t('common.password')} + + {t('common.forgotPassword')} + +
+
diff --git a/web/src/app/reset-password/layout.tsx b/web/src/app/reset-password/layout.tsx new file mode 100644 index 00000000..5db7817e --- /dev/null +++ b/web/src/app/reset-password/layout.tsx @@ -0,0 +1,15 @@ +'use client'; + +import React from 'react'; + +export default function ResetPasswordLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
{children}
+
+ ); +} diff --git a/web/src/app/reset-password/page.tsx b/web/src/app/reset-password/page.tsx new file mode 100644 index 00000000..30671595 --- /dev/null +++ b/web/src/app/reset-password/page.tsx @@ -0,0 +1,205 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, + InputOTPSeparator, +} from '@/components/ui/input-otp'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from '@/components/ui/form'; +import { useState } from 'react'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { useRouter } from 'next/navigation'; +import { Mail, Lock, ArrowLeft } from 'lucide-react'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import Link from 'next/link'; + +const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/; + +const formSchema = (t: (key: string) => string) => + z.object({ + email: z.string().email(t('common.invalidEmail')), + recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')), + newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')), + }); + +export default function ResetPassword() { + const router = useRouter(); + const { t } = useTranslation(); + const [isResetting, setIsResetting] = useState(false); + + const form = useForm>>({ + resolver: zodResolver(formSchema(t)), + defaultValues: { + email: '', + recoveryKey: '', + newPassword: '', + }, + }); + + function onSubmit(values: z.infer>) { + handleResetPassword(values.email, values.recoveryKey, values.newPassword); + } + + function handleResetPassword( + email: string, + recoveryKey: string, + newPassword: string, + ) { + setIsResetting(true); + httpClient + .resetPassword(email, recoveryKey, newPassword) + .then((res) => { + console.log('reset password success: ', res); + toast.success(t('resetPassword.resetSuccess')); + router.push('/login'); + }) + .catch((err) => { + console.log('reset password error: ', err); + toast.error(t('resetPassword.resetFailed')); + }) + .finally(() => { + setIsResetting(false); + }); + } + + return ( +
+ + +
+ + + {t('resetPassword.backToLogin')} + +
+ + {t('resetPassword.title')} + + + {t('resetPassword.description')} + +
+ +
+ + ( + + {t('common.email')} + +
+ + +
+
+ +
+ )} + /> + + ( + + {t('resetPassword.recoveryKey')} + + {t('resetPassword.recoveryKeyDescription')} + + + { + // 将输入的值转换为大写 + const upperValue = value.toUpperCase(); + field.onChange(upperValue); + }} + > + + + + + + + + + + + + + + + + )} + /> + + ( + + {t('resetPassword.newPassword')} + +
+ + +
+
+ +
+ )} + /> + + + + +
+
+
+ ); +} diff --git a/web/src/components/ui/input-otp.tsx b/web/src/components/ui/input-otp.tsx new file mode 100644 index 00000000..26c5f7af --- /dev/null +++ b/web/src/components/ui/input-otp.tsx @@ -0,0 +1,77 @@ +'use client'; + +import * as React from 'react'; +import { OTPInput, OTPInputContext } from 'input-otp'; +import { MinusIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string; +}) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<'div'> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 0e171e4b..1975a521 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -39,6 +39,7 @@ const enUS = { addRound: 'Add Round', copySuccess: 'Copy Successfully', test: 'Test', + forgotPassword: 'Forgot Password?', }, notFound: { title: 'Page not found', @@ -239,6 +240,25 @@ const enUS = { initSuccess: 'Initialization successful, please login', initFailed: 'Initialization failed: ', }, + resetPassword: { + title: 'Reset Password 🔐', + description: + 'Enter your recovery key and new password to reset your account password', + recoveryKey: 'Recovery Key', + recoveryKeyDescription: + 'Stored in `system.recovery_key` of config file `data/config.yaml`', + newPassword: 'New Password', + enterRecoveryKey: 'Enter recovery key', + enterNewPassword: 'Enter new password', + recoveryKeyRequired: 'Recovery key cannot be empty', + newPasswordRequired: 'New password cannot be empty', + resetPassword: 'Reset Password', + resetting: 'Resetting...', + resetSuccess: 'Password reset successfully, please login', + resetFailed: + 'Password reset failed, please check your email and recovery key', + backToLogin: 'Back to Login', + }, }; export default enUS; diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index f1783a35..bac6f805 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -40,6 +40,7 @@ const jaJP = { addRound: 'ラウンドを追加', copySuccess: 'コピーに成功しました', test: 'テスト', + forgotPassword: 'パスワードを忘れた?', }, notFound: { title: 'ページが見つかりません', @@ -240,6 +241,25 @@ const jaJP = { initSuccess: '初期化に成功しました。ログインしてください', initFailed: '初期化に失敗しました:', }, + resetPassword: { + title: 'パスワードをリセット 🔐', + description: + '復旧キーと新しいパスワードを入力して、アカウントのパスワードをリセットします', + recoveryKey: '復旧キー', + recoveryKeyDescription: + '設定ファイル `data/config.yaml` の `system.recovery_key` に保存されています', + newPassword: '新しいパスワード', + enterRecoveryKey: '復旧キーを入力', + enterNewPassword: '新しいパスワードを入力', + recoveryKeyRequired: '復旧キーは必須です', + newPasswordRequired: '新しいパスワードは必須です', + resetPassword: 'パスワードをリセット', + resetting: 'リセット中...', + resetSuccess: 'パスワードのリセットに成功しました。ログインしてください', + resetFailed: + 'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください', + backToLogin: 'ログインに戻る', + }, }; export default jaJP; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 2a960131..2ded8236 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -39,6 +39,7 @@ const zhHans = { addRound: '添加回合', copySuccess: '复制成功', test: '测试', + forgotPassword: '忘记密码?', }, notFound: { title: '页面不存在', @@ -233,6 +234,23 @@ const zhHans = { initSuccess: '初始化成功 请登录', initFailed: '初始化失败:', }, + resetPassword: { + title: '重置密码 🔐', + description: '输入恢复密钥和新的密码来重置您的账户密码', + recoveryKey: '恢复密钥', + recoveryKeyDescription: + '存储在配置文件`data/config.yaml`的`system.recovery_key`中', + newPassword: '新密码', + enterRecoveryKey: '输入恢复密钥', + enterNewPassword: '输入新密码', + recoveryKeyRequired: '恢复密钥不能为空', + newPasswordRequired: '新密码不能为空', + resetPassword: '重置密码', + resetting: '重置中...', + resetSuccess: '密码重置成功,请登录', + resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确', + backToLogin: '返回登录', + }, }; export default zhHans;