♻️(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:
@@ -1,7 +1,7 @@
|
|||||||
import { Crisp } from 'crisp-sdk-web';
|
import { Crisp } from 'crisp-sdk-web';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
|
|
||||||
import { useAuthStore } from '../useAuthStore';
|
import { gotoLogout } from '../utils';
|
||||||
|
|
||||||
jest.mock('crisp-sdk-web', () => ({
|
jest.mock('crisp-sdk-web', () => ({
|
||||||
...jest.requireActual('crisp-sdk-web'),
|
...jest.requireActual('crisp-sdk-web'),
|
||||||
@@ -17,7 +17,7 @@ jest.mock('crisp-sdk-web', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('useAuthStore', () => {
|
describe('utils', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
fetchMock.restore();
|
fetchMock.restore();
|
||||||
@@ -33,7 +33,7 @@ describe('useAuthStore', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useAuthStore.getState().logout();
|
gotoLogout();
|
||||||
|
|
||||||
expect(Crisp.session.reset).toHaveBeenCalled();
|
expect(Crisp.session.reset).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from './getMe';
|
export * from './useAuthQuery';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { fetchAPI } from '@/api';
|
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { APIError, fetchAPI } from '@/api';
|
||||||
|
|
||||||
import { User } from './types';
|
import { User } from './types';
|
||||||
|
|
||||||
@@ -19,3 +21,16 @@ export const getMe = async (): Promise<User> => {
|
|||||||
}
|
}
|
||||||
return response.json() as 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Loader } from '@openfun/cunningham-react';
|
import { Loader } from '@openfun/cunningham-react';
|
||||||
import { useRouter } from 'next/router';
|
import { PropsWithChildren } from 'react';
|
||||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Box } from '@/components';
|
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.
|
* 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
|
* When we will have a homepage design for non-authenticated users, we will remove this restriction to have
|
||||||
* the full website accessible without authentication.
|
* the full website accessible without authentication.
|
||||||
*/
|
*/
|
||||||
const regexpUrlsAuth = [/\/docs\/$/g, /^\/$/g];
|
|
||||||
|
|
||||||
export const Auth = ({ children }: PropsWithChildren) => {
|
export const Auth = ({ children }: PropsWithChildren) => {
|
||||||
const { initAuth, initiated, authenticated, login, getAuthUrl } =
|
const { user, isLoading, pathAllowed } = useAuth();
|
||||||
useAuthStore();
|
|
||||||
const { asPath, replace } = useRouter();
|
|
||||||
|
|
||||||
const [pathAllowed, setPathAllowed] = useState<boolean>(
|
if (isLoading) {
|
||||||
!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)) {
|
|
||||||
return (
|
return (
|
||||||
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
|
||||||
<Loader />
|
<Loader />
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { Button } from '@openfun/cunningham-react';
|
import { Button } from '@openfun/cunningham-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuthStore } from '@/features/auth';
|
import { useAuth } from '../hooks';
|
||||||
|
import { gotoLogin, gotoLogout } from '../utils';
|
||||||
|
|
||||||
export const ButtonLogin = () => {
|
export const ButtonLogin = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { logout, authenticated, login } = useAuthStore();
|
const { authenticated } = useAuth();
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
return (
|
return (
|
||||||
<Button onClick={login} color="primary-text" aria-label={t('Login')}>
|
<Button onClick={gotoLogin} color="primary-text" aria-label={t('Login')}>
|
||||||
{t('Login')}
|
{t('Login')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={logout} color="primary-text" aria-label={t('Logout')}>
|
<Button onClick={gotoLogout} color="primary-text" aria-label={t('Logout')}>
|
||||||
{t('Logout')}
|
{t('Logout')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
import { baseApiUrl } from '@/api';
|
||||||
|
|
||||||
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
|
||||||
|
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
|
||||||
|
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './useAuth';
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './api/types';
|
export * from './api/types';
|
||||||
export * from './components';
|
export * from './components';
|
||||||
export * from './stores';
|
export * from './hooks';
|
||||||
|
export * from './utils';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './useAuthStore';
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
27
src/frontend/apps/impress/src/features/auth/utils.ts
Normal file
27
src/frontend/apps/impress/src/features/auth/utils.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import { css } from 'styled-components';
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
import { Box, TextErrors } from '@/components';
|
import { Box, TextErrors } from '@/components';
|
||||||
import { useAuthStore } from '@/features/auth';
|
import { useAuth } from '@/features/auth';
|
||||||
import { Doc } from '@/features/docs/doc-management';
|
import { Doc } from '@/features/docs/doc-management';
|
||||||
|
|
||||||
import { useUploadFile } from '../hook';
|
import { useUploadFile } from '../hook';
|
||||||
@@ -113,7 +113,7 @@ interface BlockNoteEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
||||||
const { userData } = useAuthStore();
|
const { user } = useAuth();
|
||||||
const { setEditor } = useEditorStore();
|
const { setEditor } = useEditorStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
|
|||||||
|
|
||||||
const collabName = readOnly
|
const collabName = readOnly
|
||||||
? 'Reader'
|
? 'Reader'
|
||||||
: userData?.full_name || userData?.email || t('Anonymous');
|
: user?.full_name || user?.email || t('Anonymous');
|
||||||
|
|
||||||
const editor = useCreateBlockNote(
|
const editor = useCreateBlockNote(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useAuthStore } from '@/features/auth';
|
import { useAuth } from '@/features/auth';
|
||||||
import { Access, Role } from '@/features/docs/doc-management';
|
import { Access, Role } from '@/features/docs/doc-management';
|
||||||
|
|
||||||
export const useWhoAmI = (access: Access) => {
|
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 rolesAllowed = access.abilities.set_role_to;
|
||||||
|
|
||||||
const isLastOwner =
|
const isLastOwner =
|
||||||
!rolesAllowed.length && access.role === Role.OWNER && isMyself;
|
!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 {
|
return {
|
||||||
isLastOwner,
|
isLastOwner,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { Box, Icon, SeparatedSection } from '@/components';
|
import { Box, Icon, SeparatedSection } from '@/components';
|
||||||
import { useAuthStore } from '@/features/auth';
|
import { useAuth } from '@/features/auth';
|
||||||
import { useCreateDoc } from '@/features/docs/doc-management';
|
import { useCreateDoc } from '@/features/docs/doc-management';
|
||||||
import { DocSearchModal } from '@/features/docs/doc-search';
|
import { DocSearchModal } from '@/features/docs/doc-search';
|
||||||
import { useCmdK } from '@/hook/useCmdK';
|
import { useCmdK } from '@/hook/useCmdK';
|
||||||
@@ -14,7 +14,7 @@ import { useLeftPanelStore } from '../stores';
|
|||||||
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchModal = useModal();
|
const searchModal = useModal();
|
||||||
const auth = useAuthStore();
|
const { authenticated } = useAuth();
|
||||||
useCmdK(searchModal.open);
|
useCmdK(searchModal.open);
|
||||||
const { togglePanel } = useLeftPanelStore();
|
const { togglePanel } = useLeftPanelStore();
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
|||||||
<Icon $variation="800" $theme="primary" iconName="house" />
|
<Icon $variation="800" $theme="primary" iconName="house" />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{auth.authenticated && (
|
{authenticated && (
|
||||||
<Button
|
<Button
|
||||||
onClick={searchModal.open}
|
onClick={searchModal.open}
|
||||||
size="medium"
|
size="medium"
|
||||||
@@ -65,7 +65,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{auth.authenticated && (
|
{authenticated && (
|
||||||
<Button onClick={createNewDoc}>{t('New doc')}</Button>
|
<Button onClick={createNewDoc}>{t('New doc')}</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Box, Text, TextErrors } from '@/components';
|
import { Box, Text, TextErrors } from '@/components';
|
||||||
import { useAuthStore } from '@/features/auth';
|
import { gotoLogin } from '@/features/auth';
|
||||||
import { DocEditor } from '@/features/docs/doc-editor';
|
import { DocEditor } from '@/features/docs/doc-editor';
|
||||||
import {
|
import {
|
||||||
Doc,
|
Doc,
|
||||||
@@ -45,7 +45,6 @@ interface DocProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DocPage = ({ id }: DocProps) => {
|
const DocPage = ({ id }: DocProps) => {
|
||||||
const { login } = useAuthStore();
|
|
||||||
const {
|
const {
|
||||||
data: docQuery,
|
data: docQuery,
|
||||||
isError,
|
isError,
|
||||||
@@ -106,7 +105,7 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
login();
|
gotoLogin();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user