Site builder: security & data-loss hardening + unified asset picker + audit backlog #3

Merged
jknapp merged 61 commits from builder-hardening-2026-07 into main 2026-07-13 01:13:28 +00:00
4 changed files with 96 additions and 4 deletions
Showing only changes of commit 6421306849 - Show all commits
+29
View File
@@ -0,0 +1,29 @@
import { describe, test, expect, afterEach } from 'vitest';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
describe('clipboard', () => {
afterEach(() => {
setClipboardNodeId(null);
});
test('starts empty', () => {
expect(getClipboardNodeId()).toBeNull();
});
test('set then get returns the stored node id', () => {
setClipboardNodeId('node-123');
expect(getClipboardNodeId()).toBe('node-123');
});
test('is a shared module-level store -- overwriting replaces the previous value', () => {
setClipboardNodeId('first');
setClipboardNodeId('second');
expect(getClipboardNodeId()).toBe('second');
});
test('can be cleared back to null', () => {
setClipboardNodeId('node-123');
setClipboardNodeId(null);
expect(getClipboardNodeId()).toBeNull();
});
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Tiny shared clipboard for canvas node copy/paste.
*
* Both the context menu (right-click Copy/Paste) and the keyboard shortcuts
* hook (Ctrl/Cmd+C / Ctrl/Cmd+V) read and write this single module-level
* store, so copying a node via one entry point and pasting via the other
* behaves consistently instead of each maintaining its own clipboard.
*
* Deliberately not React state -- nothing in the UI needs to re-render
* reactively when the clipboard changes; consumers just read the current
* value at the moment they need it (on paste, or when a menu opens).
*/
let clipboardNodeId: string | null = null;
/** Returns the id of the node currently on the clipboard, or null if empty. */
export function getClipboardNodeId(): string | null {
return clipboardNodeId;
}
/** Sets (or clears, with `null`) the node id on the clipboard. */
export function setClipboardNodeId(nodeId: string | null): void {
clipboardNodeId = nodeId;
}
+40
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers'; import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree'; import { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
function isInputFocused(): boolean { function isInputFocused(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -85,6 +86,45 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+C: copy selected node id to the shared clipboard
if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardNodeId(selected[0]);
}
} catch (err) {
console.error('Copy failed:', err);
}
return;
}
// Ctrl+V: paste the clipboard node as a sibling of the current selection
if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
try {
const sourceId = getClipboardNodeId();
if (!sourceId || !query.node(sourceId).get()) return;
const selected = query.getEvent('selected').all();
if (selected.length === 0) return;
const selectedId = selected[0];
let targetParent = 'ROOT';
if (selectedId !== 'ROOT') {
const node = query.node(selectedId).get();
targetParent = node?.data?.parent || 'ROOT';
}
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
actions.addNodeTree(tree, targetParent);
} catch (err) {
console.error('Paste failed:', err);
}
return;
}
// Escape: deselect all // Escape: deselect all
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
@@ -4,6 +4,7 @@ import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree'; import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
interface ContextMenuProps { interface ContextMenuProps {
visible: boolean; visible: boolean;
@@ -32,7 +33,6 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const { actions, query } = useEditor(); const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal(); const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const clipboardRef = useRef<string | null>(null);
// Close on click outside // Close on click outside
useEffect(() => { useEffect(() => {
@@ -80,7 +80,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const copyNode = useCallback(() => { const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
try { try {
clipboardRef.current = nodeId; setClipboardNodeId(nodeId);
} catch (e) { } catch (e) {
console.error('Copy failed:', e); console.error('Copy failed:', e);
} }
@@ -88,7 +88,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, onClose]); }, [nodeId, onClose]);
const pasteNode = useCallback(() => { const pasteNode = useCallback(() => {
const sourceId = clipboardRef.current; const sourceId = getClipboardNodeId();
if (!sourceId) { if (!sourceId) {
onClose(); onClose();
return; return;
@@ -210,7 +210,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
label: 'Paste', label: 'Paste',
shortcut: 'Ctrl+V', shortcut: 'Ctrl+V',
action: pasteNode, action: pasteNode,
disabled: !clipboardRef.current, disabled: !getClipboardNodeId(),
dividerAfter: true, dividerAfter: true,
}, },
{ {