mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 13:00:39 +03:00
F: Rework password change
This commit is contained in:
parent
6583f44209
commit
e3d8c9110a
|
@ -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 (
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: 'Примите политику обработки персональных данных',
|
||||||
|
|
Loading…
Reference in New Issue
Block a user