Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion env.d/development/backend.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion env.d/development/backend.e2e
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/frontend/public/locales/common/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/frontend/public/locales/common/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 62 additions & 15 deletions src/frontend/src/features/auth/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,38 @@ 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";

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;
// 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));
};

interface AuthContextInterface {
Expand Down Expand Up @@ -50,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;
Expand All @@ -68,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 (user === undefined) return;
if (!sessionStorage.getItem(SESSION_EXPIRED_KEY)) return;
sessionStorage.removeItem(SESSION_EXPIRED_KEY);
addToast(
<ToasterItem type="info">
{t('Your session has expired. Please log in again.')}
</ToasterItem>
);
}, [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 (sessionStorage.getItem(SESSION_EXPIRED_KEY)) {
sessionStorage.removeItem(SESSION_EXPIRED_KEY);
if (user === undefined) return;
if (!sessionStorage.getItem(OIDC_LOGIN_ATTEMPT_KEY)) return;
sessionStorage.removeItem(OIDC_LOGIN_ATTEMPT_KEY);
if (user === null) {
addToast(
<ToasterItem type="info">
{t('Your session has expired. Please log in again.')}
<ToasterItem type="warning">
{t('No Messages account is associated with this identity. Please contact your administrator.')}
</ToasterItem>
)
);
}
}, []);
}, [user, t]);

if (query.isLoading || shouldAttemptSilentLogin) {
return (
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/src/features/auth/silent-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
};

Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/features/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_";
Expand All @@ -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";


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
14 changes: 9 additions & 5 deletions src/frontend/src/features/ui/components/toaster/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down Expand Up @@ -28,7 +30,7 @@ export const ToasterItem = ({
type?: "error" | "info" | "warning";
actions?: ToastAction[];
} & Partial<ToastContentProps>) => {

const { t } = useTranslation();
const buttonColor = useMemo(() => {
switch (type) {
case "error":
Expand All @@ -39,6 +41,7 @@ export const ToasterItem = ({
return "brand";
}
}, [type]);

return (
<div
className={clsx(
Expand All @@ -52,21 +55,22 @@ export const ToasterItem = ({
{actions.map((action) => (
<Button
key={action.label}
aria-label={!action.showLabel ? action.label : undefined}
aria-label={!action.showLabel === false ? action.label : undefined}
onClick={action.onClick}
color={buttonColor}
variant="tertiary"
size="small"
icon={action.icon && <span className="material-icons">{action.icon}</span>}
>{action.showLabel || !action.icon && action.label}</Button>
icon={action.icon && <Icon name={action.icon} aria-hidden={true} />}
>{(action.showLabel === true || !action.icon) && action.label}</Button>
))}
{closeButton && (
<Button
onClick={closeToast}
color={buttonColor}
variant="tertiary"
size="small"
icon={<span className="material-icons">close</span>}
aria-label={t('Close')}
icon={<Icon name="close" />}
></Button>
)}
</div>
Expand Down
8 changes: 7 additions & 1 deletion src/frontend/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <MainLayout />;
}

const handleLogin = () => {
const raw = router.query.next;
login(typeof raw === "string" ? raw : undefined);
};

return (
<AppLayout
Expand All @@ -33,7 +39,7 @@ export default function HomePage() {
title={t("Simple and intuitive messaging")}
banner="/images/banner.webp"
subtitle={t("Send and receive your messages in an instant.")}
mainButton={<ProConnectButton onClick={login} />}
mainButton={<ProConnectButton onClick={handleLogin} />}
/>
</HomeGutter>
{themeConfig.footer && (
Expand Down