From dd30d08c68f2b611b0429b331a942941c4d2e150 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 17 Aug 2025 03:03:36 +0000 Subject: [PATCH] feat: add password change functionality - Add password change button to sidebar account menu - Create PasswordChangeDialog component with shadcn UI components - Implement backend API endpoint /api/v1/user/change-password - Add form validation with current password verification - Include internationalization support for Chinese and English - Add proper error handling and success notifications Co-Authored-By: Rock --- pkg/api/http/controller/groups/user.py | 16 ++ pkg/api/http/service/user.py | 15 ++ .../components/home-sidebar/HomeSidebar.tsx | 25 +++ .../PasswordChangeDialog.tsx | 163 ++++++++++++++++++ web/src/app/infra/http/HttpClient.ts | 10 ++ web/src/i18n/locales/en-US.ts | 14 ++ web/src/i18n/locales/zh-Hans.ts | 14 ++ 7 files changed, 257 insertions(+) create mode 100644 web/src/app/home/components/password-change-dialog/PasswordChangeDialog.tsx 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..4ab7eaeb 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/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index f0b5c9ba..44628bc4 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', + currentPasswordIncorrect: 'Current password is incorrect', }, notFound: { title: 'Page not found', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index bc42f4b0..f7a6730e 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -44,6 +44,20 @@ const zhHans = { forgotPassword: '忘记密码?', loading: '加载中...', theme: '主题', + changePassword: '修改密码', + currentPassword: '当前密码', + newPassword: '新密码', + confirmNewPassword: '确认新密码', + enterCurrentPassword: '输入当前密码', + enterNewPassword: '输入新密码', + enterConfirmPassword: '确认新密码', + currentPasswordRequired: '当前密码不能为空', + newPasswordRequired: '新密码不能为空', + confirmPasswordRequired: '确认密码不能为空', + passwordsDoNotMatch: '两次输入的密码不一致', + changePasswordSuccess: '密码修改成功', + changePasswordFailed: '密码修改失败', + currentPasswordIncorrect: '当前密码不正确', }, notFound: { title: '页面不存在',