feat(fe): global external-link guard

This commit is contained in:
andrewwallacespeckle
2025-07-16 15:52:03 +01:00
parent b59f232ed3
commit 83b6aa48dc
5 changed files with 90 additions and 57 deletions
@@ -6,7 +6,6 @@
'text-editor flex flex-col relative',
!!readonly ? 'text-editor--read-only' : ''
]"
@click.capture="onRootClick"
>
<FormButton
v-if="unlinkVisible"
@@ -18,23 +17,6 @@
Remove link
</FormButton>
<LayoutDialog
v-model:open="externalLinkDialogOpen"
max-width="xs"
:buttons="externalLinkDialogButtons"
>
<template #header>Leaving Speckle</template>
<p class="mb-2">You're about to open the link below in a new tab:</p>
<div class="p-3 bg-highlight-2 rounded-md font-mono break-all">
{{ externalLinkDialogUrl }}
</div>
<p class="mt-2 mb-4">
This is an external website. Speckle is not responsible for its content or
security.
</p>
<p class="font-medium">Do you want to continue?</p>
</LayoutDialog>
<EditorContent
ref="editorContentRef"
class="simple-scrollbar flex flex-1"
@@ -60,11 +42,7 @@ import type { Nullable } from '@speckle/shared'
// import { userProfileRoute } from '~~/lib/common/helpers/route'
import { onKeyDown } from '@vueuse/core'
import { noop } from 'lodash-es'
import {
FormButton,
LayoutDialog,
type LayoutDialogButton
} from '@speckle/ui-components'
import { FormButton } from '@speckle/ui-components'
const emit = defineEmits<{
(e: 'update:modelValue', val: JSONContent): void
@@ -89,24 +67,6 @@ const props = defineProps<{
const editorContentRef = ref(null as Nullable<HTMLElement>)
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<HTMLAnchorElement>
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
@@ -0,0 +1,39 @@
<template>
<LayoutDialog v-model:open="state.open" max-width="xs" :buttons="buttons">
<template #header>Leaving Speckle</template>
<p class="mb-2">You're about to open the link below in a new tab:</p>
<div class="p-3 bg-highlight-2 rounded-md font-mono break-all">
{{ state.url }}
</div>
<p class="mt-2 mb-4">
This is an external website. Speckle is not responsible for its content or
security.
</p>
<p class="font-medium">Do you want to continue?</p>
</LayoutDialog>
</template>
<script setup lang="ts">
import { useGlobalExternalLinkDialog } from '~/lib/common/composables/externalLinkDialog'
import type { LayoutDialogButton } from '@speckle/ui-components'
const { state } = useGlobalExternalLinkDialog()
const buttons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
onClick: () => {
state.value.open = false
state.value._resolver?.(false)
}
},
{
text: 'Continue',
props: { color: 'danger' },
onClick: () => {
window.open(state.value.url, '_blank', 'noopener,noreferrer')
state.value.open = false
state.value._resolver?.(true)
}
}
])
</script>
@@ -5,6 +5,7 @@
</ClientOnly>
<SingletonFileUploadErrorDialog />
<SingletonAppErrorStateManager />
<SingletonExternalLinkDialog />
</div>
<div v-else />
</template>
@@ -0,0 +1,20 @@
type ExternalLinkState = {
open: boolean
url: string
_resolver?: (accepted: boolean) => void
}
const useGlobalExternalLinkDialog = () => {
const state = useState<ExternalLinkState>('global_external_link', () => ({
open: false,
url: ''
}))
const confirm = (url: string) =>
new Promise<boolean>((resolve) => {
state.value = { open: true, url, _resolver: resolve }
})
return { state, confirm }
}
export { useGlobalExternalLinkDialog }
@@ -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)
})