F: Implement react-query pt2
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled

This commit is contained in:
Ivan 2025-01-21 20:33:05 +03:00
parent d899e17fcd
commit 76aee5bea7
50 changed files with 460 additions and 620 deletions

View File

@ -6,7 +6,6 @@ import { ErrorBoundary } from 'react-error-boundary';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { queryClient } from '@/backend/queryClient'; import { queryClient } from '@/backend/queryClient';
import { AuthState } from '@/context/AuthContext';
import { GlobalOssState } from '@/context/GlobalOssContext'; import { GlobalOssState } from '@/context/GlobalOssContext';
import { LibraryState } from '@/context/LibraryContext'; import { LibraryState } from '@/context/LibraryContext';
@ -33,7 +32,6 @@ function GlobalProviders({ children }: React.PropsWithChildren) {
> >
<IntlProvider locale='ru' defaultLocale='ru'> <IntlProvider locale='ru' defaultLocale='ru'>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthState>
<LibraryState> <LibraryState>
<GlobalOssState> <GlobalOssState>
@ -42,7 +40,6 @@ function GlobalProviders({ children }: React.PropsWithChildren) {
</GlobalOssState> </GlobalOssState>
</LibraryState> </LibraryState>
</AuthState>
</QueryClientProvider> </QueryClientProvider>
</IntlProvider> </IntlProvider>
</ErrorBoundary>); </ErrorBoundary>);

View File

@ -29,7 +29,8 @@ function Navigation() {
className={clsx( className={clsx(
'z-navigation', // prettier: split lines 'z-navigation', // prettier: split lines
'sticky top-0 left-0 right-0', 'sticky top-0 left-0 right-0',
'select-none' 'select-none',
'bg-prim-100'
)} )}
> >
<ToggleNavigation /> <ToggleNavigation />
@ -57,6 +58,7 @@ function Navigation() {
<NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} /> <NavigationButton text='Новая схема' icon={<IconNewItem2 size='1.5rem' />} onClick={navigateCreateNew} />
<NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} /> <NavigationButton text='Библиотека' icon={<IconLibrary2 size='1.5rem' />} onClick={navigateLibrary} />
<NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} /> <NavigationButton text='Справка' icon={<IconManuals size='1.5rem' />} onClick={navigateHelp} />
<UserMenu /> <UserMenu />
</div> </div>
</div> </div>

View File

@ -0,0 +1,35 @@
import { useAuthSuspense } from '@/backend/auth/useAuth';
import { IconLogin, IconUser2 } from '@/components/Icons';
import { usePreferencesStore } from '@/stores/preferences';
import NavigationButton from './NavigationButton';
interface UserButtonProps {
onLogin: () => void;
onClickUser: () => void;
}
function UserButton({ onLogin, onClickUser }: UserButtonProps) {
const { user } = useAuthSuspense();
const adminMode = usePreferencesStore(state => state.adminMode);
if (!user) {
return (
<NavigationButton
className='cc-fade-in'
title='Перейти на страницу логина'
icon={<IconLogin size='1.5rem' className='icon-primary' />}
onClick={onLogin}
/>
);
} else {
return (
<NavigationButton
className='cc-fade-in'
icon={<IconUser2 size='1.5rem' className={adminMode && user.is_staff ? 'icon-primary' : ''} />}
onClick={onClickUser}
/>
);
}
}
export default UserButton;

View File

@ -1,3 +1,5 @@
import { useAuth } from '@/backend/auth/useAuth';
import { useLogout } from '@/backend/auth/useLogout';
import { import {
IconAdmin, IconAdmin,
IconAdminOff, IconAdminOff,
@ -15,7 +17,6 @@ import {
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton'; import DropdownButton from '@/components/ui/DropdownButton';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
@ -28,7 +29,8 @@ interface UserDropdownProps {
function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) { function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user, logout } = useAuth(); const { user } = useAuth();
const { logout } = useLogout();
const darkMode = usePreferencesStore(state => state.darkMode); const darkMode = usePreferencesStore(state => state.darkMode);
const toggleDarkMode = usePreferencesStore(state => state.toggleDarkMode); const toggleDarkMode = usePreferencesStore(state => state.toggleDarkMode);

View File

@ -1,41 +1,22 @@
import { IconLogin, IconUser2 } from '@/components/Icons'; import { Suspense } from 'react';
import Loader from '@/components/ui/Loader'; import Loader from '@/components/ui/Loader';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import useDropdown from '@/hooks/useDropdown'; import useDropdown from '@/hooks/useDropdown';
import { usePreferencesStore } from '@/stores/preferences';
import { urls } from '../urls'; import { urls } from '../urls';
import NavigationButton from './NavigationButton'; import UserButton from './UserButton';
import UserDropdown from './UserDropdown'; import UserDropdown from './UserDropdown';
function UserMenu() { function UserMenu() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user, loading } = useAuth();
const adminMode = usePreferencesStore(state => state.adminMode);
const menu = useDropdown(); const menu = useDropdown();
const navigateLogin = () => router.push(urls.login);
return ( return (
<div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'> <div ref={menu.ref} className='h-full w-[4rem] flex items-center justify-center'>
{loading ? <Loader circular scale={1.5} /> : null} <Suspense fallback={<Loader circular scale={1.5} />}>
{!user && !loading ? ( <UserButton onLogin={() => router.push(urls.login)} onClickUser={menu.toggle} />
<NavigationButton </Suspense>
className='cc-fade-in' <UserDropdown isOpen={menu.isOpen} hideDropdown={() => menu.hide()} />
title='Перейти на страницу логина'
icon={<IconLogin size='1.5rem' className='icon-primary' />}
onClick={navigateLogin}
/>
) : null}
{user && !loading ? (
<NavigationButton
className='cc-fade-in'
icon={<IconUser2 size='1.5rem' className={adminMode && user.is_staff ? 'icon-primary' : ''} />}
onClick={menu.toggle}
/>
) : null}
<UserDropdown isOpen={!!user && menu.isOpen} hideDropdown={() => menu.hide()} />
</div> </div>
); );
} }

View File

@ -42,17 +42,17 @@ export interface IAxiosRequest<RequestData, ResponseData> {
// ================ Transport API calls ================ // ================ Transport API calls ================
export function AxiosGet<ResponseData>({ endpoint, request, options }: IAxiosRequest<undefined, ResponseData>) { export function AxiosGet<ResponseData>({ endpoint, request, options }: IAxiosRequest<undefined, ResponseData>) {
if (request.setLoading) request.setLoading(true); request.setLoading?.(true);
axiosInstance axiosInstance
.get<ResponseData>(endpoint, options) .get<ResponseData>(endpoint, options)
.then(response => { .then(response => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.onSuccess) request.onSuccess(response.data); request.onSuccess?.(response.data);
}) })
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error)); if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error); request.onError?.(error);
}); });
} }
@ -61,17 +61,17 @@ export function AxiosPost<RequestData, ResponseData>({
request, request,
options options
}: IAxiosRequest<RequestData, ResponseData>) { }: IAxiosRequest<RequestData, ResponseData>) {
if (request.setLoading) request.setLoading(true); request.setLoading?.(true);
axiosInstance axiosInstance
.post<ResponseData>(endpoint, request.data, options) .post<ResponseData>(endpoint, request.data, options)
.then(response => { .then(response => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.onSuccess) request.onSuccess(response.data); request.onSuccess?.(response.data);
}) })
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error)); if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error); request.onError?.(error);
}); });
} }
@ -80,17 +80,17 @@ export function AxiosDelete<RequestData, ResponseData>({
request, request,
options options
}: IAxiosRequest<RequestData, ResponseData>) { }: IAxiosRequest<RequestData, ResponseData>) {
if (request.setLoading) request.setLoading(true); request.setLoading?.(true);
axiosInstance axiosInstance
.delete<ResponseData>(endpoint, options) .delete<ResponseData>(endpoint, options)
.then(response => { .then(response => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.onSuccess) request.onSuccess(response.data); request.onSuccess?.(response.data);
}) })
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error)); if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error); request.onError?.(error);
}); });
} }
@ -99,17 +99,17 @@ export function AxiosPatch<RequestData, ResponseData>({
request, request,
options options
}: IAxiosRequest<RequestData, ResponseData>) { }: IAxiosRequest<RequestData, ResponseData>) {
if (request.setLoading) request.setLoading(true); request.setLoading?.(true);
axiosInstance axiosInstance
.patch<ResponseData>(endpoint, request.data, options) .patch<ResponseData>(endpoint, request.data, options)
.then(response => { .then(response => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.onSuccess) request.onSuccess(response.data); request.onSuccess?.(response.data);
return response.data; return response.data;
}) })
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
if (request.setLoading) request.setLoading(false); request.setLoading?.(false);
if (request.showError) toast.error(extractErrorMessage(error)); if (request.showError) toast.error(extractErrorMessage(error));
if (request.onError) request.onError(error); request.onError?.(error);
}); });
} }

