From 2940be7413b8302449f9514bf145c05b81bb86ca Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Thu, 28 May 2026 14:37:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(frontend)=20support=20login=20nex?= =?UTF-8?q?t=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user tries to access to a message route when it is not authenticated, it is redirect on the homepage and have to authenticate. Now in this case, we redirect on homepage and persist the previous route within a next query param, in this way, we are able to automatically redirect the user on the right view once it is authenticated. --- env.d/development/backend.defaults | 2 +- src/frontend/src/features/auth/index.tsx | 23 +++++++++++++++++-- .../src/features/auth/silent-login.ts | 6 ++--- .../components/main/authenticated-view.tsx | 3 ++- src/frontend/src/pages/index.tsx | 8 ++++++- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/env.d/development/backend.defaults b/env.d/development/backend.defaults index 3c98b7d8c..0cc7e33ca 100644 --- a/env.d/development/backend.defaults +++ b/env.d/development/backend.defaults @@ -72,7 +72,7 @@ LOGIN_REDIRECT_URL=http://localhost:8900 LOGIN_REDIRECT_URL_FAILURE=http://localhost:8900 LOGOUT_REDIRECT_URL=http://localhost:8900 -OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8902", "http://localhost:8900"] +OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8902,localhost:8900 OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} # keycloak diff --git a/src/frontend/src/features/auth/index.tsx b/src/frontend/src/features/auth/index.tsx index 9a5f3c3b1..223a790c2 100644 --- a/src/frontend/src/features/auth/index.tsx +++ b/src/frontend/src/features/auth/index.tsx @@ -14,8 +14,27 @@ export const logout = () => { window.location.replace(getRequestUrl("/api/v1.0/logout/")); }; -export const login = () => { - window.location.replace(getRequestUrl("/api/v1.0/authenticate/")); +/** + * Restricts the post-login redirect to the current site origin to prevent + * open redirects. Accepts a relative path or absolute URL; returns an + * absolute URL on the current origin, or undefined if the input is malformed + * or off-origin. + */ +const sanitizeNextUrl = (raw?: string): string | undefined => { + if (!raw) return undefined; + try { + const absolute = new URL(raw, window.location.origin); + if (absolute.origin !== window.location.origin) return undefined; + return absolute.href; + } catch { + return undefined; + } +}; + +export const login = (nextUrl?: string) => { + const safeNext = sanitizeNextUrl(nextUrl); + const params = safeNext ? { next: safeNext } : undefined; + window.location.replace(getRequestUrl("/api/v1.0/authenticate/", params)); }; interface AuthContextInterface { diff --git a/src/frontend/src/features/auth/silent-login.ts b/src/frontend/src/features/auth/silent-login.ts index 0b322cd9e..35b80d9b8 100644 --- a/src/frontend/src/features/auth/silent-login.ts +++ b/src/frontend/src/features/auth/silent-login.ts @@ -5,13 +5,13 @@ import { SILENT_LOGIN_RETRY_INTERVAL, SILENT_LOGIN_RETRY_KEY } from "../config/c * Replace the current window location with the silent login URL * * The silent login URL is the same as the login URL, but with the silent - * parameter set to true and the returnTo parameter set - * to the current window location + * parameter set to true and the `next` parameter set to the current window + * location so the OIDC callback redirects the user back to where they were. */ const silentLogin = () => { window.location.replace(getRequestUrl("/api/v1.0/authenticate/", { silent: "true", - returnTo: window.location.href, + next: window.location.href, })); }; diff --git a/src/frontend/src/features/layouts/components/main/authenticated-view.tsx b/src/frontend/src/features/layouts/components/main/authenticated-view.tsx index a620ee15c..033941f1e 100644 --- a/src/frontend/src/features/layouts/components/main/authenticated-view.tsx +++ b/src/frontend/src/features/layouts/components/main/authenticated-view.tsx @@ -11,7 +11,8 @@ const AuthenticatedView = ({ children }: { children: React.ReactNode }) => { useEffect(() => { if (user === null) { - router.replace("/"); + const next = window.location.pathname + window.location.search + window.location.hash; + router.replace({ pathname: "/", query: { next } }); } }, [user, router]); diff --git a/src/frontend/src/pages/index.tsx b/src/frontend/src/pages/index.tsx index 914b31b10..0150f68b5 100644 --- a/src/frontend/src/pages/index.tsx +++ b/src/frontend/src/pages/index.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { useRouter } from "next/router"; import { Hero, HomeGutter, Footer, ProConnectButton } from "@gouvfr-lasuite/ui-kit"; import { login, useAuth } from "@/features/auth"; import { MainLayout } from "@/features/layouts/components/main"; @@ -13,11 +14,16 @@ export default function HomePage() { const { t } = useTranslation(); const { theme, variant, themeConfig } = useTheme(); const { user } = useAuth(); + const router = useRouter(); if (user) { return ; } + const handleLogin = () => { + const raw = router.query.next; + login(typeof raw === "string" ? raw : undefined); + }; return ( } + mainButton={} /> {themeConfig.footer && ( From 9c0286f3207f5bfa61e7bace07805415b0e0d0d5 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Thu, 28 May 2026 15:11:10 +0200 Subject: [PATCH 2/3] !wip --- env.d/development/backend.e2e | 2 +- .../src/features/ui/components/toaster/index.tsx | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/env.d/development/backend.e2e b/env.d/development/backend.e2e index 31540122a..5c1d37d1a 100644 --- a/env.d/development/backend.e2e +++ b/env.d/development/backend.e2e @@ -14,7 +14,7 @@ OIDC_OP_AUTHORIZATION_ENDPOINT=http://keycloak:8802/realms/messages/protocol/ope OIDC_OP_TOKEN_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/token OIDC_OP_USER_ENDPOINT=http://keycloak:8802/realms/messages/protocol/openid-connect/userinfo -OIDC_REDIRECT_ALLOWED_HOSTS=["http://keycloak:8802", "http://proxy"] +OIDC_REDIRECT_ALLOWED_HOSTS=keycloak:8802,proxy LOGIN_REDIRECT_URL=http://proxy LOGIN_REDIRECT_URL_FAILURE=http://proxy diff --git a/src/frontend/src/features/ui/components/toaster/index.tsx b/src/frontend/src/features/ui/components/toaster/index.tsx index 496b45e10..255aaafd8 100644 --- a/src/frontend/src/features/ui/components/toaster/index.tsx +++ b/src/frontend/src/features/ui/components/toaster/index.tsx @@ -1,6 +1,8 @@ import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Icon } from "@gouvfr-lasuite/ui-kit"; import clsx from "clsx"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Slide, ToastContainer, ToastContentProps, toast } from "react-toastify"; export const Toaster = () => { @@ -28,7 +30,7 @@ export const ToasterItem = ({ type?: "error" | "info" | "warning"; actions?: ToastAction[]; } & Partial) => { - + const { t } = useTranslation(); const buttonColor = useMemo(() => { switch (type) { case "error": @@ -39,6 +41,7 @@ export const ToasterItem = ({ return "brand"; } }, [type]); + return (
( + icon={action.icon && } + >{(action.showLabel === true || !action.icon) && action.label} ))} {closeButton && ( )}
From f9e3e6666cc6e123ae37f605aa5f9e1e9df4b1a6 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Thu, 28 May 2026 16:21:24 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9A=B8(frontend)=20explicit=20auth=20?= =?UTF-8?q?issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In some case, a user can be authenticated on the identity provider but do not have account on Messages. In this precise case, we know display a toast to explicit what's wrong. --- src/frontend/public/locales/common/en-US.json | 1 + src/frontend/public/locales/common/fr-FR.json | 1 + src/frontend/src/features/auth/index.tsx | 54 ++++++++++++++----- src/frontend/src/features/config/constants.ts | 3 +- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 19a576720..c5e021b74 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -541,6 +541,7 @@ "No message could be deleted.": "No message could be deleted.", "No message could be reported as spam.": "No message could be reported as spam.", "No message could be starred.": "No message could be starred.", + "No Messages account is associated with this identity. Please contact your administrator.": "No Messages account is associated with this identity. Please contact your administrator.", "No results": "No results", "No signature": "No signature", "No signatures found": "No signatures found", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 3640a043c..38551176d 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -616,6 +616,7 @@ "No message could be deleted.": "Aucun message n'a pu être supprimé.", "No message could be reported as spam.": "Aucun message n'a pu être signalé comme spam.", "No message could be starred.": "Aucun message n'a pu être suivi.", + "No Messages account is associated with this identity. Please contact your administrator.": "Aucun compte Messages n'est associé à cette identité. Veuillez contacter votre administrateur.", "No results": "Aucun résultat", "No signature": "Aucune signature", "No signatures found": "Aucune signature trouvée", diff --git a/src/frontend/src/features/auth/index.tsx b/src/frontend/src/features/auth/index.tsx index 223a790c2..cad767097 100644 --- a/src/frontend/src/features/auth/index.tsx +++ b/src/frontend/src/features/auth/index.tsx @@ -6,7 +6,7 @@ import { Spinner } from "@gouvfr-lasuite/ui-kit"; import { UserWithAbilities } from "../api/gen/models/user_with_abilities"; import { addToast, ToasterItem } from "../ui/components/toaster"; import { useTranslation } from "react-i18next"; -import { SESSION_EXPIRED_KEY } from "../config/constants"; +import { OIDC_LOGIN_ATTEMPT_KEY, SESSION_EXPIRED_KEY } from "../config/constants"; import { useConfig } from "../providers/config"; import { attemptSilentLogin, canAttemptSilentLogin } from "./silent-login"; @@ -34,6 +34,9 @@ const sanitizeNextUrl = (raw?: string): string | undefined => { export const login = (nextUrl?: string) => { const safeNext = sanitizeNextUrl(nextUrl); const params = safeNext ? { next: safeNext } : undefined; + // Marker read back after the OIDC callback to detect a failed sign-in + // (e.g. no Messages user exists for the authenticated identity). + sessionStorage.setItem(OIDC_LOGIN_ATTEMPT_KEY, "true"); window.location.replace(getRequestUrl("/api/v1.0/authenticate/", params)); }; @@ -69,10 +72,18 @@ export const Auth = ({ if (query.isError && query.error?.code === 401) return null; return undefined; }, [query.isError, query.error?.code, query.data]); - const shouldAttemptSilentLogin = useMemo( - () => config.FRONTEND_SILENT_LOGIN_ENABLED && user === null && canAttemptSilentLogin(), - [config.FRONTEND_SILENT_LOGIN_ENABLED, user] - ); + const shouldAttemptSilentLogin = useMemo(() => { + if (!config.FRONTEND_SILENT_LOGIN_ENABLED) return false; + if (user !== null) return false; + if (!canAttemptSilentLogin()) return false; + if (typeof window === "undefined") return false; + // Skip silent login while a one-shot toast still needs to be shown, + // otherwise the redirect unmounts the page before the Toaster renders + // (e.g. failed explicit sign-in, or session expired notification). + if (sessionStorage.getItem(OIDC_LOGIN_ATTEMPT_KEY)) return false; + if (sessionStorage.getItem(SESSION_EXPIRED_KEY)) return false; + return true; + }, [config.FRONTEND_SILENT_LOGIN_ENABLED, user]); useEffect(() => { if (user !== null) return; @@ -87,18 +98,35 @@ export const Auth = ({ } }, [user]); - // When the session is expired, display a toast to - // inform the user that they have been disconnected for that reason + // When the session is expired, display a toast to inform the user that + // they have been disconnected for that reason. Deferred until `user` is + // resolved so the Toaster has been mounted by the rendered children. useEffect(() => { - if (sessionStorage.getItem(SESSION_EXPIRED_KEY)) { - sessionStorage.removeItem(SESSION_EXPIRED_KEY); + if (user === undefined) return; + if (!sessionStorage.getItem(SESSION_EXPIRED_KEY)) return; + sessionStorage.removeItem(SESSION_EXPIRED_KEY); + addToast( + + {t('Your session has expired. Please log in again.')} + + ); + }, [user, t]); + + // After an explicit OIDC sign-in attempt, warn the user when no Messages + // account is associated with the authenticated identity (the backend + // redirects to the homepage unauthenticated in that case). + useEffect(() => { + if (user === undefined) return; + if (!sessionStorage.getItem(OIDC_LOGIN_ATTEMPT_KEY)) return; + sessionStorage.removeItem(OIDC_LOGIN_ATTEMPT_KEY); + if (user === null) { addToast( - - {t('Your session has expired. Please log in again.')} + + {t('No Messages account is associated with this identity. Please contact your administrator.')} - ) + ); } - }, []); + }, [user, t]); if (query.isLoading || shouldAttemptSilentLogin) { return ( diff --git a/src/frontend/src/features/config/constants.ts b/src/frontend/src/features/config/constants.ts index a4d82fbb4..1375a4b48 100644 --- a/src/frontend/src/features/config/constants.ts +++ b/src/frontend/src/features/config/constants.ts @@ -10,7 +10,7 @@ export enum PORTALS { export const DEFAULT_PAGE_SIZE = 20; // Default silent login retry interval in milliseconds -export const SILENT_LOGIN_RETRY_INTERVAL = 30 * 1000; // 30 seconds +export const SILENT_LOGIN_RETRY_INTERVAL = 60 * 1000; // 1 minute // Session storage keys export const APP_STORAGE_PREFIX = "messages_"; @@ -21,6 +21,7 @@ export const MESSAGE_IMPORT_TASK_KEY = APP_STORAGE_PREFIX + "message-import-task export const EXTERNAL_IMAGES_CONSENT_KEY = APP_STORAGE_PREFIX + "external-images-consent"; export const THREAD_SELECTED_FILTERS_KEY = APP_STORAGE_PREFIX + "thread-selected-filters"; export const SILENT_LOGIN_RETRY_KEY = APP_STORAGE_PREFIX + "silent-login-retry"; +export const OIDC_LOGIN_ATTEMPT_KEY = APP_STORAGE_PREFIX + "oidc-login-attempt"; export const EXPANDED_FOLDERS_KEY = APP_STORAGE_PREFIX + "expanded-folders";