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 (
+
+ );
+}
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: '页面不存在',