-
Notifications
You must be signed in to change notification settings - Fork 25
✨(frontend) Add a link preview modal #651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { useState, useCallback, useRef } from "react"; | ||
| import i18n from "@/features/i18n/initI18n"; | ||
| import { Button, Modal, ModalSize, Alert, VariantType, iconFromType } from "@gouvfr-lasuite/cunningham-react"; | ||
| import classNames from "classnames"; | ||
| import { Trans, useTranslation } from "react-i18next"; | ||
| /** | ||
| * Modal component to show a preview of a link. | ||
| * <a href={url}>{linkText}</a> | ||
| * | ||
| * @param isOpen - Whether the modal is open | ||
| * @param onClose - Function to call when the modal is closed | ||
| * @param url - The URL to preview | ||
| * @param linkText - The text of the link (optional) | ||
| * @param hardWarning - Whether to show a more prominent warning | ||
| * @param decision - Function to call with the user's confirmation choice | ||
| */ | ||
| type LinkPreviewModalProps = { | ||
| isOpen: boolean; | ||
| url: string; | ||
| hardWarning?: boolean; | ||
| decision: (choice: boolean) => void; | ||
| } | ||
|
|
||
| /** | ||
| * Confirmation modal before redirecting to an external link. | ||
| * It alerts the user about potential risks (phishing, etc.). | ||
| */ | ||
| export const LinkPreviewModal = ({ isOpen, url, hardWarning, decision }: LinkPreviewModalProps) => { | ||
| const { t } = useTranslation(); | ||
| return ( | ||
| <Modal | ||
| isOpen={isOpen} | ||
| size={ModalSize.SMALL} | ||
| title={( | ||
| <span className="c__modal__text--centered">{ | ||
| hardWarning | ||
| ? i18n.t('Be careful!') | ||
| : i18n.t('This links redirects to :') | ||
| }</span> | ||
| )} | ||
| titleIcon={hardWarning && ( | ||
| <span | ||
| className="material-icons modal-message-error-icon" | ||
| > | ||
| {iconFromType(VariantType.WARNING)} | ||
| </span> | ||
| )} | ||
| hideCloseButton={true} | ||
| actions={[ | ||
| <Button | ||
| key="cancel" | ||
| variant={hardWarning ? "primary" : "tertiary"} | ||
| onClick={() => decision(false)} | ||
| > | ||
| {t("Cancel")} | ||
| </Button>, | ||
| <Button | ||
| key="confirm" | ||
| variant={hardWarning ? "tertiary" : "primary"} | ||
| color={hardWarning ? "error" : "neutral"} | ||
| onClick={() => decision(true)} | ||
| > | ||
| {t("Open the link")} | ||
| </Button> | ||
| ]} | ||
| onClose={() => decision(false)} | ||
| closeOnClickOutside={true} | ||
| > | ||
| <div className="link-preview__children"> | ||
| {hardWarning && i18n.t('The link you clicked is probably unsafe :')} | ||
| <Alert type={hardWarning ? VariantType.WARNING : VariantType.NEUTRAL}>{url}</Alert> | ||
| <p className="link-preview__phishing-notice"> | ||
| <Trans i18nKey="phishing_notice"> | ||
| Be careful when clicking links in email, it could be a | ||
| <a href={`https://www.service-public.gouv.fr/particuliers/vosdroits/F34800`}> | ||
| phishing attempt | ||
| </a>. | ||
| </Trans> | ||
| </p> | ||
| </div> | ||
| </Modal > | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Hook to manage the state and logic of the link preview modal. | ||
| * Exposes an asynchronous `askConfirmation` function that waits for user action. | ||
| * | ||
| * @returns An object containing: | ||
| * - `askConfirmation`: an async function that opens the modal and returns a boolean (`true` if confirmed) | ||
| * - `modal`: the React node of the modal to be injected into the component tree | ||
| */ | ||
| export const useLinkPreviewModal = () => { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [url, setUrl] = useState(''); | ||
| const [hardWarning, setHardWarning] = useState(false); | ||
| const resolverRef = useRef<((choice: boolean) => void) | null>(null); | ||
|
|
||
| const askConfirmation = useCallback((urlToPreview: string, isHardWarning: boolean = false, textToPreview?: string) => { | ||
| setUrl(urlToPreview); | ||
| setHardWarning(isHardWarning); | ||
| setIsOpen(true); | ||
|
|
||
| return new Promise<boolean>((resolve) => { | ||
| resolverRef.current = resolve; | ||
| }); | ||
| }, []); | ||
|
|
||
| const decision = useCallback((choice: boolean) => { | ||
| setIsOpen(false); | ||
| if (resolverRef.current) { | ||
| resolverRef.current(choice); | ||
| resolverRef.current = null; | ||
| } | ||
| }, []); | ||
|
|
||
| const modal = isOpen ? ( | ||
| <LinkPreviewModal | ||
| isOpen={isOpen} | ||
| url={url} | ||
| hardWarning={hardWarning} | ||
| decision={decision} | ||
| /> | ||
| ) : null; | ||
|
|
||
| return { askConfirmation, modal }; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getRequestUrl, getApiOrigin } from "@/features/api/utils"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { getBlobDownloadRetrieveUrl } from "@/features/api/gen/blob/blob"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { UnquoteMessage } from '@/features/utils/unquote-message'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useTranslation } from "react-i18next"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Trans, useTranslation } from "react-i18next"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { tokens } from '@/styles/cunningham-tokens' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useTheme } from "@/features/providers/theme"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useConfig } from "@/features/providers/config"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -14,6 +14,11 @@ import { getMailboxesImageProxyListUrl } from "@/features/api/gen/mailboxes/mail | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { EXTERNAL_IMAGES_CONSENT_KEY } from "@/features/config/constants"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { renderBodyParts } from "./renderers"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ThreadMessageBodyProps } from "./types"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { LinkPreviewModal, useLinkPreviewModal } from "./link-preview-modal"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Alert, Button, iconFromType, VariantType } from "@gouvfr-lasuite/cunningham-react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { handle } from "@/features/utils/errors"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { set } from "zod"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import Link from "next/link"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CSP = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Allow images from our domain, data URIs, and API endpoints | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -44,7 +49,7 @@ const CSP = [ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ].join('; '); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, messageId, onLoad }: ThreadMessageBodyProps) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { t } = useTranslation(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { t, i18n } = useTranslation(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const iframeRef = useRef<HTMLIFrameElement>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { cunninghamTheme, variant } = useTheme(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { selectedMailbox } = useMailboxContext(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -64,6 +69,8 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setDisplayExternalImages(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { askConfirmation: askLinkConfirmation, modal: linkConfirmationModal } = useLinkPreviewModal(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Build CID to blob URL mapping for inline image resolution | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cidToBlobUrlMap = useMemo(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const map = new Map<string, string>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -321,10 +328,28 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| doc.querySelectorAll('details.email-quoted-content').forEach(node => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| node.addEventListener('toggle', resizeIframe); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Handle link clicks | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| doc.querySelectorAll('a').forEach(link => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| link.addEventListener('click', (e) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (link.href != link.textContent) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handleLinkClick(link.href, link.textContent); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onLoad?.(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [onLoad, resizeIframe]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleLinkClick = async (url: string, text: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lang = i18n.language.split('-')[0]; // Get base language for documentation link | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const decision = await askLinkConfirmation(url, false, text); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (decision) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.open(url, '_blank'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gate every email link behind the confirmation flow.
🛡️ Suggested fix // Handle link clicks
doc.querySelectorAll('a').forEach(link => {
link.addEventListener('click', (e) => {
- if (link.href != link.textContent) {
- e.preventDefault();
- handleLinkClick(link.href, link.textContent);
- }
+ e.preventDefault();
+ const visibleText = link.textContent?.trim() ?? '';
+ const isMaskedLink = visibleText !== '' && visibleText !== link.href;
+ void handleLinkClick(link.href, visibleText, isMaskedLink);
});
});
}
onLoad?.();
}, [onLoad, resizeIframe]);
- const handleLinkClick = async (url: string, text: string) => {
- const lang = i18n.language.split('-')[0]; // Get base language for documentation link
- const decision = await askLinkConfirmation(url, false, text);
+ const handleLinkClick = async (url: string, text: string, hardWarning: boolean) => {
+ const decision = await askLinkConfirmation(url, hardWarning, text);
if (decision) {
window.open(url, '_blank');
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
My concerns are withdrawn. Thanks for the context! ✏️ Learnings added
🧠 Learnings usedThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's confirm the file exists and examine the specific lines
if [ -f "src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx" ]; then
echo "File exists"
wc -l "src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx"
echo "--- Lines 340-360 ---"
sed -n '340,360p' "src/frontend/src/features/layouts/components/thread-view/components/thread-message/thread-message-body.tsx"
else
echo "File not found"
fiRepository: suitenumerique/messages Length of output: 991 🏁 Script executed: # Search for all window.open calls in the codebase to identify similar patterns
rg "window\.open\s*\(" --type tsx --type ts -nRepository: suitenumerique/messages Length of output: 95 🌐 Web query:
💡 Result: For tabnabbing protection when opening a new tab/window with JavaScript, the key issue is whether the newly opened page gets access to the opener via window.opener. 1) The vulnerability (tabnabbing / reverse tabnabbing) - If window.opener is available, the opened (possibly malicious) page can navigate the original page (the opener), e.g. by changing opener.location, which enables phishing-style attacks. - OWASP describes reverse tabnabbing as being possible when the source page opens another page (e.g., via target="_blank" without preventative measures), allowing the linked page to rewrite the target page. 2) How to prevent it with window.open (not just links) A) Use the noopener window feature in window.open - Modern browsers support a “noopener” feature in the third argument of window.open. - MDN states: if noopener is set, the new window will not have access to the originating window via Window.opener and it returns null. Example: const w = window.open('https://example.com', '_blank', 'noopener'); B) Use noreferrer if you also want to suppress the Referer header - MDN states: if “noreferrer” is set in window.open features, the browser omits the Referer header and also sets noopener to true. Example: window.open('https://example.com', '_blank', 'noreferrer'); C) Fallback / defense-in-depth: explicitly clear opener on the returned window - Even with feature support differences, a common fallback is to set the opened window’s opener to null. - (Note: this may have popup-timing/behavior quirks in some browsers; but it’s the conceptual mitigation.) Example pattern: const w = window.open('https://example.com', '_blank', ''); w.opener = null; 3) For HTML links (contrast) - For elements, rel="noopener"/rel="noreferrer" prevents the opened page from accessing the opener (window.opener becomes null). - MDN explains that rel="noopener" prevents access to the document that opened it by not setting Window.opener. Practical recommendation - For external/untrusted destinations opened via window.open, pass 'noopener' (or 'noreferrer' if you want to avoid sending the Referer header). - For maximum robustness on older/edge environments, consider additionally clearing w.opener = null right after opening. Citations:
🏁 Script executed: # Search for window.open calls without file type restrictions (rg will auto-detect)
rg "window\.open\s*\(" -A 1 -B 1 --no-type-listRepository: suitenumerique/messages Length of output: 511 🏁 Script executed: # Alternatively, search in the frontend directory more specifically
find src/frontend -name "*.ts" -o -name "*.tsx" | xargs grep -n "window.open" 2>/dev/null | head -20Repository: suitenumerique/messages Length of output: 450 🏁 Script executed: # Let's examine the context around the use-print.tsx window.open call
sed -n '55,75p' "src/frontend/src/features/message/use-print.tsx"Repository: suitenumerique/messages Length of output: 770 🏁 Script executed: # Let's also check if there are any other window.open calls we might have missed
rg "window\.open" -nRepository: suitenumerique/messages Length of output: 450 Prevent tabnabbing by using
Suggested fix- window.open(url, '_blank');
+ window.open(url, '_blank', 'noopener,noreferrer');📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleMessage = (event: MessageEvent) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (event.data === 'iframe-loaded') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -380,6 +405,7 @@ const ThreadMessageBody = ({ bodyParts, attachments = [], isHidden = false, mess | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onLoad={handleIframeLoad} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {linkConfirmationModal} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.