View File

@ -0,0 +1,67 @@
import { queryOptions } from '@tanstack/react-query';
import { ICurrentUser, IUser } from '@/models/user';
import { axiosInstance } from '../apiConfiguration';
/**
* Represents login data, used to authenticate users.
*/
export interface IUserLoginDTO {
username: string;
password: string;
}
/**
* Represents data needed to update password for current user.
*/
export interface IChangePasswordDTO {
old_password: string;
new_password: string;
}
/**
* Represents password reset request data.
*/
export interface IRequestPasswordDTO extends Pick<IUser, 'email'> {}
/**
* Represents password reset data.
*/
export interface IResetPasswordDTO {
password: string;
token: string;
}
/**
* Represents password token data.
*/
export interface IPasswordTokenDTO extends Pick<IResetPasswordDTO, 'token'> {}
/**
* Authentication API.
*/
export const authApi = {
baseKey: 'auth',
getAuthQueryOptions: () => {
return queryOptions({
queryKey: [authApi.baseKey, 'user'],
queryFn: meta =>
axiosInstance
.get<ICurrentUser>('/users/api/auth', {
signal: meta.signal
})
.then(response => (response.data.id === null ? null : response.data)),
placeholderData: null,
staleTime: 24 * 60 * 60 * 1000
});
},
logout: () => axiosInstance.post('/users/api/logout'),
login: (data: IUserLoginDTO) => axiosInstance.post('/users/api/login', data),
changePassword: (data: IChangePasswordDTO) => axiosInstance.post('/users/api/change-password', data),
requestPasswordReset: (data: IRequestPasswordDTO) => axiosInstance.post('/users/api/password-reset', data),
validatePasswordToken: (data: IPasswordTokenDTO) => axiosInstance.post('/users/api/password-reset/validate', data),
resetPassword: (data: IResetPasswordDTO) => axiosInstance.post('/users/api/password-reset/confirm', data)
};

View File

