fix: harden external link navigation in the web app

- add a shared external-navigation guard at the document level
- route onboarding, markdown, settings, and node source links through one helper
- stop browser fallback code from replacing the current app page on failures

Generated with Codex
This commit is contained in:
“BeeRad”
2026-03-17 18:20:37 +11:00
parent 213c054c9f
commit 7444599817
8 changed files with 164 additions and 41 deletions
+32 -4
View File
@@ -9,6 +9,7 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
import { Node, NodeConnection, Chunk } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons';
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source';
@@ -1811,13 +1812,23 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
) : nodesData[activeTab].link ? (
<a
href={nodesData[activeTab].link}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
startEdit('link', nodesData[activeTab].link || '');
return;
}
const link = nodesData[activeTab].link;
if (!link || !shouldOpenExternally(link)) {
return;
}
e.preventDefault();
void openExternalUrl(link).catch((error) => {
console.error('[FocusPanel] Failed to open node link', error);
window.alert(`Unable to open ${link}`);
});
}}
style={{
color: '#3b82f6',
@@ -1993,9 +2004,26 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
title="Connections"
>
<Link size={12} />
{activeTab && edgesData[activeTab] && edgesData[activeTab].length > 0 && (
<span>Edges</span>
{activeTab && (
<span style={{ fontWeight: 600 }}>
{edgesData[activeTab].length}
<span
style={{
minWidth: '18px',
height: '18px',
borderRadius: '999px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 6px',
background: activeContentTab === 'edges' ? 'rgba(34, 197, 94, 0.18)' : '#1a1a1a',
color: activeContentTab === 'edges' ? '#a7f3b8' : '#888',
fontSize: '10px',
lineHeight: 1,
}}
>
{(edgesData[activeTab] || []).length}
</span>
</span>
)}
</button>
@@ -3,6 +3,7 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
interface NodeLabelInlineProps {
id: string;
@@ -197,8 +198,17 @@ export default function MarkdownWithNodeTokens({ content, onNodeClick }: Markdow
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => {
if (!href || !shouldOpenExternally(href)) {
return;
}
event.preventDefault();
void openExternalUrl(href).catch((error) => {
console.error('[MarkdownWithNodeTokens] Failed to open external link', error);
window.alert(`Unable to open ${href}`);
});
}}
style={{ color: '#22c55e', textDecoration: 'underline' }}
>
{processChildren(children, 'a')}
+15 -5
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { isFirstRun, markFirstRunComplete } from '@/services/storage/apiKeys';
import { openExternalUrl } from '@/utils/openExternalUrl';
export default function FirstRunModal() {
const [isOpen, setIsOpen] = useState(false);
@@ -46,14 +47,18 @@ export default function FirstRunModal() {
<div style={noteStyle}>
<p>Without a key, you can still create and organise nodes manually.</p>
<p style={{ marginTop: 8 }}>
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => {
void openExternalUrl('https://platform.openai.com/api-keys').catch((error) => {
console.error('[FirstRunModal] Failed to open OpenAI API keys page', error);
window.alert('Unable to open the OpenAI API keys page automatically.');
});
}}
style={linkStyle}
>
Get an API key from OpenAI
</a>
</button>
</p>
</div>
</div>
@@ -148,4 +153,9 @@ const noteStyle: CSSProperties = {
const linkStyle: CSSProperties = {
color: '#22c55e',
textDecoration: 'none',
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
};
+15 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, type CSSProperties } from 'react';
import { openExternalUrl } from '@/utils/openExternalUrl';
export default function ApiKeysViewer() {
const [status, setStatus] = useState<'checking' | 'configured' | 'not-set'>('checking');
@@ -59,14 +60,18 @@ export default function ApiKeysViewer() {
{/* Get key link */}
<div style={helpStyle}>
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => {
void openExternalUrl('https://platform.openai.com/api-keys').catch((error) => {
console.error('[ApiKeysViewer] Failed to open OpenAI API keys page', error);
window.alert('Unable to open the OpenAI API keys page automatically.');
});
}}
style={linkStyle}
>
Get your API key from OpenAI
</a>
</button>
</div>
</div>
);
@@ -163,4 +168,9 @@ const helpStyle: CSSProperties = {
const linkStyle: CSSProperties = {
color: '#22c55e',
textDecoration: 'none',
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
font: 'inherit',
};
+7 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react';
import { Folder, Link as LinkIcon } from 'lucide-react';
import { Node } from '@/types/database';
import { openExternalUrl } from '@/utils/openExternalUrl';
interface NodeWithMetrics extends Node {
edge_count?: number;
@@ -440,7 +441,12 @@ export default function DatabaseViewer() {
{node.title || 'Untitled node'}
{node.link && (
<button
onClick={() => window.open(node.link, '_blank')}
onClick={() => {
void openExternalUrl(node.link!).catch((error) => {
console.error('[DatabaseViewer] Failed to open node link', error);
window.alert(`Unable to open ${node.link}`);
});
}}
title="Open original link"
style={{
background: 'transparent',
@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { openExternalUrl, shouldOpenExternally } from "@/utils/openExternalUrl";
export default function ExternalNavigationManager() {
useEffect(() => {
const handleDocumentClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const rawHref = anchor.getAttribute("href");
if (!rawHref || rawHref.startsWith("#") || anchor.hasAttribute("download")) {
return;
}
if (!shouldOpenExternally(rawHref)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(anchor.href || rawHref).catch((error) => {
console.error("[external] document click open failed", error);
window.alert(`Unable to open this link automatically.\n\n${anchor.href || rawHref}`);
});
};
document.addEventListener("click", handleDocumentClick, true);
return () => {
document.removeEventListener("click", handleDocumentClick, true);
};
}, []);
return null;
}