Compare commits

...

6 Commits

Author SHA1 Message Date
lebaudantoine 4cecfec0b7 🩹(frontend) correct misleading error message for config fetch failure
Update APIError message that incorrectly mentioned document when
configuration fetching fails.
2025-05-25 00:10:20 +02:00
lebaudantoine ec663b601f 🚸(auth) implement first draft of silent login with prompt=None
Add initial naive implementation of silent login functionality using
prompt=None parameter to request OIDC provider to skip login screen and
return proper HTTP error response instead.
2025-05-25 00:10:09 +02:00
lebaudantoine 4de26c331b ️(frontend) optimize document fetch error handling
Reduce unnecessary fetch requests when retrieving documents with permission
or authentication issues. Previous implementation was triggering multiple
document requests despite having sufficient error information from initial
attempt to determine appropriate user redirection.

Additionally, fix issue where resetting the auth cache was triggering redundant
authentication verification requests. The responsibility for checking auth
status should belong to the 401 page component on mount, rather than being
triggered by cache resets during error handling.

Known limitations:
- Not waiting for async  function completion makes code harder to
 maintain
- Added loading spinner as temporary solution to prevent UI flicker
- Future improvement should implement consistent error-based redirects rather
 than rendering error messages directly on document page
2025-05-25 00:09:54 +02:00
lebaudantoine f66a8e3f1d 🔥(frontend) delete unused variable
Remove variable that is no longer referenced in the codebase to improve
code cleanliness and reduce potential confusion.
2025-05-25 00:09:54 +02:00
lebaudantoine 4f3e0ef5b3 ️(frontend) implement Mozilla Django OIDC returnTo for login flow
Use returnTo capability to pass target URL during authentication, eliminating
redundant renders when returning to app. Work around Next.js router limitations
(can't pass data when replacing URL) by adding temporary target parameter.

Discovered issue: 401 responses on document fetch trigger retry loop instead
of redirecting to login. Will address in subsequent commits.
2025-05-25 00:09:50 +02:00
lebaudantoine 7b363d4624 ️(frontend) prevent authentication retry on 401 responses
Stop retry attempts when receiving 401 Unauthorized from /me endpoint since
this clearly indicates authentication status. The original purpose of the /me
call is simply to determine if user is authenticated, and a 401 provides
sufficient information.

Prevents unnecessary network requests caused by React Query's automatic retry
behavior when re-raising exceptions, which was hiding the 401 status. Improves
performance and reduces server load during authentication failures.
2025-05-25 00:09:32 +02:00
12 changed files with 115 additions and 67 deletions
+5
View File
@@ -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
@@ -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)}`,
);
}
},
},
@@ -38,7 +38,10 @@ export const getConfig = async (): Promise<ConfigResponse> => {
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<ConfigResponse>;
@@ -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<User> => {
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,
});
}
@@ -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 (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
}
/**
* If the user is not authenticated and the path is not allowed, we redirect to the login page.
*/
@@ -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';
@@ -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();
};
@@ -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 = () => {
@@ -19,7 +19,6 @@ export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
};
export const KEY_DOC = 'doc';
export const KEY_DOC_VISIBILITY = 'doc-visibility';
export function useDoc(
param: DocParams,
@@ -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';
+14 -3
View File
@@ -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<string | undefined>(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.')}
</Text>
<Button onClick={() => gotoLogin(false)} aria-label={t('Login')}>
<Button onClick={() => gotoLogin(returnTo)} aria-label={t('Login')}>
{t('Login')}
</Button>
</Box>
@@ -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 (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
return (