@ -0,0 +1,21 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { authApi } from './api';
export function useAuth() {
const {
data: user,
isLoading,
error
} = useQuery({
...authApi.getAuthQueryOptions()
});
return { user, isLoading, error };
}
export function useAuthSuspense() {
const { data: user } = useSuspenseQuery({
...authApi.getAuthQueryOptions()
});
return { user };
}

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi, IChangePasswordDTO } from './api';
export const useChangePassword = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['change-password'],
mutationFn: authApi.changePassword,
onSettled: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return {
changePassword: (data: IChangePasswordDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from './api';
export const useLogin = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['login'],
mutationFn: authApi.login,
onSettled: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return {
login: (username: string, password: string, onSuccess?: () => void) =>
mutation.mutate({ username, password }, { onSuccess }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from './api';
export const useLogout = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['logout'],
mutationFn: authApi.logout,
onSettled: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return { logout: (onSuccess?: () => void) => mutation.mutate(undefined, { onSuccess }) };
};

View File

@ -0,0 +1,16 @@
import { useMutation } from '@tanstack/react-query';
import { authApi, IRequestPasswordDTO } from './api';
export const useRequestPasswordReset = () => {
const mutation = useMutation({
mutationKey: ['request-password-reset'],
mutationFn: authApi.requestPasswordReset
});
return {
requestPasswordReset: (data: IRequestPasswordDTO, onSuccess?: () => void) => mutation.mutate(data, { onSuccess }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi, IPasswordTokenDTO, IResetPasswordDTO } from './api';
export const useResetPassword = () => {
const queryClient = useQueryClient();
const validateMutation = useMutation({
mutationKey: ['reset-password'],
mutationFn: authApi.validatePasswordToken,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
const resetMutation = useMutation({
mutationKey: ['reset-password'],
mutationFn: authApi.resetPassword,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [authApi.baseKey] })
});
return {
validateToken: (data: IPasswordTokenDTO, onSuccess?: () => void) => validateMutation.mutate(data, { onSuccess }),
resetPassword: (data: IResetPasswordDTO, onSuccess?: () => void) => resetMutation.mutate(data, { onSuccess }),
isPending: resetMutation.isPending,
error: resetMutation.error,
reset: resetMutation.reset
};
};

View File

@ -1,99 +0,0 @@
/**
* Endpoints: users.
*/
import {
ICurrentUser,
IPasswordTokenData,
IRequestPasswordData,
IResetPasswordData,
IUserInfo,
IUserLoginData,
IUserProfile,
IUserSignupData,
IUserUpdateData,
IUserUpdatePassword
} from '@/models/user';
import { AxiosGet, AxiosPatch, AxiosPost, FrontAction, FrontExchange, FrontPull, FrontPush } from './apiTransport';
export function getAuth(request: FrontPull<ICurrentUser>) {
AxiosGet({
endpoint: `/users/api/auth`,
request: request
});
}
export function postLogin(request: FrontPush<IUserLoginData>) {
AxiosPost({
endpoint: '/users/api/login',
request: request
});
}
export function postLogout(request: FrontAction) {
AxiosPost({
endpoint: '/users/api/logout',
request: request
});
}
export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>) {
AxiosPost({
endpoint: '/users/api/signup',
request: request
});
}
export function getProfile(request: FrontPull<IUserProfile>) {
AxiosGet({
endpoint: '/users/api/profile',
request: request
});
}
export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfile>) {
AxiosPatch({
endpoint: '/users/api/profile',
request: request
});
}
export function patchPassword(request: FrontPush<IUserUpdatePassword>) {
AxiosPatch({
endpoint: '/users/api/change-password',
request: request
});
}
export function postRequestPasswordReset(request: FrontPush<IRequestPasswordData>) {
// title: 'Request password reset',
AxiosPost({
endpoint: '/users/api/password-reset',
request: request
});
}
export function postValidatePasswordToken(request: FrontPush<IPasswordTokenData>) {
// title: 'Validate password token',
AxiosPost({
endpoint: '/users/api/password-reset/validate',
request: request
});
}
export function postResetPassword(request: FrontPush<IResetPasswordData>) {
// title: 'Reset password',
AxiosPost({
endpoint: '/users/api/password-reset/confirm',
request: request
});
}
export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
// title: 'Active users list',
AxiosGet({
endpoint: '/users/api/active-users',
request: request
});
}

View File

@ -1,18 +1,40 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { IUserInfo } from '@/models/user'; import { IUser, IUserInfo, IUserProfile, IUserSignupData } from '@/models/user';
import { axiosInstance } from '../apiConfiguration'; import { axiosInstance } from '../apiConfiguration';
/**
* Represents user data, intended to update user profile in persistent storage.
*/
export interface IUpdateProfileDTO extends Omit<IUser, 'is_staff' | 'id'> {}
export const usersApi = { export const usersApi = {
baseKey: 'users', baseKey: 'users',
getUsersQueryOptions: () => { getUsersQueryOptions: () =>
return queryOptions({ queryOptions({
queryKey: [usersApi.baseKey, 'list'], queryKey: [usersApi.baseKey, 'list'],
queryFn: meta => queryFn: meta =>
axiosInstance.get<IUserInfo[]>(`/users/api/active-users`, { axiosInstance
.get<IUserInfo[]>('/users/api/active-users', {
signal: meta.signal signal: meta.signal
}) })
}); .then(response => response.data),
} placeholderData: []
}),
getProfileQueryOptions: () =>
queryOptions({
queryKey: [usersApi.baseKey, 'profile'],
queryFn: meta =>
axiosInstance
.get<IUserProfile>('/users/api/profile', {
signal: meta.signal
})
.then(response => response.data)
}),
signup: (data: IUserSignupData) => axiosInstance.post('/users/api/signup', data),
updateProfile: (data: IUpdateProfileDTO) => axiosInstance.patch('/users/api/profile', data)
}; };
//DataCallback<IUserProfile>

View File

@ -0,0 +1,21 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { usersApi } from './api';
export function useProfile() {
const {
data: profile,
isLoading,
error
} = useQuery({
...usersApi.getProfileQueryOptions()
});
return { profile, isLoading, error };
}
export function useProfileSuspense() {
const { data: profile } = useSuspenseQuery({
...usersApi.getProfileQueryOptions()
});
return { profile };
}

View File

@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '@/backend/users/api';
import { IUserProfile, IUserSignupData } from '@/models/user';
import { DataCallback } from '../apiTransport';
export const useSignup = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['signup'],
mutationFn: usersApi.signup,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [usersApi.baseKey] })
});
return {
signup: (data: IUserSignupData, onSuccess?: DataCallback<IUserProfile>) =>
mutation.mutate(data, { onSuccess: response => onSuccess?.(response.data as IUserProfile) }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { IUserProfile } from '@/models/user';
import { IUpdateProfileDTO, usersApi } from './api';
// TODO: reload users / optimistic update
export const useUpdateProfile = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationKey: ['update-profile'],
mutationFn: usersApi.updateProfile,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [usersApi.baseKey] })
});
return {
updateProfile: (data: IUpdateProfileDTO, onSuccess?: (newUser: IUserProfile) => void) =>
mutation.mutate(data, { onSuccess: response => onSuccess?.(response.data as IUserProfile) }),
isPending: mutation.isPending,
error: mutation.error,
reset: mutation.reset
};
};

View File

@ -3,17 +3,16 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { usersApi } from './api'; import { usersApi } from './api';
export function useUsersSuspense() { export function useUsersSuspense() {
const { data: users, refetch } = useSuspenseQuery({ const { data: users } = useSuspenseQuery({
...usersApi.getUsersQueryOptions() ...usersApi.getUsersQueryOptions(),
initialData: []
}); });
return { users };
return { users: users?.data ?? [], refetch };
} }
export function useUsers() { export function useUsers() {
const { data: users, refetch } = useQuery({ const { data: users } = useQuery({
...usersApi.getUsersQueryOptions() ...usersApi.getUsersQueryOptions()
}); });
return { users: users ?? [] };
return { users: users?.data ?? [], refetch };
} }

View File

@ -29,7 +29,7 @@ function SubmitButton({ text = 'ОК', icon, disabled, loading, className, ...re
loading && 'cursor-progress', loading && 'cursor-progress',
className className
)} )}
disabled={disabled ?? loading} disabled={disabled || loading}
{...restProps} {...restProps}
> >
{icon ? <span>{icon}</span> : null} {icon ? <span>{icon}</span> : null}

View File

