♻️(frontend) use a hook instead of a store for auth

We will use a hook instead of a store for the auth
feature. The hook will be powered by ReactQuery,
it will provide us fine-grained control over the
auth state and will be easier to use.
This commit is contained in:
Anthony LC
2025-01-31 10:10:22 +01:00
committed by Anthony LC
parent 40fdf97520
commit c493eb8924
16 changed files with 111 additions and 131 deletions

View File

@@ -1,7 +1,7 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '../useAuthStore';
import { gotoLogout } from '../utils';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
@@ -17,7 +17,7 @@ jest.mock('crisp-sdk-web', () => ({
},
}));
describe('useAuthStore', () => {
describe('utils', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
@@ -33,7 +33,7 @@ describe('useAuthStore', () => {
writable: true,
});
useAuthStore.getState().logout();
gotoLogout();
expect(Crisp.session.reset).toHaveBeenCalled();
});

View File

@@ -1,2 +1,2 @@
export * from './getMe';
export * from './useAuthQuery';
export * from './types';

View File

@@ -1,4 +1,6 @@
import { fetchAPI } from '@/api';
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, fetchAPI } from '@/api';
import { User } from './types';
@@ -19,3 +21,16 @@ export const getMe = async (): Promise<User> => {
}
return response.json() as Promise<User>;
};
export const KEY_AUTH = 'auth';
export function useAuthQuery(
queryConfig?: UseQueryOptions<User, APIError, User>,
) {
return useQuery<User, APIError, User>({
queryKey: [KEY_AUTH],
queryFn: getMe,
staleTime: 1000 * 60 * 15, // 15 minutes
...queryConfig,
});
}

View File

@@ -1,10 +1,10 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { PropsWithChildren, useEffect, useState } from 'react';
import { PropsWithChildren } from 'react';
import { Box } from '@/components';
import HomeContent from '@/features/home/components/HomeContent';
import { useAuthStore } from '../stores/useAuthStore';
import { useAuth } from '../hooks';
/**
* TODO: Remove this restriction when we will have a homepage design for non-authenticated users.
@@ -14,47 +14,10 @@ import { useAuthStore } from '../stores/useAuthStore';
* When we will have a homepage design for non-authenticated users, we will remove this restriction to have
* the full website accessible without authentication.
*/
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
export const Auth = ({ children }: PropsWithChildren) => {
const { initAuth, initiated, authenticated, login, getAuthUrl } =
useAuthStore();
const { asPath, replace } = useRouter();
const { user, isLoading, pathAllowed } = useAuth();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)),
);
useEffect(() => {
initAuth();
}, [initAuth]);
useEffect(() => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
}, [asPath]);
// We force to login except on allowed paths
useEffect(() => {
if (!initiated || authenticated || pathAllowed) {
return;
}
login();
}, [authenticated, pathAllowed, login, initiated]);
// Redirect to the path before login
useEffect(() => {
if (!authenticated) {
return;
}
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
}
}, [authenticated, getAuthUrl, replace]);
if ((!initiated && pathAllowed) || (!authenticated && !pathAllowed)) {
if (isLoading) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />

View File

@@ -1,22 +1,23 @@
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/features/auth';
import { useAuth } from '../hooks';
import { gotoLogin, gotoLogout } from '../utils';
export const ButtonLogin = () => {
const { t } = useTranslation();
const { logout, authenticated, login } = useAuthStore();
const { authenticated } = useAuth();
if (!authenticated) {
return (
<Button onClick={login} color="primary-text" aria-label={t('Login')}>
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
{t('Login')}
</Button>
);
}
return (
<Button onClick={logout} color="primary-text" aria-label={t('Logout')}>
<Button onClick={gotoLogout} color="primary-text" aria-label={t('Logout')}>
{t('Logout')}
</Button>
);

View File

@@ -1 +1,5 @@
import { baseApiUrl } from '@/api';
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
export const LOGOUT_URL = `${baseApiUrl()}logout/`;

View File

@@ -0,0 +1 @@
export * from './useAuth';

View File

@@ -0,0 +1,34 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useAuthQuery } from '../api';
import { getAuthUrl } from '../utils';
const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g];
export const useAuth = () => {
const { data: user, ...authStates } = useAuthQuery();
const { pathname, replace } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
);
useEffect(() => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
}, [pathname]);
// Redirect to the path before login
useEffect(() => {
if (!user) {
return;
}
const authUrl = getAuthUrl();
if (authUrl) {
void replace(authUrl);
}
}, [user, replace]);
return { user, authenticated: !!user, pathAllowed, ...authStates };
};

View File

