From 83b6aa48dcf497d16f91ba625a6d2e6c06005da9 Mon Sep 17 00:00:00 2001 From: andrewwallacespeckle Date: Wed, 16 Jul 2025 15:52:03 +0100 Subject: [PATCH] feat(fe): global external-link guard --- .../components/common/tiptap/TextEditor.vue | 58 +------------------ .../singleton/ExternalLinkDialog.vue | 39 +++++++++++++ .../components/singleton/Managers.vue | 1 + .../common/composables/externalLinkDialog.ts | 20 +++++++ .../plugins/externalLinkGuard.client.ts | 29 ++++++++++ 5 files changed, 90 insertions(+), 57 deletions(-) create mode 100644 packages/frontend-2/components/singleton/ExternalLinkDialog.vue create mode 100644 packages/frontend-2/lib/common/composables/externalLinkDialog.ts create mode 100644 packages/frontend-2/plugins/externalLinkGuard.client.ts diff --git a/packages/frontend-2/components/common/tiptap/TextEditor.vue b/packages/frontend-2/components/common/tiptap/TextEditor.vue index 5b3d8b460..290f2dfc5 100644 --- a/packages/frontend-2/components/common/tiptap/TextEditor.vue +++ b/packages/frontend-2/components/common/tiptap/TextEditor.vue @@ -6,7 +6,6 @@ 'text-editor flex flex-col relative', !!readonly ? 'text-editor--read-only' : '' ]" - @click.capture="onRootClick" > - - -

You're about to open the link below in a new tab:

-
- {{ externalLinkDialogUrl }} -
-

- This is an external website. Speckle is not responsible for its content or - security. -

-

Do you want to continue?

-
- ) const unlinkVisible = ref(false) -const externalLinkDialogOpen = ref(false) -const externalLinkDialogUrl = ref('') - -const externalLinkDialogButtons = computed((): LayoutDialogButton[] => [ - { - text: 'Cancel', - props: { color: 'outline' }, - onClick: () => (externalLinkDialogOpen.value = false) - }, - { - text: 'Continue', - props: { color: 'danger' }, - onClick: () => { - window.open(externalLinkDialogUrl.value, '_blank', 'noopener,noreferrer') - externalLinkDialogOpen.value = false - } - } -]) const isMultiLine = computed(() => !!props.schemaOptions?.multiLine) const isEditable = computed(() => !props.disabled && !props.readonly) @@ -161,22 +121,6 @@ const onEditorContentClick = (e: MouseEvent) => { e.stopPropagation() } -const onRootClick = (e: MouseEvent) => { - if (!props.readonly) return - - const anchor = (e.target as HTMLElement).closest('a') as Nullable - if (!anchor) return - - // Only react to left-clicks without modifier keys - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - - e.preventDefault() - const url = new URL(anchor.href, window.location.href) - if (url.origin === window.location.origin) return // treat as internal - externalLinkDialogUrl.value = anchor.href - externalLinkDialogOpen.value = true -} - // TODO: No profile page to link to in FE2 yet // const onMentionClick = (userId: string, e: MouseEvent) => { // if (!props.readonly) return diff --git a/packages/frontend-2/components/singleton/ExternalLinkDialog.vue b/packages/frontend-2/components/singleton/ExternalLinkDialog.vue new file mode 100644 index 000000000..72dea5d15 --- /dev/null +++ b/packages/frontend-2/components/singleton/ExternalLinkDialog.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/frontend-2/components/singleton/Managers.vue b/packages/frontend-2/components/singleton/Managers.vue index 4e3645ffe..8fce96a27 100644 --- a/packages/frontend-2/components/singleton/Managers.vue +++ b/packages/frontend-2/components/singleton/Managers.vue @@ -5,6 +5,7 @@ +
diff --git a/packages/frontend-2/lib/common/composables/externalLinkDialog.ts b/packages/frontend-2/lib/common/composables/externalLinkDialog.ts new file mode 100644 index 000000000..313c3b886 --- /dev/null +++ b/packages/frontend-2/lib/common/composables/externalLinkDialog.ts @@ -0,0 +1,20 @@ +type ExternalLinkState = { + open: boolean + url: string + _resolver?: (accepted: boolean) => void +} + +const useGlobalExternalLinkDialog = () => { + const state = useState('global_external_link', () => ({ + open: false, + url: '' + })) + + const confirm = (url: string) => + new Promise((resolve) => { + state.value = { open: true, url, _resolver: resolve } + }) + + return { state, confirm } +} +export { useGlobalExternalLinkDialog } diff --git a/packages/frontend-2/plugins/externalLinkGuard.client.ts b/packages/frontend-2/plugins/externalLinkGuard.client.ts new file mode 100644 index 000000000..44705e0f8 --- /dev/null +++ b/packages/frontend-2/plugins/externalLinkGuard.client.ts @@ -0,0 +1,29 @@ +import { useGlobalExternalLinkDialog } from '~/lib/common/composables/externalLinkDialog' + +export default defineNuxtPlugin(() => { + const { confirm } = useGlobalExternalLinkDialog() + + const handler = (e: MouseEvent) => { + // Ignore modified / non-left clicks + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const a = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null + if (!a) return + if (a.hasAttribute('data-no-external-confirm') || a.hasAttribute('download')) return + + const url = new URL(a.href, window.location.href) + + if (url.origin === window.location.origin) { + e.preventDefault() + + const path = url.pathname + url.search + url.hash + navigateTo(path) + return + } + + e.preventDefault() + void confirm(a.href) + } + + document.addEventListener('click', handler, true) +})