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
Showing only changes of commit 7973ee9ba8 - Show all commits
+83 -104
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
@@ -50,6 +50,15 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
const [applyDesign, setApplyDesign] = useState(true); const [applyDesign, setApplyDesign] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Guards state updates after the component has unmounted mid-sequence.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Close on Escape key // Close on Escape key
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -112,73 +121,63 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
} }
}, [query, actions]); }, [query, actions]);
/** Promise-based `setTimeout` — lets the staged apply steps below be awaited
* in sequence instead of chained via nested callbacks. */
const wait = useCallback((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)), []);
/**
* Switch to a zone (header/footer), clear it, and add the template's
* components, giving Craft.js a tick to settle between each step.
*/
const applyZone = useCallback(
async (switchFn: () => void, components: TemplateComponent[]) => {
switchFn();
await wait(30);
try {
clearCanvas();
await wait(20);
try {
addTemplateComponents(components);
} catch (e) {
console.warn('Failed to add zone components:', e);
}
} catch (e) {
console.warn('Failed to clear zone:', e);
}
await wait(30);
},
[wait, clearCanvas, addTemplateComponents],
);
/** /**
* After all pages are loaded, apply header and footer template content. * After all pages are loaded, apply header and footer template content.
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId. * Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
*/ */
const applyHeaderFooter = useCallback( const applyHeaderFooter = useCallback(
(tpl: TemplateDefinition, firstPageId: string) => { async (tpl: TemplateDefinition, firstPageId: string) => {
const hasHeader = tpl.header?.components?.length > 0; const hasHeader = tpl.header?.components?.length > 0;
const hasFooter = tpl.footer?.components?.length > 0; const hasFooter = tpl.footer?.components?.length > 0;
if (!hasHeader && !hasFooter) { if (hasHeader) await applyZone(editHeader, tpl.header.components);
setLoading(false); if (hasFooter) await applyZone(editFooter, tpl.footer.components);
return;
}
const applyZone = ( // Switch back to the first page
switchFn: () => void, switchPage(firstPageId);
components: TemplateComponent[], await wait(30);
next: () => void,
) => {
switchFn();
setTimeout(() => {
try {
clearCanvas();
setTimeout(() => {
try {
addTemplateComponents(components);
} catch (e) {
console.warn('Failed to add zone components:', e);
}
setTimeout(next, 30);
}, 20);
} catch (e) {
console.warn('Failed to clear zone:', e);
setTimeout(next, 30);
}
}, 30);
};
const finish = () => {
// Switch back to the first page
switchPage(firstPageId);
setTimeout(() => setLoading(false), 30);
};
const doFooter = () => {
if (hasFooter) {
applyZone(editFooter, tpl.footer.components, finish);
} else {
finish();
}
};
if (hasHeader) {
applyZone(editHeader, tpl.header.components, doFooter);
} else {
doFooter();
}
}, },
[clearCanvas, addTemplateComponents, switchPage, editHeader, editFooter], [applyZone, switchPage, editHeader, editFooter, wait],
); );
// Load the selected template // Load the selected template. Awaits the full staged sequence (clear, add
const handleLoad = useCallback(() => { // components, add pages, apply header/footer) before closing the modal, so
// "Loading…" stays visible for the duration and onClose() never fires
// while the sequence is still mutating a canvas the dialog just tore down.
const handleLoad = useCallback(async () => {
if (!confirmTemplate) return; if (!confirmTemplate) return;
setLoading(true); setLoading(true);
const tpl = confirmTemplate; const tpl = confirmTemplate;
setConfirmTemplate(null);
try { try {
// 1. Optionally apply design tokens // 1. Optionally apply design tokens
@@ -195,66 +194,46 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
// 3. Clear the current canvas and add the first template page's components // 3. Clear the current canvas and add the first template page's components
clearCanvas(); clearCanvas();
// Short delay to let the clear settle before adding new nodes
await wait(20);
// Use a short delay to let the clear settle before adding new nodes const firstPage = tpl.pages[0];
setTimeout(() => { addTemplateComponents(firstPage.content.components);
try {
const firstPage = tpl.pages[0];
addTemplateComponents(firstPage.content.components);
// 4. Add remaining pages (if multi-page template) // 4. Add remaining pages (if multi-page template)
const finishPages = () => { if (tpl.pages.length > 1) {
// 5. Apply header and footer template content await wait(30);
if (keepId) { for (let pageIndex = 1; pageIndex < tpl.pages.length; pageIndex++) {
applyHeaderFooter(tpl, keepId); const pageDef = tpl.pages[pageIndex];
} else { addPage(pageDef.name, pageDef.slug);
setLoading(false);
}
};
if (tpl.pages.length > 1) { // After addPage, the new page is active with an empty canvas.
let pageIndex = 1; // Give a tick for Craft.js to settle, then add components.
const addNextPage = () => { await wait(30);
if (pageIndex >= tpl.pages.length) { try {
// Switch back to first page, then apply header/footer addTemplateComponents(pageDef.content.components);
if (keepId) switchPage(keepId); } catch (e) {
setTimeout(finishPages, 30); console.warn('Failed to add components for page', pageDef.name, e);
return;
}
const pageDef = tpl.pages[pageIndex];
addPage(pageDef.name, pageDef.slug);
// After addPage, the new page is active with an empty canvas.
// Give a tick for Craft.js to settle, then add components.
setTimeout(() => {
try {
addTemplateComponents(pageDef.content.components);
} catch (e) {
console.warn('Failed to add components for page', pageDef.name, e);
}
pageIndex++;
setTimeout(addNextPage, 30);
}, 30);
};
setTimeout(addNextPage, 30);
} else {
finishPages();
} }
} catch (e) { await wait(30);
console.error('Failed to load template:', e);
setLoading(false);
} }
}, 20);
// Switch back to the first page before applying header/footer
if (keepId) switchPage(keepId);
await wait(30);
}
// 5. Apply header and footer template content
if (keepId) {
await applyHeaderFooter(tpl, keepId);
}
} catch (e) { } catch (e) {
console.error('Failed to load template:', e); console.error('Failed to load template:', e);
setLoading(false); } finally {
if (mountedRef.current) setLoading(false);
onClose();
} }
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
setConfirmTemplate(null);
onClose();
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]);
// Close on backdrop click // Close on backdrop click
const handleBackdropClick = useCallback( const handleBackdropClick = useCallback(