Skip to content

Commit 326d1ff

Browse files
✨(frontend) Add a link preview modal
Instead of opening the link directly when clicked on in an email, a modal now shows the true URL and asks to confirm. Signed-off-by: Valentin Regnault <valentinregnault22@gmail.com>
1 parent 1044614 commit 326d1ff

6 files changed

Lines changed: 186 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ and this project adheres to
1818
- Add unread and starred filters in thread panel #581
1919
- Add better filtering and granularity for usage metrics
2020
- Expose `oidc_autojoin` and `identity_sync` flags in provisioning API
21+
- Add a link preview modal : instead of opening the link directly when clicked on in an email, a modal now shows the true URL and asks to confirm.
22+
2123

2224
### Changed
2325

src/frontend/public/locales/common/en-US.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
"Back": "Back",
151151
"Back to your inbox": "Back to your inbox",
152152
"BCC: ": "BCC: ",
153+
"Be careful!": "Be careful!",
153154
"Blind copy: ": "Blind copy: ",
154155
"Calendar invite": "Calendar invite",
155156
"Cancel": "Cancel",
@@ -432,6 +433,7 @@
432433
"On going": "On going",
433434
"Open {{driveAppName}} preview": "Open {{driveAppName}} preview",
434435
"Open filters": "Open filters",
436+
"Open the link": "Open the link",
435437
"Open the menu": "Open the menu",
436438
"Or": "Or",
437439
"or drag and drop some files": "or drag and drop some files",
@@ -442,6 +444,7 @@
442444
"Password reset successfully!": "Password reset successfully!",
443445
"Personal mailbox": "Personal mailbox",
444446
"Personal mailboxes cannot be created when identity synchronization is disabled.": "Personal mailboxes cannot be created when identity synchronization is disabled.",
447+
"phishing_notice": "Be careful when clicking links in email, it could be a <1> phishing attempt</1>.",
445448
"Please enter a valid email address.": "Please enter a valid email address.",
446449
"Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Prefix can only contain letters, numbers, dots, underscores and hyphens.",
447450
"Prefix is required.": "Prefix is required.",
@@ -553,6 +556,7 @@
553556
"The email {{email}} is invalid.": "The email {{email}} is invalid.",
554557
"The email address is invalid.": "The email address is invalid.",
555558
"The forced signature will be the only one usable for new messages.": "The forced signature will be the only one usable for new messages.",
559+
"The link you clicked is probably unsafe :": "The link you clicked is probably unsafe :",
556560
"The message could not be sent.": "The message could not be sent.",
557561
"The message could not be sent. Please try again later.": "The message could not be sent. Please try again later.",
558562
"The personal mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "The personal mailbox <1>{{mailboxAddress}}</1> has been created successfully.",
@@ -567,6 +571,7 @@
567571
"This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.",
568572
"This event has been cancelled": "This event has been cancelled",
569573
"This is the only admin of this mailbox, you cannot therefore modify its access.": "This is the only admin of this mailbox, you cannot therefore modify its access.",
574+
"This links redirects to :": "This links redirects to :",
570575
"This message has {{count}} attachments_one": "This message has one attachment",
571576
"This message has {{count}} attachments_other": "This message has {{count}} attachments",
572577
"This message has a draft": "This message has a draft",
@@ -645,4 +650,4 @@
645650
"Your email...": "Your email...",
646651
"Your messages have been imported successfully!": "Your messages have been imported successfully!",
647652
"Your session has expired. Please log in again.": "Your session has expired. Please log in again."
648-
}
653+
}

