Merge pull request #1610 from langbot-app/devin/1755399221-add-password-change-feature

feat: add password change functionality
This commit is contained in:
Junyan Qin (Chin)
2025-08-17 14:25:24 +08:00
committed by GitHub
10 changed files with 284 additions and 1 deletions

View File

@@ -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})

View File

@@ -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)
)

View File

@@ -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({
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
setPasswordChangeOpen(true);
setPopoverOpen(false);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M6 8V7C6 3.68629 8.68629 1 12 1C15.3137 1 18 3.68629 18 7V8H20C20.5523 8 21 8.44772 21 9V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V9C3 8.44772 3.44772 8 4 8H6ZM19 10H5V20H19V10ZM11 15.7324C10.4022 15.3866 10 14.7403 10 14C10 12.8954 10.8954 12 12 12C13.1046 12 14 12.8954 14 14C14 14.7403 13.5978 15.3866 13 15.7324V18H11V15.7324ZM8 8H16V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V8Z"></path>
</svg>
{t('common.changePassword')}
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"
@@ -256,6 +276,7 @@ export default function HomeSidebar({
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M4 18H6V20H18V4H6V6H4V3C4 2.44772 4.44772 2 5 2H19C19.5523 2 20 2.44772 20 3V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V18ZM6 11H13V13H6V16L1 12L6 8V11Z"></path>
</svg>
@@ -265,6 +286,10 @@ export default function HomeSidebar({
</PopoverContent>
</Popover>
</div>
<PasswordChangeDialog
open={passwordChangeOpen}
onOpenChange={setPasswordChangeOpen}
/>
</div>
);
}

View File

@@ -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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.changePassword')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.currentPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterCurrentPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.newPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterNewPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.confirmNewPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterConfirmPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 => {

View File

@@ -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: {

View File

@@ -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',

View File

@@ -45,6 +45,20 @@ const jaJP = {
forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...',
theme: 'テーマ',
changePassword: 'パスワードを変更',
currentPassword: '現在のパスワード',
newPassword: '新しいパスワード',
confirmNewPassword: '新しいパスワードを確認',
enterCurrentPassword: '現在のパスワードを入力',
enterNewPassword: '新しいパスワードを入力',
enterConfirmPassword: '新しいパスワードを確認',
currentPasswordRequired: '現在のパスワードは必須です',
newPasswordRequired: '新しいパスワードは必須です',
confirmPasswordRequired: '新しいパスワードを確認してください',
passwordsDoNotMatch: '新しいパスワードが一致しません',
changePasswordSuccess: 'パスワードの変更に成功しました',
changePasswordFailed:
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
},
notFound: {
title: 'ページが見つかりません',

View File

@@ -44,6 +44,19 @@ const zhHans = {
forgotPassword: '忘记密码?',
loading: '加载中...',
theme: '主题',
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
confirmNewPassword: '确认新密码',
enterCurrentPassword: '输入当前密码',
enterNewPassword: '输入新密码',
enterConfirmPassword: '确认新密码',
currentPasswordRequired: '当前密码不能为空',
newPasswordRequired: '新密码不能为空',
confirmPasswordRequired: '确认密码不能为空',
passwordsDoNotMatch: '两次输入的密码不一致',
changePasswordSuccess: '密码修改成功',
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
},
notFound: {
title: '页面不存在',

View File

@@ -44,6 +44,19 @@ const zhHant = {
forgotPassword: '忘記密碼?',
loading: '載入中...',
theme: '主題',
changePassword: '修改密碼',
currentPassword: '當前密碼',
newPassword: '新密碼',
confirmNewPassword: '確認新密碼',
enterCurrentPassword: '輸入當前密碼',
enterNewPassword: '輸入新密碼',
enterConfirmPassword: '確認新密碼',
currentPasswordRequired: '當前密碼不能為空',
newPasswordRequired: '新密碼不能為空',
confirmPasswordRequired: '確認密碼不能為空',
passwordsDoNotMatch: '兩次輸入的密碼不一致',
changePasswordSuccess: '密碼修改成功',
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
},
notFound: {
title: '頁面不存在',