F: Rework password change

This commit is contained in:
Ivan 2025-02-03 18:17:07 +03:00
parent 9aa23aedfb
commit f5419472f5
9 changed files with 89 additions and 91 deletions

View File

@ -11,7 +11,6 @@ interface UserButtonProps {
function UserButton({ onLogin, onClickUser }: UserButtonProps) { function UserButton({ onLogin, onClickUser }: UserButtonProps) {
const { user, isAnonymous } = useAuthSuspense(); const { user, isAnonymous } = useAuthSuspense();
console.log(user);
const adminMode = usePreferencesStore(state => state.adminMode); const adminMode = usePreferencesStore(state => state.adminMode);
if (isAnonymous) { if (isAnonymous) {
return ( return (

View File

@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { z } from 'zod'; import { z } from 'zod';
import { axiosGet, axiosPost } from '@/backend/apiTransport'; import { axiosGet, axiosPatch, axiosPost } from '@/backend/apiTransport';
import { DELAYS } from '@/backend/configuration'; import { DELAYS } from '@/backend/configuration';
import { ICurrentUser } from '@/models/user'; import { ICurrentUser } from '@/models/user';
import { errors, information } from '@/utils/labels'; import { errors, information } from '@/utils/labels';
@ -22,10 +22,25 @@ export type IUserLoginDTO = z.infer<typeof UserLoginSchema>;
/** /**
* Represents data needed to update password for current user. * Represents data needed to update password for current user.
*/ */
export interface IChangePasswordDTO { export const ChangePasswordSchema = z
old_password: string; .object({
new_password: string; old_password: z.string().nonempty(errors.requiredField),
} new_password: z.string().nonempty(errors.requiredField),
new_password2: z.string().nonempty(errors.requiredField)
})
.refine(schema => schema.new_password === schema.new_password2, {
path: ['new_password2'],
message: errors.passwordsMismatch
})
.refine(schema => schema.old_password !== schema.new_password, {
path: ['new_password'],
message: errors.passwordsSame
});
/**
* Represents data needed to update password for current user.
*/
export type IChangePasswordDTO = z.infer<typeof ChangePasswordSchema>;
/** /**
* Represents password reset request data. * Represents password reset request data.
@ -75,7 +90,7 @@ export const authApi = {
request: { data: data } request: { data: data }
}), }),
changePassword: (data: IChangePasswordDTO) => changePassword: (data: IChangePasswordDTO) =>
axiosPost({ axiosPatch({
endpoint: '/users/api/change-password', endpoint: '/users/api/change-password',
request: { request: {
data: data, data: data,

View File

@ -1,5 +1,4 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { authApi, IUserLoginDTO } from './api'; import { authApi, IUserLoginDTO } from './api';
@ -12,8 +11,7 @@ export const useLogin = () => {
onSuccess: () => client.resetQueries() onSuccess: () => client.resetQueries()
}); });
return { return {
login: (data: IUserLoginDTO, onSuccess?: () => void, onError?: (error: AxiosError) => void) => login: (data: IUserLoginDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }),
mutation.mutate(data, { onSuccess, onError }),
isPending: mutation.isPending, isPending: mutation.isPending,
error: mutation.error, error: mutation.error,
reset: mutation.reset reset: mutation.reset

View File

@ -1,17 +1,24 @@
import clsx from 'clsx';
import { FieldError, GlobalError } from 'react-hook-form'; import { FieldError, GlobalError } from 'react-hook-form';
interface ErrorFieldProps { import { CProps } from '../props';
interface ErrorFieldProps extends CProps.Styling {
error?: FieldError | GlobalError; error?: FieldError | GlobalError;
} }
/** /**
* Displays an error message for input field. * Displays an error message for input field.
*/ */
function ErrorField({ error }: ErrorFieldProps) { function ErrorField({ error, className, ...restProps }: ErrorFieldProps): React.ReactElement | null {
if (!error) { if (!error) {
return null; return null;
} }
return <div className='text-sm text-warn-600'>{error.message}</div>; return (
<div className={clsx('text-sm text-warn-600 select-none', className)} {...restProps}>
{error.message}
</div>
);
} }
export default ErrorField; export default ErrorField;

View File

@ -40,7 +40,7 @@ function TextInput({
<div <div
className={clsx( className={clsx(
{ {
'flex flex-col gap-2': !dense, 'flex flex-col': !dense,
'flex items-center gap-3': dense 'flex items-center gap-3': dense
}, },
dense && className dense && className
@ -50,7 +50,7 @@ function TextInput({
<input <input
id={id} id={id}
className={clsx( className={clsx(
'min-w-0 py-2', 'min-w-0 py-2 mt-2',
'leading-tight truncate hover:text-clip', 'leading-tight truncate hover:text-clip',
{ {
'px-3': !noBorder || !disabled, 'px-3': !noBorder || !disabled,
@ -65,7 +65,7 @@ function TextInput({
disabled={disabled} disabled={disabled}
{...restProps} {...restProps}
/> />
<ErrorField error={error} /> <ErrorField className='mt-1' error={error} />
</div> </div>
); );
} }

View File

@ -12,7 +12,6 @@ import { useAuthSuspense } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin'; import { useLogin } from '@/backend/auth/useLogin';
import ExpectedAnonymous from '@/components/ExpectedAnonymous'; import ExpectedAnonymous from '@/components/ExpectedAnonymous';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
import ErrorField from '@/components/ui/ErrorField';
import SubmitButton from '@/components/ui/SubmitButton'; import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput'; import TextInput from '@/components/ui/TextInput';
import TextURL from '@/components/ui/TextURL'; import TextURL from '@/components/ui/TextURL';
@ -28,7 +27,6 @@ function LoginPage() {
register, register,
handleSubmit, handleSubmit,
clearErrors, clearErrors,
setError,
resetField, resetField,
formState: { errors } formState: { errors }
} = useForm({ } = useForm({
@ -37,25 +35,17 @@ function LoginPage() {
}); });
const { isAnonymous } = useAuthSuspense(); const { isAnonymous } = useAuthSuspense();
const { login, isPending, error: loginError, reset } = useLogin(); const { login, isPending, error: serverError, reset } = useLogin();
function onSubmit(data: IUserLoginDTO) { function onSubmit(data: IUserLoginDTO) {
login( login(data, () => {
data,
() => {
resetField('password'); resetField('password');
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
router.push(urls.library); router.push(urls.library);
} }
}, });
error => {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
setError('root', { message: 'На Портале отсутствует такое сочетание имени пользователя и пароля' });
}
}
);
} }
function resetErrors() { function resetErrors() {
@ -99,8 +89,7 @@ function LoginPage() {
<TextURL text='Восстановить пароль...' href='/restore-password' /> <TextURL text='Восстановить пароль...' href='/restore-password' />
<TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' /> <TextURL text='Нет аккаунта? Зарегистрируйтесь...' href='/signup' />
</div> </div>
<ErrorField error={errors.root} /> {serverError ? <ServerError error={serverError} /> : null}
<EscalateError error={loginError} />
</form> </form>
); );
} }
@ -108,13 +97,13 @@ function LoginPage() {
export default LoginPage; export default LoginPage;
// ====== Internals ========= // ====== Internals =========
function EscalateError({ error }: { error: ErrorData }): React.ReactElement | null { function ServerError({ error }: { error: ErrorData }): React.ReactElement | null {
// TODO: rework error escalation mechanism. Probably make it global.
if (!error) {
return null;
}
if (axios.isAxiosError(error) && error.response && error.response.status === 400) { if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return null; return (
<div className='text-sm select-text text-warn-600'>
На Портале отсутствует такое сочетание имени пользователя и пароля
</div>
);
} }
throw error as Error; throw error as Error;
} }

View File

@ -39,8 +39,6 @@ function FormSignup() {
resolver: zodResolver(UserSignupSchema) resolver: zodResolver(UserSignupSchema)
}); });
const isValid = acceptPrivacy && acceptRules;
function resetErrors() { function resetErrors() {
reset(); reset();
clearErrors(); clearErrors();
@ -57,6 +55,7 @@ function FormSignup() {
function onSubmit(data: IUserSignupDTO) { function onSubmit(data: IUserSignupDTO) {
signup(data, createdUser => router.push(urls.login_hint(createdUser.username))); signup(data, createdUser => router.push(urls.login_hint(createdUser.username)));
} }
return ( return (
<form <form
className={clsx('cc-fade-in cc-column', 'mx-auto w-[36rem]', 'px-6 py-3')} className={clsx('cc-fade-in cc-column', 'mx-auto w-[36rem]', 'px-6 py-3')}
@ -149,7 +148,12 @@ function FormSignup() {
</div> </div>
<div className='flex justify-around my-3'> <div className='flex justify-around my-3'>
<SubmitButton text='Регистрировать' className='min-w-[10rem]' loading={isPending || !isValid} /> <SubmitButton
text='Регистрировать'
className='min-w-[10rem]'
loading={isPending}
disabled={!acceptPrivacy || !acceptRules}
/>
<Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div> </div>
{serverError ? <ServerError error={serverError} /> : null} {serverError ? <ServerError error={serverError} /> : null}

View File

@ -1,92 +1,77 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import axios from 'axios'; import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form';
import { toast } from 'react-toastify';
import { useConceptNavigation } from '@/app/Navigation/NavigationContext'; import { useConceptNavigation } from '@/app/Navigation/NavigationContext';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { IChangePasswordDTO } from '@/backend/auth/api'; import { ChangePasswordSchema, IChangePasswordDTO } from '@/backend/auth/api';
import { useChangePassword } from '@/backend/auth/useChangePassword'; import { useChangePassword } from '@/backend/auth/useChangePassword';
import { ErrorData } from '@/components/info/InfoError'; import { ErrorData } from '@/components/info/InfoError';
import FlexColumn from '@/components/ui/FlexColumn'; import FlexColumn from '@/components/ui/FlexColumn';
import SubmitButton from '@/components/ui/SubmitButton'; import SubmitButton from '@/components/ui/SubmitButton';
import TextInput from '@/components/ui/TextInput'; import TextInput from '@/components/ui/TextInput';
import { errors } from '@/utils/labels';
function EditorPassword() { function EditorPassword() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { changePassword, isPending, error, reset } = useChangePassword(); const { changePassword, isPending, error: serverError, reset } = useChangePassword();
const {
register,
handleSubmit,
clearErrors,
formState: { errors }
} = useForm<IChangePasswordDTO>({
resolver: zodResolver(ChangePasswordSchema)
});
const [oldPassword, setOldPassword] = useState(''); function resetErrors() {
const [newPassword, setNewPassword] = useState(''); reset();
const [newPasswordRepeat, setNewPasswordRepeat] = useState(''); clearErrors();
const passwordColor =
!!newPassword && !!newPasswordRepeat && newPassword !== newPasswordRepeat ? 'bg-warn-100' : 'clr-input';
const canSubmit = !!oldPassword && !!newPassword && !!newPasswordRepeat && newPassword === newPasswordRepeat;
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (newPassword !== newPasswordRepeat) {
toast.error(errors.passwordsMismatch);
return;
} }
const data: IChangePasswordDTO = {
old_password: oldPassword, function onSubmit(data: IChangePasswordDTO) {
new_password: newPassword
};
changePassword(data, () => router.push(urls.login)); changePassword(data, () => router.push(urls.login));
} }
useEffect(() => {
reset();
}, [newPassword, oldPassword, newPasswordRepeat, reset]);
return ( return (
<form <form
className={clsx('max-w-[14rem]', 'px-6 py-2 flex flex-col justify-between', 'border-l-2')} className={clsx('max-w-[16rem]', 'px-6 py-2 flex flex-col justify-between', 'border-l-2')}
onSubmit={handleSubmit} onSubmit={event => void handleSubmit(onSubmit)(event)}
onChange={resetErrors}
> >
<FlexColumn> <FlexColumn>
<TextInput <TextInput
id='old_password' id='old_password'
type='password' type='password'
{...register('old_password')}
label='Старый пароль' label='Старый пароль'
autoComplete='current-password' autoComplete='current-password'
allowEnter allowEnter
value={oldPassword} error={errors.old_password}
onChange={event => setOldPassword(event.target.value)}
/> />
<TextInput <TextInput
id='new_password' id='new_password'
type='password' type='password'
{...register('new_password')}
label='Новый пароль' label='Новый пароль'
autoComplete='new-password' autoComplete='new-password'
allowEnter allowEnter
colors={passwordColor} error={errors.new_password}
value={newPassword}
onChange={event => {
setNewPassword(event.target.value);
}}
/> />
<TextInput <TextInput
id='new_password_repeat' id='new_password2'
type='password' type='password'
{...register('new_password2')}
label='Повторите новый' label='Повторите новый'
autoComplete='new-password' autoComplete='new-password'
allowEnter allowEnter
colors={passwordColor} error={errors.new_password2}
value={newPasswordRepeat}
onChange={event => {
setNewPasswordRepeat(event.target.value);
}}
/> />
{error ? <ProcessError error={error} /> : null} {serverError ? <ServerError error={serverError} /> : null}
</FlexColumn> </FlexColumn>
<SubmitButton text='Сменить пароль' className='self-center' disabled={!canSubmit} loading={isPending} /> <SubmitButton text='Сменить пароль' className='self-center mt-2' loading={isPending} />
</form> </form>
); );
} }
@ -94,7 +79,7 @@ function EditorPassword() {
export default EditorPassword; export default EditorPassword;
// ====== Internals ========= // ====== Internals =========
function ProcessError({ error }: { error: ErrorData }): React.ReactElement { function ServerError({ error }: { error: ErrorData }): React.ReactElement {
if (axios.isAxiosError(error) && error.response && error.response.status === 400) { if (axios.isAxiosError(error) && error.response && error.response.status === 400) {
return <div className='text-sm select-text text-warn-600'>Неверно введен старый пароль</div>; return <div className='text-sm select-text text-warn-600'>Неверно введен старый пароль</div>;
} }

View File

@ -982,11 +982,12 @@ export const errors = {
astFailed: 'Невозможно построить дерево разбора', astFailed: 'Невозможно построить дерево разбора',
typeStructureFailed: 'Структура отсутствует', typeStructureFailed: 'Структура отсутствует',
passwordsMismatch: 'Пароли не совпадают', passwordsMismatch: 'Пароли не совпадают',
passwordsSame: 'Пароль совпадает со старым',
imageFailed: 'Ошибка при создании изображения', imageFailed: 'Ошибка при создании изображения',
reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении', reuseOriginal: 'Повторное использование удаляемой конституенты при отождествлении',
substituteInherited: 'Нельзя удалять наследованные конституенты при отождествлении', substituteInherited: 'Нельзя удалять наследованные конституенты при отождествлении',
inputAlreadyExists: 'Концептуальная схема с таким именем уже существует', inputAlreadyExists: 'Концептуальная схема с таким именем уже существует',
requiredField: 'Поле обязательно для заполнения', requiredField: 'Обязательное поле',
emailField: 'Введите корректный адрес электронной почты', emailField: 'Введите корректный адрес электронной почты',
rulesNotAccepted: 'Примите условия пользования Порталом', rulesNotAccepted: 'Примите условия пользования Порталом',
privacyNotAccepted: 'Примите политику обработки персональных данных', privacyNotAccepted: 'Примите политику обработки персональных данных',