src/frontend/public/locales/common/fr-FR.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@
192192
"Back": "Précédent",
193193
"Back to your inbox": "Retour à votre messagerie",
194194
"BCC: ": "CCI : ",
195+
"Be careful!": "Attention !",
195196
"Blind copy: ": "Copie cachée : ",
196197
"Calendar invite": "Invitation calendrier",
197198
"Cancel": "Annuler",
@@ -479,6 +480,7 @@
479480
"On going": "En cours",
480481
"Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}",
481482
"Open filters": "Ouvrir les filtres",
483+
"Open the link": "Ouvrir le lien",
482484
"Open the menu": "Ouvrir le menu",
483485
"Or": "Ou",
484486
"or drag and drop some files": "ou glissez-déposez des fichiers",
@@ -489,6 +491,7 @@
489491
"Password reset successfully!": "Mot de passe réinitialisé avec succès !",
490492
"Personal mailbox": "Boîte personnelle",
491493
"Personal mailboxes cannot be created when identity synchronization is disabled.": "Les boîtes aux lettres personnelles ne peuvent pas être créées lorsque la synchronisation d'identité est désactivée.",
494+
"phishing_notice": "Soyez prudent en cliquant sur les liens dans les emails, il pourrait s'agir d'une <1> tentative de phishing</1>",
492495
"Please enter a valid email address.": "Veuillez saisir une adresse email valide.",
493496
"Prefix can only contain letters, numbers, dots, underscores and hyphens.": "Le préfixe ne peut contenir que des lettres, chiffres, points, tirets bas et tirets.",
494497
"Prefix is required.": "Le préfixe est requis.",
@@ -603,6 +606,7 @@
603606
"The email {{email}} is invalid.": "Le courriel {{email}} est invalide.",
604607
"The email address is invalid.": "L'adresse email est invalide.",
605608
"The forced signature will be the only one usable for new messages.": "La signature forcée sera la seule utilisable pour les nouveaux messages.",
609+
"The link you clicked is probably unsafe :": "Le lien sur lequel vous avez cliqué est probablement dangereux :",
606610
"The message could not be sent.": "Le message n'a pas pu être envoyé.",
607611
"The message could not be sent. Please try again later.": "Le message n'a pas pu être envoyé. Veuillez réessayer plus tard.",
608612
"The personal mailbox <strong>{{mailboxAddress}}</strong> has been created successfully.": "L'adresse personnelle <strong>{{mailboxAddress}}</strong> a été créée avec succès.",
@@ -617,6 +621,7 @@
617621
"This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Ce préfixe d'adresse n'est pas autorisé pour les boîtes aux lettres personnelles. Veuillez choisir un autre préfixe.",
618622
"This event has been cancelled": "Cet événement a été annulé",
619623
"This is the only admin of this mailbox, you cannot therefore modify its access.": "C'est le seul administrateur de cette boîte aux lettres, vous ne pouvez donc pas modifier son accès.",
624+
"This links redirects to :": "Ce lien redirige vers :",
620625
"This message has {{count}} attachments_one": "Ce message a une pièce jointe",
621626
"This message has {{count}} attachments_many": "Ce message a {{count}} pièces jointes",
622627
"This message has {{count}} attachments_other": "Ce message a {{count}} pièces jointes",
@@ -688,6 +693,7 @@
688693
"You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "Vous pouvez désormais prévenir la personne que sa boîte aux lettres est prête à être utilisée et lui communiquer les instructions pour s'authentifier.",
689694
"You cannot delete the last editor of this thread": "Vous ne pouvez pas supprimer le dernier éditeur de cette conversation",
690695
"You cannot modify it.": "Vous ne pouvez pas la modifier.",
696+
"You clicked on the link \"{{linkText}}\" which redirects to :": "Vous avez cliqué sur le lien \"{{linkText}}\" qui redirige vers :",
691697
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._one": "Vous avez {{count}} destinataire, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.",
692698
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._many": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.",
693699
"You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.",
@@ -697,4 +703,4 @@
697703
"Your email...": "Renseigner votre email...",
698704
"Your messages have been imported successfully!": "Vos messages ont été importés avec succès !",
699705
"Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter."
700-
}
706+
}

