diff --git a/pkg/api/http/controller/groups/user.py b/pkg/api/http/controller/groups/user.py index d8024107..b84b2292 100644 --- a/pkg/api/http/controller/groups/user.py +++ b/pkg/api/http/controller/groups/user.py @@ -67,3 +67,19 @@ class UserRouterGroup(group.RouterGroup): await self.ap.user_service.reset_password(user_email, new_password) return self.success(data={'user': user_email}) + + @self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(user_email: str) -> str: + json_data = await quart.request.json + + current_password = json_data['current_password'] + new_password = json_data['new_password'] + + try: + await self.ap.user_service.change_password(user_email, current_password, new_password) + except argon2.exceptions.VerifyMismatchError: + return self.http_status(400, -1, 'Current password is incorrect') + except ValueError as e: + return self.http_status(400, -1, str(e)) + + return self.success(data={'user': user_email}) diff --git a/pkg/api/http/service/user.py b/pkg/api/http/service/user.py index c724bfcf..7a1f0323 100644 --- a/pkg/api/http/service/user.py +++ b/pkg/api/http/service/user.py @@ -82,3 +82,18 @@ class UserService: await self.ap.persistence_mgr.execute_async( sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password) ) + + async def change_password(self, user_email: str, current_password: str, new_password: str) -> None: + ph = argon2.PasswordHasher() + + user_obj = await self.get_user_by_email(user_email) + if user_obj is None: + raise ValueError('User not found') + + ph.verify(user_obj.password, current_password) + + 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/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 9734c537..25b3b58f 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -22,6 +22,7 @@ import { import { Button } from '@/components/ui/button'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { LanguageSelector } from '@/components/ui/language-selector'; +import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog'; // TODO 侧边导航栏要加动画 export default function HomeSidebar({ @@ -41,6 +42,7 @@ export default function HomeSidebar({ const { theme, setTheme } = useTheme(); const { t } = useTranslation(); const [popoverOpen, setPopoverOpen] = useState(false); + const [passwordChangeOpen, setPasswordChangeOpen] = useState(false); const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false); useEffect(() => { @@ -245,6 +247,24 @@ export default function HomeSidebar({
{t('common.account')} +
+ ); } diff --git a/web/src/app/home/components/password-change-dialog/PasswordChangeDialog.tsx b/web/src/app/home/components/password-change-dialog/PasswordChangeDialog.tsx new file mode 100644 index 00000000..03a302af --- /dev/null +++ b/web/src/app/home/components/password-change-dialog/PasswordChangeDialog.tsx @@ -0,0 +1,163 @@ +'use client'; + +import * as React from 'react'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +const getFormSchema = (t: (key: string) => string) => + z + .object({ + currentPassword: z + .string() + .min(1, { message: t('common.currentPasswordRequired') }), + newPassword: z + .string() + .min(1, { message: t('common.newPasswordRequired') }), + confirmNewPassword: z + .string() + .min(1, { message: t('common.confirmPasswordRequired') }), + }) + .refine((data) => data.newPassword === data.confirmNewPassword, { + message: t('common.passwordsDoNotMatch'), + path: ['confirmNewPassword'], + }); + +interface PasswordChangeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function PasswordChangeDialog({ + open, + onOpenChange, +}: PasswordChangeDialogProps) { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const formSchema = getFormSchema(t); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmNewPassword: '', + }, + }); + + const onSubmit = async (values: z.infer) => { + setIsSubmitting(true); + try { + await httpClient.changePassword( + values.currentPassword, + values.newPassword, + ); + toast.success(t('common.changePasswordSuccess')); + form.reset(); + onOpenChange(false); + } catch { + toast.error(t('common.changePasswordFailed')); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + {t('common.changePassword')} + +
+ + ( + + {t('common.currentPassword')} + + + + + + )} + /> + ( + + {t('common.newPassword')} + + + + + + )} + /> + ( + + {t('common.confirmNewPassword')} + + + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/web/src/app/infra/http/HttpClient.ts b/web/src/app/infra/http/HttpClient.ts index 9a49c1e3..5e090e99 100644 --- a/web/src/app/infra/http/HttpClient.ts +++ b/web/src/app/infra/http/HttpClient.ts @@ -622,6 +622,16 @@ class HttpClient { new_password: newPassword, }); } + + public changePassword( + currentPassword: string, + newPassword: string, + ): Promise<{ user: string }> { + return this.post('/api/v1/user/change-password', { + current_password: currentPassword, + new_password: newPassword, + }); + } } const getBaseURL = (): string => { diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 477fab8b..7f03b702 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', ghost: - 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/100', link: 'text-primary underline-offset-4 hover:underline', }, size: { diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f0b5c9ba..70635bd4 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -44,6 +44,20 @@ const enUS = { forgotPassword: 'Forgot Password?', loading: 'Loading...', theme: 'Theme', + changePassword: 'Change Password', + currentPassword: 'Current Password', + newPassword: 'New Password', + confirmNewPassword: 'Confirm New Password', + enterCurrentPassword: 'Enter current password', + enterNewPassword: 'Enter new password', + enterConfirmPassword: 'Confirm new password', + currentPasswordRequired: 'Current password is required', + newPasswordRequired: 'New password is required', + confirmPasswordRequired: 'Confirm password is required', + passwordsDoNotMatch: 'Passwords do not match', + changePasswordSuccess: 'Password changed successfully', + changePasswordFailed: + 'Failed to change password, please check your current password', }, notFound: { title: 'Page not found', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 58fb3063..6e286727 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -45,6 +45,20 @@ const jaJP = { forgotPassword: 'パスワードを忘れた?', loading: '読み込み中...', theme: 'テーマ', + changePassword: 'パスワードを変更', + currentPassword: '現在のパスワード', + newPassword: '新しいパスワード', + confirmNewPassword: '新しいパスワードを確認', + enterCurrentPassword: '現在のパスワードを入力', + enterNewPassword: '新しいパスワードを入力', + enterConfirmPassword: '新しいパスワードを確認', + currentPasswordRequired: '現在のパスワードは必須です', + newPasswordRequired: '新しいパスワードは必須です', + confirmPasswordRequired: '新しいパスワードを確認してください', + passwordsDoNotMatch: '新しいパスワードが一致しません', + changePasswordSuccess: 'パスワードの変更に成功しました', + changePasswordFailed: + 'パスワードの変更に失敗しました。現在のパスワードを確認してください', }, notFound: { title: 'ページが見つかりません', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bc42f4b0..256124dc 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -44,6 +44,19 @@ const zhHans = { forgotPassword: '忘记密码?', loading: '加载中...', theme: '主题', + changePassword: '修改密码', + currentPassword: '当前密码', + newPassword: '新密码', + confirmNewPassword: '确认新密码', + enterCurrentPassword: '输入当前密码', + enterNewPassword: '输入新密码', + enterConfirmPassword: '确认新密码', + currentPasswordRequired: '当前密码不能为空', + newPasswordRequired: '新密码不能为空', + confirmPasswordRequired: '确认密码不能为空', + passwordsDoNotMatch: '两次输入的密码不一致', + changePasswordSuccess: '密码修改成功', + changePasswordFailed: '密码修改失败,请检查当前密码是否正确', }, notFound: { title: '页面不存在', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 637a47a2..0c90e1a3 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -44,6 +44,19 @@ const zhHant = { forgotPassword: '忘記密碼?', loading: '載入中...', theme: '主題', + changePassword: '修改密碼', + currentPassword: '當前密碼', + newPassword: '新密碼', + confirmNewPassword: '確認新密碼', + enterCurrentPassword: '輸入當前密碼', + enterNewPassword: '輸入新密碼', + enterConfirmPassword: '確認新密碼', + currentPasswordRequired: '當前密碼不能為空', + newPasswordRequired: '新密碼不能為空', + confirmPasswordRequired: '確認密碼不能為空', + passwordsDoNotMatch: '兩次輸入的密碼不一致', + changePasswordSuccess: '密碼修改成功', + changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確', }, notFound: { title: '頁面不存在',