From 5fe105b948e1d59c53dd723c2209c9ab7b2600a3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 5 Nov 2026 17:35:25 +1100 Subject: [PATCH] [UI] Add link rendering helper for plugin (#13193) - Adds reusable helpers for rendering sanitized links - Can be used by plugins --- src/frontend/CHANGELOG.md | 7 ++ src/frontend/lib/functions/Links.tsx | 98 +++++++++++++++++++ src/frontend/lib/index.ts | 6 ++ .../src/components/tables/ColumnRenderers.tsx | 44 +-------- 4 files changed, 116 insertions(+), 39 deletions(-) create mode 100644 src/frontend/lib/functions/Links.tsx diff --git a/src/frontend/CHANGELOG.md b/src/frontend/CHANGELOG.md index 2dd212b919..77a812074f 100644 --- a/src/frontend/CHANGELOG.md +++ b/src/frontend/CHANGELOG.md @@ -2,6 +2,13 @@ This file contains historical changelog information for the InvenTree UI components library. +### 1.8.0 - Unreleased + +#### New Features + +- Adds `renderLink` helper function, which renders a validated external URL (`http` / `https` only) as an `` element with a link icon +- Adds `sanitizeLinkUrl` helper function, which validates a URL for use as an external link + ### 1.7.0 - November 2026 Updated UI components library to match 1.7.0 InvenTree release. diff --git a/src/frontend/lib/functions/Links.tsx b/src/frontend/lib/functions/Links.tsx new file mode 100644 index 0000000000..af21d391b9 --- /dev/null +++ b/src/frontend/lib/functions/Links.tsx @@ -0,0 +1,98 @@ +import { Anchor, type AnchorProps } from '@mantine/core'; +import { IconLink } from '@tabler/icons-react'; +import type { ReactNode } from 'react'; +import { cancelEvent } from './Events'; + +// Only these URL protocols are permitted for rendered links +const ALLOWED_LINK_PROTOCOLS: string[] = ['http:', 'https:']; + +/** + * Validate a user-supplied URL for use as an external link. + * + * - The URL must be absolute and parseable + * - Only http and https protocols are permitted + * (this rejects e.g. javascript:, data:, vbscript:, file: URLs) + * + * @param url - The URL to validate + * @returns The normalized URL string if valid, otherwise null + */ +export function sanitizeLinkUrl(url: any): string | null { + if (typeof url !== 'string') { + return null; + } + + const value = url.trim(); + + if (!value) { + return null; + } + + try { + const parsed = new URL(value); + + if (!ALLOWED_LINK_PROTOCOLS.includes(parsed.protocol.toLowerCase())) { + return null; + } + + return parsed.href; + } catch { + return null; + } +} + +export type RenderLinkOptions = { + /** Icon size (default = 18) */ + size?: number; + /** Optional content to render instead of the link icon */ + children?: ReactNode; + /** Content to render if the URL is empty (default = '-') */ + placeholder?: ReactNode; + /** Additional props passed through to the Anchor component */ + anchorProps?: AnchorProps; +}; + +/** + * Render an external link as an element with a link icon. + * + * The URL is validated before rendering - only absolute http / https URLs + * are rendered as clickable links. Invalid or unsafe URLs are rendered + * as plain (escaped) text, so that the value is still visible to the user. + * + * Links are opened in a new tab, with "noopener" and "noreferrer" set. + * + * @param url - The URL to render + * @param options - Optional rendering options + */ +export function renderLink( + url: string | null | undefined, + options: RenderLinkOptions = {} +): ReactNode { + const placeholder = options.placeholder ?? '-'; + + if (!url || (typeof url === 'string' && !url.trim())) { + return placeholder; + } + + const href = sanitizeLinkUrl(url); + + if (!href) { + // Not a valid / permitted URL - render as plain text (React escapes this) + return String(url); + } + + return ( + { + cancelEvent(event); + window.open(href, '_blank', 'noopener,noreferrer'); + }} + > + {options.children ?? } + + ); +} diff --git a/src/frontend/lib/index.ts b/src/frontend/lib/index.ts index dc64c9c94d..58c5ad6184 100644 --- a/src/frontend/lib/index.ts +++ b/src/frontend/lib/index.ts @@ -75,6 +75,12 @@ export { navigateToLink } from './functions/Navigation'; +export { + renderLink, + sanitizeLinkUrl, + type RenderLinkOptions +} from './functions/Links'; + export { permissionDenied, invalidResponse, diff --git a/src/frontend/src/components/tables/ColumnRenderers.tsx b/src/frontend/src/components/tables/ColumnRenderers.tsx index c57d061e45..5ba70a84c7 100644 --- a/src/frontend/src/components/tables/ColumnRenderers.tsx +++ b/src/frontend/src/components/tables/ColumnRenderers.tsx @@ -2,27 +2,14 @@ * Common rendering functions for table column data. */ import { t } from '@lingui/core/macro'; -import { - Anchor, - Badge, - Center, - Group, - Skeleton, - Text, - Tooltip -} from '@mantine/core'; -import { - IconBell, - IconExclamationCircle, - IconLink, - IconLock -} from '@tabler/icons-react'; +import { Badge, Center, Group, Skeleton, Text, Tooltip } from '@mantine/core'; +import { IconBell, IconExclamationCircle, IconLock } from '@tabler/icons-react'; import { ProgressBar } from '@lib/components/ProgressBar'; import { YesNoButton } from '@lib/components/YesNoButton'; import type { ModelType } from '@lib/enums/ModelType'; import { resolveItem } from '@lib/functions/Conversion'; -import { cancelEvent } from '@lib/functions/Events'; +import { renderLink } from '@lib/functions/Links'; import type { TableColumn, TableColumnProps } from '@lib/types/Tables'; import type { ReactNode } from 'react'; import { @@ -508,29 +495,8 @@ export function LinkColumn(props: TableColumnProps): TableColumn { defaultVisible: false, copyable: true, copyAccessor: props.accessor ?? 'link', - render: (record: any) => { - const url = resolveItem(record, props.accessor ?? 'link'); - - if (!url) { - return '-'; - } - - return ( - { - cancelEvent(event); - - window.open(url, '_blank', 'noopener,noreferrer'); - }} - title={url} - > - - - ); - }, + render: (record: any) => + renderLink(resolveItem(record, props.accessor ?? 'link')), ...props }; }