src/frontend/src/features/layouts/components/thread-view/_index.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,19 @@
146146
top: 0;
147147
z-index: 2;
148148
}
149+
150+
.c__modal__title-icon .material-icons {
151+
font-size: var(--icon-size);
152+
}
153+
154+
.link-preview__children {
155+
display: flex;
156+
flex-direction: column;
157+
gap: var(--c--globals--spacings--xs);
158+
}
159+
160+
.link-preview__phishing-notice {
161+
font-size: var(--c--globals--font--sizes--xs);
162+
color: var(--c--contextuals--content--semantic--neutral--secondary);
163+
text-align: left;
164+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { useState, useCallback, useRef } from "react";
2+
import i18n from "@/features/i18n/initI18n";
3+
import { Button, Modal, ModalSize, Alert, VariantType, iconFromType } from "@gouvfr-lasuite/cunningham-react";
4+
import classNames from "classnames";
5+
import { Trans, useTranslation } from "react-i18next";
6+
/**
7+
* Modal component to show a preview of a link.
8+
* <a href={url}>{linkText}</a>
9+
*
10+
* @param isOpen - Whether the modal is open
11+
* @param onClose - Function to call when the modal is closed
12+
* @param url - The URL to preview
13+
* @param linkText - The text of the link (optional)
14+
* @param hardWarning - Whether to show a more prominent warning
15+
* @param decision - Function to call with the user's confirmation choice
16+
*/
17+
type LinkPreviewModalProps = {
18+
isOpen: boolean;
19+
url: string;
20+
hardWarning?: boolean;
21+
decision: (choice: boolean) => void;
22+
}
23+
24+
/**
25+
* Confirmation modal before redirecting to an external link.
26+
* It alerts the user about potential risks (phishing, etc.).
27+
*/
28+
export const LinkPreviewModal = ({ isOpen, url, hardWarning, decision }: LinkPreviewModalProps) => {
29+
const { t } = useTranslation();
30+
return (
31+
<Modal
32+
isOpen={isOpen}
33+
size={ModalSize.SMALL}
34+
title={(
35+
<span className="c__modal__text--centered">{
36+
hardWarning
37+
? i18n.t('Be careful!')
38+
: i18n.t('This links redirects to :')
39+
}</span>
40+
)}
41+
titleIcon={hardWarning && (
42+
<span
43+
className="material-icons modal-message-error-icon"
44+
>
45+
{iconFromType(VariantType.WARNING)}
46+
</span>
47+
)}
48+
hideCloseButton={true}
49+
actions={[
50+
<Button
51+
key="cancel"
52+
variant={hardWarning ? "primary" : "tertiary"}
53+
onClick={() => decision(false)}
54+
>
55+
{t("Cancel")}
56+
</Button>,
57+
<Button
58+
key="confirm"
59+
variant={hardWarning ? "tertiary" : "primary"}
60+
color={hardWarning ? "error" : "neutral"}
61+
onClick={() => decision(true)}
62+
>
63+
{t("Open the link")}
64+
</Button>
65+
]}
66+
onClose={() => decision(false)}
67+
closeOnClickOutside={true}
68+
>
69+
<div className="link-preview__children">
70+
{hardWarning && i18n.t('The link you clicked is probably unsafe :')}
71+
<Alert type={hardWarning ? VariantType.WARNING : VariantType.NEUTRAL}>{url}</Alert>
72+
<p className="link-preview__phishing-notice">
73+
<Trans i18nKey="phishing_notice">
74+
Be careful when clicking links in email, it could be a
75+
<a href={`https://www.service-public.gouv.fr/particuliers/vosdroits/F34800`}>
76+
phishing attempt
77+
</a>.
78+
</Trans>
79+
</p>
80+
</div>
81+
</Modal >
82+
)
83+
}
84+
85+
/**
86+
* Hook to manage the state and logic of the link preview modal.
87+
* Exposes an asynchronous `askConfirmation` function that waits for user action.
88+
*
89+
* @returns An object containing:
90+
* - `askConfirmation`: an async function that opens the modal and returns a boolean (`true` if confirmed)
91+
* - `modal`: the React node of the modal to be injected into the component tree
92+
*/
93+
export const useLinkPreviewModal = () => {
94+
const [isOpen, setIsOpen] = useState(false);
95+
const [url, setUrl] = useState('');
96+
const [hardWarning, setHardWarning] = useState(false);
97+
const resolverRef = useRef<((choice: boolean) => void) | null>(null);
98+
99+
const askConfirmation = useCallback((urlToPreview: string, isHardWarning: boolean = false, textToPreview?: string) => {
100+
setUrl(urlToPreview);
101+
setHardWarning(isHardWarning);
102+
setIsOpen(true);
103+
104+
return new Promise<boolean>((resolve) => {
105+
resolverRef.current = resolve;
106+
});
107+
}, []);
108+
109+
const decision = useCallback((choice: boolean) => {
110+
setIsOpen(false);
111+
if (resolverRef.current) {
112+
resolverRef.current(choice);
113+
resolverRef.current = null;
114+
}
115+
}, []);
116+
117+
const modal = isOpen ? (
118+
<LinkPreviewModal
119+
isOpen={isOpen}
120+
url={url}
121+
hardWarning={hardWarning}
122+
decision={decision}
123+
/>
124+
) : null;
125+
126+
return { askConfirmation, modal };
127+
}

src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
33
import { getRequestUrl, getApiOrigin } from "@/features/api/utils";
44
import { getBlobDownloadRetrieveUrl } from "@/features/api/gen/blob/blob";
55
import { UnquoteMessage } from '@/features/utils/unquote-message';
6-
import { useTranslation } from "react-i18next";
6+
import { Trans, useTranslation } from "react-i18next";
77
import { tokens } from '@/styles/cunningham-tokens'
88
import { useTheme } from "@/features/providers/theme";
99
import { useConfig } from "@/features/providers/config";
@@ -14,6 +14,11 @@ import { getMailboxesImageProxyListUrl } from "@/features/api/gen/mailboxes/mail
1414
import { EXTERNAL_IMAGES_CONSENT_KEY } from "@/features/config/constants";
1515
import { renderBodyParts } from "./renderers";
1616
import { ThreadMessageBodyProps } from "./types";
17+
import { LinkPreviewModal, useLinkPreviewModal } from "./link-preview-modal";
18+
import { Alert, Button, iconFromType, VariantType } from "@gouvfr-lasuite/cunningham-react";
19+
import { handle } from "@/features/utils/errors";
20+
import { set } from "zod";
21+
import Link from "next/link";
1722

1823
const CSP = [
1924
// Allow images from our domain, data URIs, and API endpoints
@@ -44,7 +49,7 @@ const CSP = [
4449
].join('; ');
4550

4651
const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, messageId, onLoad }: ThreadMessageBodyProps) => {
47-
const { t } = useTranslation();
52+
const { t, i18n } = useTranslation();
4853
const iframeRef = useRef<HTMLIFrameElement>(null);
4954
const { cunninghamTheme, variant } = useTheme();
5055
const { selectedMailbox } = useMailboxContext();
@@ -64,6 +69,8 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess
6469
setDisplayExternalImages(true);
6570
};
6671

