diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 571d7052d..815c2d8d3 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -467,6 +467,8 @@ class Base(Configuration): ) # OIDC - Authorization Code Flow + OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView" + OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView" OIDC_CREATE_USER = values.BooleanValue( default=True, environ_name="OIDC_CREATE_USER", @@ -551,6 +553,9 @@ class Base(Configuration): environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", environ_prefix=None, ) + OIDC_REDIRECT_FIELD_NAME = values.Value( + "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None + ) # WARNING: Enabling this setting allows multiple user accounts to share the same email # address. This may cause security issues and is not recommended for production use when diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 03ce5097d..49804419f 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -4,11 +4,13 @@ import { useRouter } from 'next/router'; import { useEffect } from 'react'; import { useCunninghamTheme } from '@/cunningham'; -import { Auth, KEY_AUTH, setAuthUrl } from '@/features/auth'; +import { Auth, KEY_AUTH } from '@/features/auth'; import { useResponsiveStore } from '@/stores/'; import { ConfigProvider } from './config/'; +export const DEFAULT_QUERY_RETRY = 1; + /** * QueryClient: * - defaultOptions: @@ -19,7 +21,7 @@ import { ConfigProvider } from './config/'; const defaultOptions = { queries: { staleTime: 1000 * 60 * 3, - retry: 1, + retry: DEFAULT_QUERY_RETRY, }, }; const queryClient = new QueryClient({ @@ -51,8 +53,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) { void queryClient.resetQueries({ queryKey: [KEY_AUTH], }); - setAuthUrl(); - void replace(`/401`); + void replace( + `/401?returnTo=${encodeURIComponent(window.location.pathname)}`, + ); } }, }, diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index fdfe8c97e..b245d1b91 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -38,7 +38,10 @@ export const getConfig = async (): Promise => { const response = await fetchAPI(`config/`); if (!response.ok) { - throw new APIError('Failed to get the doc', await errorCauses(response)); + throw new APIError( + 'Failed to get the configurations', + await errorCauses(response), + ); } const config = response.json() as Promise; diff --git a/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx b/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx index 026beec9f..90a87f48c 100644 --- a/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx +++ b/src/frontend/apps/impress/src/features/auth/api/useAuthQuery.tsx @@ -1,6 +1,11 @@ import { UseQueryOptions, useQuery } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; +import { DEFAULT_QUERY_RETRY } from '@/core'; +import { + attemptSilentLogin, + canAttemptSilentLogin, +} from '@/features/auth/silentLogin'; import { User } from './types'; @@ -16,6 +21,16 @@ import { User } from './types'; */ export const getMe = async (): Promise => { const response = await fetchAPI(`users/me/`); + + if (!response.ok && response.status == 401 && canAttemptSilentLogin()) { + const currentLocation = window.location.href; + attemptSilentLogin(3600); + + while (window.location.href === currentLocation) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + if (!response.ok) { throw new APIError( `Couldn't fetch user data: ${response.statusText}`, @@ -34,6 +49,13 @@ export function useAuthQuery( queryKey: [KEY_AUTH], queryFn: getMe, staleTime: 1000 * 60 * 15, // 15 minutes + retry: (failureCount, error) => { + // we assume that a 401 means the user is not logged in + if (error.status == 401) { + return false; + } + return failureCount < DEFAULT_QUERY_RETRY; + }, ...queryConfig, }); } diff --git a/src/frontend/apps/impress/src/features/auth/components/Auth.tsx b/src/frontend/apps/impress/src/features/auth/components/Auth.tsx index addb481b3..84800d877 100644 --- a/src/frontend/apps/impress/src/features/auth/components/Auth.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/Auth.tsx @@ -7,7 +7,7 @@ import { useConfig } from '@/core'; import { HOME_URL } from '../conf'; import { useAuth } from '../hooks'; -import { getAuthUrl, gotoLogin } from '../utils'; +import { gotoLogin } from '../utils'; export const Auth = ({ children }: PropsWithChildren) => { const { isLoading, pathAllowed, isFetchedAfterMount, authenticated } = @@ -23,22 +23,6 @@ export const Auth = ({ children }: PropsWithChildren) => { ); } - /** - * If the user is authenticated and wanted initially to access a document, - * we redirect to the document page. - */ - if (authenticated) { - const authUrl = getAuthUrl(); - if (authUrl) { - void replace(authUrl); - return ( - - - - ); - } - } - /** * If the user is not authenticated and the path is not allowed, we redirect to the login page. */ diff --git a/src/frontend/apps/impress/src/features/auth/conf.ts b/src/frontend/apps/impress/src/features/auth/conf.ts index c44fe0188..024c07dd5 100644 --- a/src/frontend/apps/impress/src/features/auth/conf.ts +++ b/src/frontend/apps/impress/src/features/auth/conf.ts @@ -3,4 +3,3 @@ import { baseApiUrl } from '@/api'; export const HOME_URL = '/home'; export const LOGIN_URL = `${baseApiUrl()}authenticate/`; export const LOGOUT_URL = `${baseApiUrl()}logout/`; -export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth'; diff --git a/src/frontend/apps/impress/src/features/auth/silentLogin.ts b/src/frontend/apps/impress/src/features/auth/silentLogin.ts new file mode 100644 index 000000000..2af78c3ba --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/silentLogin.ts @@ -0,0 +1,35 @@ +import { gotoLogin } from '@/features/auth'; + +const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'; + +const isRetryAllowed = () => { + const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY); + if (!lastRetryDate) { + return true; + } + const now = new Date(); + return now.getTime() > Number(lastRetryDate); +}; + +const setNextRetryTime = (retryIntervalInSeconds: number) => { + const now = new Date(); + const nextRetryTime = now.getTime() + retryIntervalInSeconds * 1000; + localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime)); +}; + +const initiateSilentLogin = () => { + const currentPath = window.location.pathname; + gotoLogin(currentPath, true); +}; + +export const canAttemptSilentLogin = () => { + return isRetryAllowed(); +}; + +export const attemptSilentLogin = (retryIntervalInSeconds: number) => { + if (!isRetryAllowed()) { + return; + } + setNextRetryTime(retryIntervalInSeconds); + initiateSilentLogin(); +}; diff --git a/src/frontend/apps/impress/src/features/auth/utils.ts b/src/frontend/apps/impress/src/features/auth/utils.ts index 41d50cf01..77f0605c7 100644 --- a/src/frontend/apps/impress/src/features/auth/utils.ts +++ b/src/frontend/apps/impress/src/features/auth/utils.ts @@ -1,27 +1,12 @@ import { terminateCrispSession } from '@/services/Crisp'; -import { LOGIN_URL, LOGOUT_URL, PATH_AUTH_LOCAL_STORAGE } from './conf'; +import { LOGIN_URL, LOGOUT_URL } 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 = (withRedirect = true) => { - if (withRedirect) { - setAuthUrl(); - } - - window.location.replace(LOGIN_URL); +export const gotoLogin = (returnTo = '/', isSilent = false) => { + const authenticateUrl = + LOGIN_URL + + `?silent=${encodeURIComponent(isSilent)}&returnTo=${window.location.origin + returnTo}`; + window.location.replace(authenticateUrl); }; export const gotoLogout = () => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx index ebbb1d543..9aeb9bdad 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx @@ -19,7 +19,6 @@ export const getDoc = async ({ id }: DocParams): Promise => { }; export const KEY_DOC = 'doc'; -export const KEY_DOC_VISIBILITY = 'doc-visibility'; export function useDoc( param: DocParams, diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts index e6bb23b99..7ecd2f3ad 100644 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useConfig } from '@/core'; -import { useAuthQuery } from '@/features/auth/api'; +import { useAuthQuery } from '@/features/auth'; import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage'; import { getMatchingLocales } from '@/features/language/utils/locale'; import { availableFrontendLanguages } from '@/i18n/initI18n'; diff --git a/src/frontend/apps/impress/src/pages/401.tsx b/src/frontend/apps/impress/src/pages/401.tsx index 995bcbe55..1ebf06efa 100644 --- a/src/frontend/apps/impress/src/pages/401.tsx +++ b/src/frontend/apps/impress/src/pages/401.tsx @@ -1,7 +1,7 @@ import { Button } from '@openfun/cunningham-react'; import Image from 'next/image'; import { useRouter } from 'next/router'; -import { ReactElement, useEffect } from 'react'; +import { ReactElement, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import img401 from '@/assets/icons/icon-401.png'; @@ -13,7 +13,18 @@ import { NextPageWithLayout } from '@/types/next'; const Page: NextPageWithLayout = () => { const { t } = useTranslation(); const { authenticated } = useAuth(); - const { replace } = useRouter(); + const router = useRouter(); + const { replace } = router; + + const [returnTo, setReturnTo] = useState(undefined); + const { returnTo: returnToParams } = router.query; + + useEffect(() => { + if (returnToParams) { + setReturnTo(returnToParams as string); + void replace('/401'); + } + }, [returnToParams, replace]); useEffect(() => { if (authenticated) { @@ -42,7 +53,7 @@ const Page: NextPageWithLayout = () => { {t('Log in to access the document.')} - diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 295672436..8aa0090de 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Icon, TextErrors } from '@/components'; +import { DEFAULT_QUERY_RETRY } from '@/core'; import { DocEditor } from '@/docs/doc-editor'; import { Doc, @@ -14,7 +15,6 @@ import { useDoc, useDocStore, } from '@/docs/doc-management/'; -import { KEY_AUTH, setAuthUrl } from '@/features/auth'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -56,6 +56,14 @@ const DocPage = ({ id }: DocProps) => { { staleTime: 0, queryKey: [KEY_DOC, { id }], + retryDelay: 1000, + retry: (failureCount, error) => { + if (error.status == 403 || error.status == 401 || error.status == 404) { + return false; + } else { + return failureCount < DEFAULT_QUERY_RETRY; + } + }, }, ); @@ -101,23 +109,17 @@ const DocPage = ({ id }: DocProps) => { }, [addTask, doc?.id, queryClient]); if (isError && error) { - if (error.status === 403) { - void replace(`/403`); - return null; - } - - if (error.status === 404) { - void replace(`/404`); - return null; - } - - if (error.status === 401) { - void queryClient.resetQueries({ - queryKey: [KEY_AUTH], - }); - setAuthUrl(); - void replace(`/401`); - return null; + if ([403, 404, 401].includes(error.status)) { + void replace( + error.status === 401 + ? `/${error.status}?returnTo=${encodeURIComponent(window.location.pathname)}` + : `/${error.status}`, + ); + return ( + + + + ); } return (