@@ -1,3 +1,4 @@
export * from './api/types';
export * from './components';
export * from './stores';
export * from './hooks';
export * from './utils';

View File

@@ -1 +0,0 @@
export * from './useAuthStore';

View File

@@ -1,64 +0,0 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { User, getMe } from '../api';
import { PATH_AUTH_LOCAL_STORAGE } from '../conf';
interface AuthStore {
initiated: boolean;
authenticated: boolean;
initAuth: () => void;
logout: () => void;
login: () => void;
setAuthUrl: (url: string) => void;
getAuthUrl: () => string | undefined;
userData?: User;
}
const initialState = {
initiated: false,
authenticated: false,
userData: undefined,
};
export const useAuthStore = create<AuthStore>((set, get) => ({
initiated: initialState.initiated,
authenticated: initialState.authenticated,
userData: initialState.userData,
initAuth: () => {
getMe()
.then((data: User) => {
set({ authenticated: true, userData: data });
})
.catch(() => {})
.finally(() => {
set({ initiated: true });
});
},
login: () => {
get().setAuthUrl(window.location.pathname);
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
setAuthUrl() {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
},
// If a path is stored in the local storage, we return it then remove it
getAuthUrl() {
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
return path_auth;
}
},
}));

View File

@@ -0,0 +1,27 @@
import { terminateCrispSession } from '@/services/Crisp';
import { LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE } from './conf';
export const getAuthUrl = () => {
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
return path_auth;
}
};
export const setAuthUrl = () => {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);
}
};
export const gotoLogin = () => {
setAuthUrl();
window.location.replace(LOGIN_URL);
};
export const gotoLogout = () => {
terminateCrispSession();
window.location.replace(LOGOUT_URL);
};

View File

@@ -10,7 +10,7 @@ import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useAuthStore } from '@/features/auth';
import { useAuth } from '@/features/auth';
import { Doc } from '@/features/docs/doc-management';
import { useUploadFile } from '../hook';
@@ -113,7 +113,7 @@ interface BlockNoteEditorProps {
}
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { userData } = useAuthStore();
const { user } = useAuth();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
@@ -126,7 +126,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const collabName = readOnly
? 'Reader'
: userData?.full_name || userData?.email || t('Anonymous');
: user?.full_name || user?.email || t('Anonymous');
const editor = useCreateBlockNote(
{

View File

@@ -1,16 +1,16 @@
import { useAuthStore } from '@/features/auth';
import { useAuth } from '@/features/auth';
import { Access, Role } from '@/features/docs/doc-management';
export const useWhoAmI = (access: Access) => {
const { userData } = useAuthStore();
const { user } = useAuth();
const isMyself = userData?.id === access.user.id;
const isMyself = user?.id === access.user.id;
const rolesAllowed = access.abilities.set_role_to;
const isLastOwner =
!rolesAllowed.length && access.role === Role.OWNER && isMyself;
const isOtherOwner = access.role === Role.OWNER && userData?.id && !isMyself;
const isOtherOwner = access.role === Role.OWNER && user?.id && !isMyself;
return {
isLastOwner,

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { PropsWithChildren } from 'react';
import { Box, Icon, SeparatedSection } from '@/components';
import { useAuthStore } from '@/features/auth';
import { useAuth } from '@/features/auth';
import { useCreateDoc } from '@/features/docs/doc-management';
import { DocSearchModal } from '@/features/docs/doc-search';
import { useCmdK } from '@/hook/useCmdK';
@@ -14,7 +14,7 @@ import { useLeftPanelStore } from '../stores';
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
const searchModal = useModal();
const auth = useAuthStore();
const { authenticated } = useAuth();
useCmdK(searchModal.open);
const { togglePanel } = useLeftPanelStore();
@@ -54,7 +54,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
<Icon $variation="800" $theme="primary" iconName="house" />
}
/>
{auth.authenticated && (
{authenticated && (
<Button
onClick={searchModal.open}
size="medium"
@@ -65,7 +65,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
/>
)}
</Box>
{auth.authenticated && (
{authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
)}
</Box>

View File

@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Box, Text, TextErrors } from '@/components';
import { useAuthStore } from '@/features/auth';
import { gotoLogin } from '@/features/auth';
import { DocEditor } from '@/features/docs/doc-editor';
import {
Doc,
@@ -45,7 +45,6 @@ interface DocProps {
}
const DocPage = ({ id }: DocProps) => {
const { login } = useAuthStore();
const {
data: docQuery,
isError,
@@ -106,7 +105,7 @@ const DocPage = ({ id }: DocProps) => {
}
if (error.status === 401) {
login();
gotoLogin();
return null;
}