72+
const { askConfirmation: askLinkConfirmation, modal: linkConfirmationModal } = useLinkPreviewModal();
73+
6774
// Build CID to blob URL mapping for inline image resolution
6875
const cidToBlobUrlMap = useMemo(() => {
6976
const map = new Map<string, string>();
@@ -321,10 +328,28 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess
321328
doc.querySelectorAll('details.email-quoted-content').forEach(node => {
322329
node.addEventListener('toggle', resizeIframe);
323330
});
331+
332+
// Handle link clicks
333+
doc.querySelectorAll('a').forEach(link => {
334+
link.addEventListener('click', (e) => {
335+
if (link.href != link.textContent) {
336+
e.preventDefault();
337+
handleLinkClick(link.href, link.textContent);
338+
}
339+
});
340+
});
324341
}
325342
onLoad?.();
326343
}, [onLoad, resizeIframe]);
327344

345+
const handleLinkClick = async (url: string, text: string) => {
346+
const lang = i18n.language.split('-')[0]; // Get base language for documentation link
347+
const decision = await askLinkConfirmation(url, false, text);
348+
if (decision) {
349+
window.open(url, '_blank');
350+
}
351+
}
352+
328353
useEffect(() => {
329354
const handleMessage = (event: MessageEvent) => {
330355
if (event.data === 'iframe-loaded') {
@@ -380,6 +405,7 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess
380405
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
381406
onLoad={handleIframeLoad}
382407
/>
408+
{linkConfirmationModal}
383409
</>
384410
)
385411
}

0 commit comments

Comments
 (0)