mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-18 02:16:07 +00:00
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 <rockchinq@gmail.com>
This commit is contained in:
@@ -67,3 +67,19 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
await self.ap.user_service.reset_password(user_email, new_password)
|
await self.ap.user_service.reset_password(user_email, new_password)
|
||||||
|
|
||||||
return self.success(data={'user': user_email})
|
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})
|
||||||
|
|||||||
@@ -82,3 +82,18 @@ class UserService:
|
|||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||||
|
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
|
||||||
|
|
||||||
// TODO 侧边导航栏要加动画
|
// TODO 侧边导航栏要加动画
|
||||||
export default function HomeSidebar({
|
export default function HomeSidebar({
|
||||||
@@ -41,6 +42,7 @@ export default function HomeSidebar({
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
|
||||||
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
|
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -245,6 +247,24 @@ export default function HomeSidebar({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<span className="text-sm font-medium">{t('common.account')}</span>
|
<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 10V8C6 5.79086 7.79086 4 10 4H14C16.2091 4 18 5.79086 18 8V10H20C20.5523 10 21 10.4477 21 11V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V11C3 10.4477 3.44772 10 4 10H6ZM8 10H16V8C16 6.89543 15.1046 6 14 6H10C8.89543 6 8 6.89543 8 8V10ZM11 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.7324Z"></path>
|
||||||
|
</svg>
|
||||||
|
{t('common.changePassword')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start font-normal"
|
className="w-full justify-start font-normal"
|
||||||
@@ -256,6 +276,7 @@ export default function HomeSidebar({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
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>
|
<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>
|
</svg>
|
||||||
@@ -265,6 +286,10 @@ export default function HomeSidebar({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
<PasswordChangeDialog
|
||||||
|
open={passwordChangeOpen}
|
||||||
|
onOpenChange={setPasswordChangeOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -622,6 +622,16 @@ class HttpClient {
|
|||||||
new_password: newPassword,
|
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 => {
|
const getBaseURL = (): string => {
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ const enUS = {
|
|||||||
forgotPassword: 'Forgot Password?',
|
forgotPassword: 'Forgot Password?',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
theme: 'Theme',
|
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: {
|
notFound: {
|
||||||
title: 'Page not found',
|
title: 'Page not found',
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ const zhHans = {
|
|||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
theme: '主题',
|
theme: '主题',
|
||||||
|
changePassword: '修改密码',
|
||||||
|
currentPassword: '当前密码',
|
||||||
|
newPassword: '新密码',
|
||||||
|
confirmNewPassword: '确认新密码',
|
||||||
|
enterCurrentPassword: '输入当前密码',
|
||||||
|
enterNewPassword: '输入新密码',
|
||||||
|
enterConfirmPassword: '确认新密码',
|
||||||
|
currentPasswordRequired: '当前密码不能为空',
|
||||||
|
newPasswordRequired: '新密码不能为空',
|
||||||
|
confirmPasswordRequired: '确认密码不能为空',
|
||||||
|
passwordsDoNotMatch: '两次输入的密码不一致',
|
||||||
|
changePasswordSuccess: '密码修改成功',
|
||||||
|
changePasswordFailed: '密码修改失败',
|
||||||
|
currentPasswordIncorrect: '当前密码不正确',
|
||||||
},
|
},
|
||||||
notFound: {
|
notFound: {
|
||||||
title: '页面不存在',
|
title: '页面不存在',
|
||||||
|
|||||||
Reference in New Issue
Block a user