@ -1,11 +1,13 @@
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/backend/auth/useAuth';
import { useLogout } from '@/backend/auth/useLogout';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import TextURL from '../ui/TextURL'; import TextURL from '../ui/TextURL';
function ExpectedAnonymous() { function ExpectedAnonymous() {
const { user, logout } = useAuth(); const { user } = useAuth();
const { logout } = useLogout();
const router = useConceptNavigation(); const router = useConceptNavigation();
function logoutAndRedirect() { function logoutAndRedirect() {

View File

@ -1,14 +1,14 @@
'use client'; 'use client';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/backend/auth/useAuth';
import Loader from '../ui/Loader'; import Loader from '../ui/Loader';
import TextURL from '../ui/TextURL'; import TextURL from '../ui/TextURL';
function RequireAuth({ children }: React.PropsWithChildren) { function RequireAuth({ children }: React.PropsWithChildren) {
const { user, loading } = useAuth(); const { user, isLoading } = useAuth();
if (loading) { if (isLoading) {
return <Loader key='auth-loader' />; return <Loader key='auth-loader' />;
} }
if (user) { if (user) {

View File

@ -1,204 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import {
getAuth,
patchPassword,
postLogin,
postLogout,
postRequestPasswordReset,
postResetPassword,
postSignup,
postValidatePasswordToken
} from '@/backend/users';
import { type ErrorData } from '@/components/info/InfoError';
import {
ICurrentUser,
IPasswordTokenData,
IRequestPasswordData,
IResetPasswordData,
IUserLoginData,
IUserProfile,
IUserSignupData,
IUserUpdatePassword
} from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
interface IAuthContext {
user: ICurrentUser | undefined;
login: (data: IUserLoginData, callback?: DataCallback) => void;
logout: (callback?: DataCallback) => void;
signup: (data: IUserSignupData, callback?: DataCallback<IUserProfile>) => void;
updatePassword: (data: IUserUpdatePassword, callback?: () => void) => void;
requestPasswordReset: (data: IRequestPasswordData, callback?: () => void) => void;
validateToken: (data: IPasswordTokenData, callback?: () => void) => void;
resetPassword: (data: IResetPasswordData, callback?: () => void) => void;
loading: boolean;
error: ErrorData;
setError: (error: ErrorData) => void;
}
const AuthContext = createContext<IAuthContext | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error(contextOutsideScope('useAuth', 'AuthState'));
}
return context;
};
export const AuthState = ({ children }: React.PropsWithChildren) => {
const [user, setUser] = useState<ICurrentUser | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<ErrorData>(undefined);
const reload = useCallback(
(callback?: () => void) => {
getAuth({
onError: () => setUser(undefined),
setLoading: setLoading,
onSuccess: currentUser => {
if (currentUser.id) {
setUser(currentUser);
} else {
setUser(undefined);
}
callback?.();
}
});
},
[setUser]
);
function login(data: IUserLoginData, callback?: DataCallback) {
setError(undefined);
postLogin({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: newData =>
reload(() => {
callback?.(newData);
})
});
}
function logout(callback?: DataCallback) {
setError(undefined);
postLogout({
showError: true,
onSuccess: newData =>
reload(() => {
callback?.(newData);
})
});
}
function signup(data: IUserSignupData, callback?: DataCallback<IUserProfile>) {
setError(undefined);
postSignup({
data: data,
showError: true,
setLoading: setLoading,
onError: setError,
onSuccess: newData =>
reload(() => {
// TODO: reload users / optimistic update
// users.push(newData as IUserInfo);
callback?.(newData);
})
});
}
const updatePassword = useCallback(
(data: IUserUpdatePassword, callback?: () => void) => {
setError(undefined);
patchPassword({
data: data,
showError: true,
setLoading: setLoading,
onError: setError,
onSuccess: () => reload(callback)
});
},
[reload]
);
const requestPasswordReset = useCallback(
(data: IRequestPasswordData, callback?: () => void) => {
setError(undefined);
postRequestPasswordReset({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
callback?.();
})
});
},
[reload]
);
const validateToken = useCallback(
(data: IPasswordTokenData, callback?: () => void) => {
setError(undefined);
postValidatePasswordToken({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
callback?.();
})
});
},
[reload]
);
const resetPassword = useCallback(
(data: IResetPasswordData, callback?: () => void) => {
setError(undefined);
postResetPassword({
data: data,
showError: false,
setLoading: setLoading,
onError: setError,
onSuccess: () =>
reload(() => {
callback?.();
})
});
},
[reload]
);
useEffect(() => {
reload();
}, [reload]);
return (
<AuthContext
value={{
user,
login,
logout,
signup,
loading,
error,
setError,
updatePassword,
requestPasswordReset,
validateToken,
resetPassword
}}
>
{children}
</AuthContext>
);
};

View File

@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { useAuth } from '@/backend/auth/useAuth';
import { import {
deleteLibraryItem, deleteLibraryItem,
getAdminLibrary, getAdminLibrary,
@ -24,8 +25,6 @@ import { RSFormLoader } from '@/models/RSFormLoader';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { contextOutsideScope } from '@/utils/labels'; import { contextOutsideScope } from '@/utils/labels';
import { useAuth } from './AuthContext';
interface ILibraryContext { interface ILibraryContext {
items: ILibraryItem[]; items: ILibraryItem[];
templates: ILibraryItem[]; templates: ILibraryItem[];
@ -62,7 +61,7 @@ export const useLibrary = (): ILibraryContext => {
}; };
export const LibraryState = ({ children }: React.PropsWithChildren) => { export const LibraryState = ({ children }: React.PropsWithChildren) => {
const { user, loading: userLoading } = useAuth(); const { user, isLoading: userLoading } = useAuth();
const adminMode = usePreferencesStore(state => state.adminMode); const adminMode = usePreferencesStore(state => state.adminMode);
const [items, setItems] = useState<ILibraryItem[]>([]); const [items, setItems] = useState<ILibraryItem[]>([]);

View File

@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { useAuth } from '@/backend/auth/useAuth';
import { import {
patchLibraryItem, patchLibraryItem,
patchSetAccessPolicy, patchSetAccessPolicy,
@ -38,7 +39,6 @@ import {
import { UserID } from '@/models/user'; import { UserID } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels'; import { contextOutsideScope } from '@/utils/labels';
import { useAuth } from './AuthContext';
import { useGlobalOss } from './GlobalOssContext'; import { useGlobalOss } from './GlobalOssContext';
import { useLibrary } from './LibraryContext'; import { useLibrary } from './LibraryContext';

View File

@ -3,6 +3,7 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport'; import { DataCallback } from '@/backend/apiTransport';
import { useAuth } from '@/backend/auth/useAuth';
import { import {
patchLibraryItem, patchLibraryItem,
patchSetAccessPolicy, patchSetAccessPolicy,
@ -50,7 +51,6 @@ import {
import { UserID } from '@/models/user'; import { UserID } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels'; import { contextOutsideScope } from '@/utils/labels';
import { useAuth } from './AuthContext';
import { useGlobalOss } from './GlobalOssContext'; import { useGlobalOss } from './GlobalOssContext';
import { useLibrary } from './LibraryContext'; import { useLibrary } from './LibraryContext';

View File

@ -1,76 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { DataCallback } from '@/backend/apiTransport';
import { getProfile, patchProfile } from '@/backend/users';
import { ErrorData } from '@/components/info/InfoError';
import { IUserProfile, IUserUpdateData } from '@/models/user';
import { contextOutsideScope } from '@/utils/labels';
interface IUserProfileContext {
user: IUserProfile | undefined;
loading: boolean;
processing: boolean;
error: ErrorData;
errorProcessing: ErrorData;
setError: (error: ErrorData) => void;
updateUser: (data: IUserUpdateData, callback?: DataCallback<IUserProfile>) => void;
}
const ProfileContext = createContext<IUserProfileContext | null>(null);
export const useUserProfile = () => {
const context = useContext(ProfileContext);
if (!context) {
throw new Error(contextOutsideScope('useUserProfile', 'UserProfileState'));
}
return context;
};
export const UserProfileState = ({ children }: React.PropsWithChildren) => {
const [user, setUser] = useState<IUserProfile | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<ErrorData>(undefined);
const [errorProcessing, setErrorProcessing] = useState<ErrorData>(undefined);
const reload = useCallback(() => {
setError(undefined);
setUser(undefined);
getProfile({
showError: true,
setLoading: setLoading,
onError: setError,
onSuccess: newData => setUser(newData)
});
}, [setUser]);
const updateUser = useCallback(
(data: IUserUpdateData, callback?: DataCallback<IUserProfile>) => {
setErrorProcessing(undefined);
patchProfile({
data: data,
showError: true,
setLoading: setProcessing,
onError: setErrorProcessing,
onSuccess: newData => {
setUser(newData);
// TODO: reload users / optimistic update
callback?.(newData);
}
});
},
[setUser]
);
useEffect(() => {
reload();
}, [reload]);
return (
<ProfileContext value={{ user, updateUser, error, loading, setError, processing, errorProcessing }}>
{children}
</ProfileContext>
);
};

View File

@ -3,12 +3,12 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '@/backend/auth/useAuth';
import SelectLocationContext from '@/components/select/SelectLocationContext'; import SelectLocationContext from '@/components/select/SelectLocationContext';
import SelectLocationHead from '@/components/select/SelectLocationHead'; import SelectLocationHead from '@/components/select/SelectLocationHead';
import Label from '@/components/ui/Label'; import Label from '@/components/ui/Label';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea'; import TextArea from '@/components/ui/TextArea';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { LocationHead } from '@/models/library'; import { LocationHead } from '@/models/library';
import { combineLocation, validateLocation } from '@/models/libraryAPI'; import { combineLocation, validateLocation } from '@/models/libraryAPI';

View File

@ -5,6 +5,7 @@ import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { VisibilityIcon } from '@/components/DomainIcons'; import { VisibilityIcon } from '@/components/DomainIcons';
import SelectAccessPolicy from '@/components/select/SelectAccessPolicy'; import SelectAccessPolicy from '@/components/select/SelectAccessPolicy';
import SelectLocationContext from '@/components/select/SelectLocationContext'; import SelectLocationContext from '@/components/select/SelectLocationContext';
@ -15,7 +16,6 @@ import MiniButton from '@/components/ui/MiniButton';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import TextArea from '@/components/ui/TextArea'; import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput'; import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { AccessPolicy, ILibraryItem, LocationHead } from '@/models/library'; import { AccessPolicy, ILibraryItem, LocationHead } from '@/models/library';

View File

@ -2,15 +2,15 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/backend/auth/useAuth';
import { getOssDetails } from '@/backend/oss'; import { getOssDetails } from '@/backend/oss';
import { type ErrorData } from '@/components/info/InfoError'; import { type ErrorData } from '@/components/info/InfoError';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { IOperationSchema, IOperationSchemaData } from '@/models/oss'; import { IOperationSchema, IOperationSchemaData } from '@/models/oss';
import { OssLoader } from '@/models/OssLoader'; import { OssLoader } from '@/models/OssLoader';
function useOssDetails({ target }: { target?: string }) { function useOssDetails({ target }: { target?: string }) {
const { loading: userLoading } = useAuth(); const { isLoading: userLoading } = useAuth();
const library = useLibrary(); const library = useLibrary();
const [schema, setInner] = useState<IOperationSchema | undefined>(undefined); const [schema, setInner] = useState<IOperationSchema | undefined>(undefined);
const [loading, setLoading] = useState(target != undefined); const [loading, setLoading] = useState(target != undefined);

View File

@ -2,14 +2,14 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/backend/auth/useAuth';
import { getRSFormDetails } from '@/backend/rsforms'; import { getRSFormDetails } from '@/backend/rsforms';
import { type ErrorData } from '@/components/info/InfoError'; import { type ErrorData } from '@/components/info/InfoError';
import { useAuth } from '@/context/AuthContext';
import { IRSForm, IRSFormData } from '@/models/rsform'; import { IRSForm, IRSFormData } from '@/models/rsform';
import { RSFormLoader } from '@/models/RSFormLoader'; import { RSFormLoader } from '@/models/RSFormLoader';
function useRSFormDetails({ target, version }: { target?: string; version?: string }) { function useRSFormDetails({ target, version }: { target?: string; version?: string }) {
const { loading: userLoading } = useAuth(); const { isLoading: userLoading } = useAuth();
const [schema, setInnerSchema] = useState<IRSForm | undefined>(undefined); const [schema, setInnerSchema] = useState<IRSForm | undefined>(undefined);
const [loading, setLoading] = useState(target != undefined); const [loading, setLoading] = useState(target != undefined);
const [error, setError] = useState<ErrorData>(undefined); const [error, setError] = useState<ErrorData>(undefined);

View File

@ -26,35 +26,9 @@ export interface IUser {
* Represents CurrentUser information. * Represents CurrentUser information.
*/ */
export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> { export interface ICurrentUser extends Pick<IUser, 'id' | 'username' | 'is_staff'> {
subscriptions: LibraryItemID[];
editor: LibraryItemID[]; editor: LibraryItemID[];
} }
/**
* Represents login data, used to authenticate users.
*/
export interface IUserLoginData extends Pick<IUser, 'username'> {
password: string;
}
/**
* Represents password reset data.
*/
export interface IResetPasswordData {
password: string;
token: string;
}
/**
* Represents password token data.
*/
export interface IPasswordTokenData extends Pick<IResetPasswordData, 'token'> {}
/**
* Represents password reset request data.
*/
export interface IRequestPasswordData extends Pick<IUser, 'email'> {}
/** /**
* Represents signup data, used to create new users. * Represents signup data, used to create new users.
*/ */
@ -63,11 +37,6 @@ export interface IUserSignupData extends Omit<IUser, 'is_staff' | 'id'> {
password2: string; password2: string;
} }
/**
* Represents user data, intended to update user profile in persistent storage.
*/
export interface IUserUpdateData extends Omit<IUser, 'is_staff' | 'id'> {}
/** /**
* Represents user profile for viewing and editing {@link IUser}. * Represents user profile for viewing and editing {@link IUser}.
*/ */
@ -78,14 +47,6 @@ export interface IUserProfile extends Omit<IUser, 'is_staff'> {}
*/ */
export interface IUserInfo extends Omit<IUserProfile, 'email' | 'username'> {} export interface IUserInfo extends Omit<IUserProfile, 'email' | 'username'> {}
/**
* Represents data needed to update password for current user.
*/
export interface IUserUpdatePassword {
old_password: string;
new_password: string;
}
/** /**
* Represents target {@link User}. * Represents target {@link User}.
*/ */

View File

@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { VisibilityIcon } from '@/components/DomainIcons'; import { VisibilityIcon } from '@/components/DomainIcons';
import { IconDownload } from '@/components/Icons'; import { IconDownload } from '@/components/Icons';
import InfoError from '@/components/info/InfoError'; import InfoError from '@/components/info/InfoError';
@ -19,7 +20,6 @@ import Overlay from '@/components/ui/Overlay';
import SubmitButton from '@/components/ui/SubmitButton'; import SubmitButton from '@/components/ui/SubmitButton';
import TextArea from '@/components/ui/TextArea'; import TextArea from '@/components/ui/TextArea';
import TextInput from '@/components/ui/TextInput'; import TextInput from '@/components/ui/TextInput';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library'; import { AccessPolicy, LibraryItemType, LocationHead } from '@/models/library';

View File

@ -1,17 +1,17 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import Loader from '@/components/ui/Loader'; import Loader from '@/components/ui/Loader';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
function HomePage() { function HomePage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user, loading } = useAuth(); const { user, isLoading } = useAuth();
useEffect(() => { useEffect(() => {
if (!loading) { if (!isLoading) {
if (!user) { if (!user) {
setTimeout(() => { setTimeout(() => {
router.replace(urls.manuals); router.replace(urls.manuals);
@ -22,7 +22,7 @@ function HomePage() {
}, PARAMETER.refreshTimeout); }, PARAMETER.refreshTimeout);
} }
} }
}, [router, user, loading]); }, [router, user, isLoading]);
return <Loader />; return <Loader />;
} }

View File

@ -1,13 +1,13 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useAuth } from '@/backend/auth/useAuth';
import { SubfoldersIcon } from '@/components/DomainIcons'; import { SubfoldersIcon } from '@/components/DomainIcons';
import { IconFolderEdit, IconFolderTree } from '@/components/Icons'; import { IconFolderEdit, IconFolderTree } from '@/components/Icons';
import BadgeHelp from '@/components/info/BadgeHelp'; import BadgeHelp from '@/components/info/BadgeHelp';
import { CProps } from '@/components/props'; import { CProps } from '@/components/props';
import SelectLocation from '@/components/select/SelectLocation'; import SelectLocation from '@/components/select/SelectLocation';
import MiniButton from '@/components/ui/MiniButton'; import MiniButton from '@/components/ui/MiniButton';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import useWindowSize from '@/hooks/useWindowSize'; import useWindowSize from '@/hooks/useWindowSize';
import { FolderNode, FolderTree } from '@/models/FolderTree'; import { FolderNode, FolderTree } from '@/models/FolderTree';

View File

@ -5,15 +5,15 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { useLogin } from '@/backend/auth/useLogin';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { ErrorData } from '@/components/info/InfoError';
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';
import ExpectedAnonymous from '@/components/wrap/ExpectedAnonymous'; import ExpectedAnonymous from '@/components/wrap/ExpectedAnonymous';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import useQueryStrings from '@/hooks/useQueryStrings'; import useQueryStrings from '@/hooks/useQueryStrings';
import { IUserLoginData } from '@/models/user';
import { resources } from '@/utils/constants'; import { resources } from '@/utils/constants';
function LoginPage() { function LoginPage() {
@ -21,23 +21,20 @@ function LoginPage() {
const query = useQueryStrings(); const query = useQueryStrings();
const userQuery = query.get('username'); const userQuery = query.get('username');
const { user, login, loading, error, setError } = useAuth(); const { user } = useAuth();
const { login, isPending, error, reset } = useLogin();
const [username, setUsername] = useState(userQuery || ''); const [username, setUsername] = useState(userQuery || '');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
useEffect(() => { useEffect(() => {
setError(undefined); reset();
}, [username, password, setError]); }, [username, password, reset]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!loading) { if (!isPending) {
const data: IUserLoginData = { login(username, password, () => {
username: username,
password: password
};
login(data, () => {
if (router.canBack()) { if (router.canBack()) {
router.back(); router.back();
} else { } else {
@ -78,7 +75,7 @@ function LoginPage() {
<SubmitButton <SubmitButton
text='Войти' text='Войти'
className='self-center w-[12rem] mt-3' className='self-center w-[12rem] mt-3'
loading={loading} loading={isPending}
disabled={!username || !password} disabled={!username || !password}
/> />
<div className='flex flex-col text-sm'> <div className='flex flex-col text-sm'>

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { import {
IconAdmin, IconAdmin,
IconAlert, IconAlert,
@ -19,7 +20,6 @@ import Button from '@/components/ui/Button';
import Divider from '@/components/ui/Divider'; import Divider from '@/components/ui/Divider';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton'; import DropdownButton from '@/components/ui/DropdownButton';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';
import useDropdown from '@/hooks/useDropdown'; import useDropdown from '@/hooks/useDropdown';

View File

@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/backend/auth/useAuth';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';

View File

@ -7,12 +7,12 @@ import { TabList, TabPanel, Tabs } from 'react-tabs';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { ErrorData } from '@/components/info/InfoError';
import Loader from '@/components/ui/Loader'; import Loader from '@/components/ui/Loader';
import Overlay from '@/components/ui/Overlay'; import Overlay from '@/components/ui/Overlay';
import TabLabel from '@/components/ui/TabLabel'; import TabLabel from '@/components/ui/TabLabel';
import TextURL from '@/components/ui/TextURL'; import TextURL from '@/components/ui/TextURL';
import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
import { useBlockNavigation, useConceptNavigation } from '@/context/NavigationContext'; import { useBlockNavigation, useConceptNavigation } from '@/context/NavigationContext';
import { useOSS } from '@/context/OssContext'; import { useOSS } from '@/context/OssContext';
@ -46,7 +46,7 @@ function OssTabs() {
useBlockNavigation( useBlockNavigation(
isModified && isModified &&
schema !== undefined && schema !== undefined &&
user !== undefined && !!user &&
(user.is_staff || user.id == schema.owner || schema.editors.includes(user.id)) (user.is_staff || user.id == schema.owner || schema.editors.includes(user.id))
); );

View File

@ -5,20 +5,20 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { IPasswordTokenDTO, IResetPasswordDTO } from '@/backend/auth/api';
import { useResetPassword } from '@/backend/auth/useResetPassword';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { ErrorData } from '@/components/info/InfoError';
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 DataLoader from '@/components/wrap/DataLoader'; import DataLoader from '@/components/wrap/DataLoader';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import useQueryStrings from '@/hooks/useQueryStrings'; import useQueryStrings from '@/hooks/useQueryStrings';
import { IPasswordTokenData, IResetPasswordData } from '@/models/user';
function PasswordChangePage() { function PasswordChangePage() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const token = useQueryStrings().get('token'); const token = useQueryStrings().get('token');
const { validateToken, resetPassword, loading, error, setError } = useAuth(); const { validateToken, resetPassword, isPending, error, reset } = useResetPassword();
const [isTokenValid, setIsTokenValid] = useState(false); const [isTokenValid, setIsTokenValid] = useState(false);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@ -31,8 +31,8 @@ function PasswordChangePage() {
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!loading) { if (!isPending) {
const data: IResetPasswordData = { const data: IResetPasswordDTO = {
password: newPassword, password: newPassword,
token: token! token: token!
}; };
@ -44,21 +44,18 @@ function PasswordChangePage() {
} }
useEffect(() => { useEffect(() => {
setError(undefined); reset();
}, [newPassword, newPasswordRepeat, setError]); }, [newPassword, newPasswordRepeat, reset]);
useEffect(() => { useEffect(() => {
const data: IPasswordTokenData = { const data: IPasswordTokenDTO = {
token: token ?? '' token: token ?? ''
}; };
validateToken(data, () => setIsTokenValid(true)); validateToken(data, () => setIsTokenValid(true));
}, [token, validateToken]); }, [token, validateToken]);
if (error) {
return <ProcessError error={error} />;
}
return ( return (
<DataLoader isLoading={loading} hasNoData={!isTokenValid}> <DataLoader isLoading={isPending} hasNoData={!isTokenValid}>
<form className={clsx('cc-fade-in cc-column', 'w-[24rem] mx-auto', 'px-6 mt-3')} onSubmit={handleSubmit}> <form className={clsx('cc-fade-in cc-column', 'w-[24rem] mx-auto', 'px-6 mt-3')} onSubmit={handleSubmit}>
<TextInput <TextInput
id='new_password' id='new_password'
@ -88,7 +85,7 @@ function PasswordChangePage() {
<SubmitButton <SubmitButton
text='Установить пароль' text='Установить пароль'
className='self-center w-[12rem] mt-3' className='self-center w-[12rem] mt-3'
loading={loading} loading={isPending}
disabled={!canSubmit} disabled={!canSubmit}
/> />
{error ? <ProcessError error={error} /> : null} {error ? <ProcessError error={error} /> : null}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/backend/auth/useAuth';
import { import {
IconAdmin, IconAdmin,
IconAlert, IconAlert,
@ -31,7 +32,6 @@ import Button from '@/components/ui/Button';
import Divider from '@/components/ui/Divider'; import Divider from '@/components/ui/Divider';
import Dropdown from '@/components/ui/Dropdown'; import Dropdown from '@/components/ui/Dropdown';
import DropdownButton from '@/components/ui/DropdownButton'; import DropdownButton from '@/components/ui/DropdownButton';
import { useAuth } from '@/context/AuthContext';
import { useGlobalOss } from '@/context/GlobalOssContext'; import { useGlobalOss } from '@/context/GlobalOssContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { useRSForm } from '@/context/RSFormContext'; import { useRSForm } from '@/context/RSFormContext';

View File

@ -5,7 +5,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/backend/auth/useAuth';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { useRSForm } from '@/context/RSFormContext'; import { useRSForm } from '@/context/RSFormContext';
import { import {

View File

@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { useSignup } from '@/backend/users/useSignup';
import { IconHelp } from '@/components/Icons'; import { IconHelp } from '@/components/Icons';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { ErrorData } from '@/components/info/InfoError';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
@ -17,15 +18,15 @@ 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';
import Tooltip from '@/components/ui/Tooltip'; import Tooltip from '@/components/ui/Tooltip';
import { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { HelpTopic } from '@/models/miscellaneous'; import { HelpTopic } from '@/models/miscellaneous';
import { IUserSignupData } from '@/models/user'; import { IUserSignupData } from '@/models/user';
import { globals, patterns } from '@/utils/constants'; import { globals, patterns } from '@/utils/constants';
import { information } from '@/utils/labels';
function FormSignup() { function FormSignup() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { signup, loading, error, setError } = useAuth(); const { signup, isPending, error, reset } = useSignup();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -40,8 +41,8 @@ function FormSignup() {
const isValid = acceptPrivacy && acceptRules && !!email && !!username; const isValid = acceptPrivacy && acceptRules && !!email && !!username;
useEffect(() => { useEffect(() => {
setError(undefined); reset();
}, [username, email, password, password2, setError]); }, [username, email, password, password2, reset]);
function handleCancel() { function handleCancel() {
if (router.canBack()) { if (router.canBack()) {
@ -53,7 +54,7 @@ function FormSignup() {
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!loading) { if (!isPending) {
const data: IUserSignupData = { const data: IUserSignupData = {
username, username,
email, email,
@ -64,7 +65,7 @@ function FormSignup() {
}; };
signup(data, createdUser => { signup(data, createdUser => {
router.push(urls.login_hint(createdUser.username)); router.push(urls.login_hint(createdUser.username));
toast.success(`Пользователь успешно создан: ${createdUser.username}`); toast.success(information.newUser(createdUser.username));
}); });
} }
} }
@ -159,7 +160,7 @@ 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={loading} disabled={!isValid} /> <SubmitButton text='Регистрировать' className='min-w-[10rem]' loading={isPending} disabled={!isValid} />
<Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} /> <Button text='Назад' className='min-w-[10rem]' onClick={() => handleCancel()} />
</div> </div>
{error ? <ProcessError error={error} /> : null} {error ? <ProcessError error={error} /> : null}

View File

@ -1,13 +1,13 @@
import { useAuth } from '@/backend/auth/useAuth';
import Loader from '@/components/ui/Loader'; import Loader from '@/components/ui/Loader';
import ExpectedAnonymous from '@/components/wrap/ExpectedAnonymous'; import ExpectedAnonymous from '@/components/wrap/ExpectedAnonymous';
import { useAuth } from '@/context/AuthContext';
import FormSignup from './FormSignup'; import FormSignup from './FormSignup';
function RegisterPage() { function RegisterPage() {
const { user, loading } = useAuth(); const { user, isLoading } = useAuth();
if (loading) { if (isLoading) {
return <Loader />; return <Loader />;
} }
if (user) { if (user) {

View File

@ -4,32 +4,28 @@ import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRequestPasswordReset } from '@/backend/auth/useRequestPasswordReset';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { ErrorData } from '@/components/info/InfoError';
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';
import { useAuth } from '@/context/AuthContext';
import { IRequestPasswordData } from '@/models/user';
function RestorePasswordPage() { function RestorePasswordPage() {
const { requestPasswordReset, loading, error, setError } = useAuth(); const { requestPasswordReset, isPending, error, reset } = useRequestPasswordReset();
const [isCompleted, setIsCompleted] = useState(false); const [isCompleted, setIsCompleted] = useState(false);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!loading) { if (!isPending) {
const data: IRequestPasswordData = { requestPasswordReset({ email: email }, () => setIsCompleted(true));
email: email
};
requestPasswordReset(data, () => setIsCompleted(true));
} }
} }
useEffect(() => { useEffect(() => {
setError(undefined); reset();
}, [email, setError]); }, [email, reset]);
if (isCompleted) { if (isCompleted) {
return ( return (
@ -55,7 +51,7 @@ function RestorePasswordPage() {
<SubmitButton <SubmitButton
text='Запросить пароль' text='Запросить пароль'
className='self-center w-[12rem] mt-3' className='self-center w-[12rem] mt-3'
loading={loading} loading={isPending}
disabled={!email} disabled={!email}
/> />
{error ? <ProcessError error={error} /> : null} {error ? <ProcessError error={error} /> : null}

View File

@ -6,18 +6,18 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { urls } from '@/app/urls'; import { urls } from '@/app/urls';
import { IChangePasswordDTO } from '@/backend/auth/api';
import { useChangePassword } from '@/backend/auth/useChangePassword';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { 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 { useAuth } from '@/context/AuthContext';
import { useConceptNavigation } from '@/context/NavigationContext'; import { useConceptNavigation } from '@/context/NavigationContext';
import { IUserUpdatePassword } from '@/models/user';
import { errors, information } from '@/utils/labels'; import { errors, information } from '@/utils/labels';
function EditorPassword() { function EditorPassword() {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { updatePassword, error, setError, loading } = useAuth(); const { changePassword, isPending, error, reset } = useChangePassword();
const [oldPassword, setOldPassword] = useState(''); const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@ -34,19 +34,19 @@ function EditorPassword() {
toast.error(errors.passwordsMismatch); toast.error(errors.passwordsMismatch);
return; return;
} }
const data: IUserUpdatePassword = { const data: IChangePasswordDTO = {
old_password: oldPassword, old_password: oldPassword,
new_password: newPassword new_password: newPassword
}; };
updatePassword(data, () => { changePassword(data, () => {
toast.success(information.changesSaved); toast.success(information.changesSaved);
router.push(urls.login); router.push(urls.login);
}); });
} }
useEffect(() => { useEffect(() => {
setError(undefined); reset();
}, [newPassword, oldPassword, newPasswordRepeat, setError]); }, [newPassword, oldPassword, newPasswordRepeat, reset]);
return ( return (
<form <form
@ -89,7 +89,7 @@ function EditorPassword() {
/> />
{error ? <ProcessError error={error} /> : null} {error ? <ProcessError error={error} /> : null}
</FlexColumn> </FlexColumn>
<SubmitButton text='Сменить пароль' className='self-center' disabled={!canSubmit} loading={loading} /> <SubmitButton text='Сменить пароль' className='self-center' disabled={!canSubmit} loading={isPending} />
</form> </form>
); );
} }

View File

@ -4,45 +4,44 @@ import axios from 'axios';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { IUpdateProfileDTO } from '@/backend/users/api';
import { useProfileSuspense } from '@/backend/users/useProfile';
import { useUpdateProfile } from '@/backend/users/useUpdateProfile';
import InfoError, { ErrorData } from '@/components/info/InfoError'; import InfoError, { ErrorData } from '@/components/info/InfoError';
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 { useBlockNavigation } from '@/context/NavigationContext'; import { useBlockNavigation } from '@/context/NavigationContext';
import { useUserProfile } from '@/context/UserProfileContext';
import { IUserUpdateData } from '@/models/user';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
function EditorProfile() { function EditorProfile() {
const { updateUser, user, errorProcessing, processing } = useUserProfile(); const { profile } = useProfileSuspense();
const { updateProfile, isPending, error } = useUpdateProfile();
const [username, setUsername] = useState(user?.username ?? ''); const [username, setUsername] = useState(profile.username);
const [email, setEmail] = useState(user?.email ?? ''); const [email, setEmail] = useState(profile.email);
const [first_name, setFirstName] = useState(user?.first_name ?? ''); const [first_name, setFirstName] = useState(profile.first_name);
const [last_name, setLastName] = useState(user?.last_name ?? ''); const [last_name, setLastName] = useState(profile.last_name);
const isModified = const isModified = profile.email !== email || profile.first_name !== first_name || profile.last_name !== last_name;
user != undefined && (user.email !== email || user.first_name !== first_name || user.last_name !== last_name);
useBlockNavigation(isModified); useBlockNavigation(isModified);
useEffect(() => { useEffect(() => {
if (user) { setUsername(profile.username);
setUsername(user.username); setEmail(profile.email);
setEmail(user.email); setFirstName(profile.first_name);
setFirstName(user.first_name); setLastName(profile.last_name);
setLastName(user.last_name); }, [profile]);
}
}, [user]);
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const data: IUserUpdateData = { const data: IUpdateProfileDTO = {
username: username, username: username,
email: email, email: email,
first_name: first_name, first_name: first_name,
last_name: last_name last_name: last_name
}; };
updateUser(data, () => toast.success(information.changesSaved)); updateProfile(data, () => toast.success(information.changesSaved));
} }
return ( return (
@ -79,11 +78,11 @@ function EditorProfile() {
value={email} value={email}
onChange={event => setEmail(event.target.value)} onChange={event => setEmail(event.target.value)}
/> />
{errorProcessing ? <ProcessError error={errorProcessing} /> : null} {error ? <ProcessError error={error} /> : null}
<SubmitButton <SubmitButton
className='self-center mt-6' className='self-center mt-6'
text='Сохранить данные' text='Сохранить данные'
loading={processing} loading={isPending}
disabled={!isModified || email == ''} disabled={!isModified || email == ''}
/> />
</form> </form>

View File

@ -1,25 +0,0 @@
'use client';
import DataLoader from '@/components/wrap/DataLoader';
import { useUserProfile } from '@/context/UserProfileContext';
import EditorPassword from './EditorPassword';
import EditorProfile from './EditorProfile';
function UserContents() {
const { user, error, loading } = useUserProfile();
return (
<DataLoader isLoading={loading} error={error} hasNoData={!user}>
<div className='cc-fade-in flex flex-col py-2 mx-auto w-fit'>
<h1 className='mb-2 select-none'>Учетные данные пользователя</h1>
<div className='flex py-2'>
<EditorProfile />
<EditorPassword />
</div>
</div>
</DataLoader>
);
}
export default UserContents;

View File

@ -1,14 +1,23 @@
import RequireAuth from '@/components/wrap/RequireAuth'; import { Suspense } from 'react';
import { UserProfileState } from '@/context/UserProfileContext';
import UserContents from './UserContents'; import Loader from '@/components/ui/Loader';
import RequireAuth from '@/components/wrap/RequireAuth';
import EditorPassword from './EditorPassword';
import EditorProfile from './EditorProfile';
function UserProfilePage() { function UserProfilePage() {
return ( return (
<RequireAuth> <RequireAuth>
<UserProfileState> <Suspense fallback={<Loader />}>
<UserContents /> <div className='cc-fade-in flex flex-col py-2 mx-auto w-fit'>
</UserProfileState> <h1 className='mb-2 select-none'>Учетные данные пользователя</h1>
<div className='flex py-2'>
<EditorProfile />
<EditorPassword />
</div>
</div>
</Suspense>
</RequireAuth> </RequireAuth>
); );
} }

View File

@ -958,8 +958,9 @@ export const information = {
noDataToExport: 'Нет данных для экспорта', noDataToExport: 'Нет данных для экспорта',
substitutionsCorrect: 'Таблица отождествлений прошла проверку', substitutionsCorrect: 'Таблица отождествлений прошла проверку',
addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,
newLibraryItem: 'Схема успешно создана', newLibraryItem: 'Схема успешно создана',
addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,
newUser: (username: string) => `Пользователь успешно создан: ${username}`,
newVersion: (version: string) => `Версия создана: ${version}`, newVersion: (version: string) => `Версия создана: ${version}`,
newConstituent: (alias: string) => `Конституента добавлена: ${alias}`, newConstituent: (alias: string) => `Конституента добавлена: ${alias}`,
newOperation: (alias: string) => `Операция добавлена: ${alias}`, newOperation: (alias: string) => `Операция добавлена: ${alias}`,