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
+66 -87
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)), []);
/** /**
* After all pages are loaded, apply header and footer template content. * Switch to a zone (header/footer), clear it, and add the template's
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId. * components, giving Craft.js a tick to settle between each step.
*/ */
const applyHeaderFooter = useCallback( const applyZone = useCallback(
(tpl: TemplateDefinition, firstPageId: string) => { async (switchFn: () => void, components: TemplateComponent[]) => {
const hasHeader = tpl.header?.components?.length > 0;
const hasFooter = tpl.footer?.components?.length > 0;
if (!hasHeader && !hasFooter) {
setLoading(false);
return;
}
const applyZone = (
switchFn: () => void,
components: TemplateComponent[],
next: () => void,
) => {
switchFn(); switchFn();
setTimeout(() => { await wait(30);
try { try {
clearCanvas(); clearCanvas();
setTimeout(() => { await wait(20);
try { try {
addTemplateComponents(components); addTemplateComponents(components);
} catch (e) { } catch (e) {
console.warn('Failed to add zone components:', e); console.warn('Failed to add zone components:', e);
} }
setTimeout(next, 30);
}, 20);
} catch (e) { } catch (e) {
console.warn('Failed to clear zone:', 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();
} }
await wait(30);
}, },
[clearCanvas, addTemplateComponents, switchPage, editHeader, editFooter], [wait, clearCanvas, addTemplateComponents],
); );
// Load the selected template /**
const handleLoad = useCallback(() => { * After all pages are loaded, apply header and footer template content.
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
*/
const applyHeaderFooter = useCallback(
async (tpl: TemplateDefinition, firstPageId: string) => {
const hasHeader = tpl.header?.components?.length > 0;
const hasFooter = tpl.footer?.components?.length > 0;
if (hasHeader) await applyZone(editHeader, tpl.header.components);
if (hasFooter) await applyZone(editFooter, tpl.footer.components);
// Switch back to the first page
switchPage(firstPageId);
await wait(30);
},
[applyZone, switchPage, editHeader, editFooter, wait],
);
// Load the selected template. Awaits the full staged sequence (clear, add
// 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
setTimeout(() => {
try {
const firstPage = tpl.pages[0]; const firstPage = tpl.pages[0];
addTemplateComponents(firstPage.content.components); addTemplateComponents(firstPage.content.components);
// 4. Add remaining pages (if multi-page template) // 4. Add remaining pages (if multi-page template)
const finishPages = () => {
// 5. Apply header and footer template content
if (keepId) {
applyHeaderFooter(tpl, keepId);
} else {
setLoading(false);
}
};
if (tpl.pages.length > 1) { if (tpl.pages.length > 1) {
let pageIndex = 1; await wait(30);
const addNextPage = () => { for (let pageIndex = 1; pageIndex < tpl.pages.length; pageIndex++) {
if (pageIndex >= tpl.pages.length) {
// Switch back to first page, then apply header/footer
if (keepId) switchPage(keepId);
setTimeout(finishPages, 30);
return;
}
const pageDef = tpl.pages[pageIndex]; const pageDef = tpl.pages[pageIndex];
addPage(pageDef.name, pageDef.slug); addPage(pageDef.name, pageDef.slug);
// After addPage, the new page is active with an empty canvas. // After addPage, the new page is active with an empty canvas.
// Give a tick for Craft.js to settle, then add components. // Give a tick for Craft.js to settle, then add components.
setTimeout(() => { await wait(30);
try { try {
addTemplateComponents(pageDef.content.components); addTemplateComponents(pageDef.content.components);
} catch (e) { } catch (e) {
console.warn('Failed to add components for page', pageDef.name, e); console.warn('Failed to add components for page', pageDef.name, e);
} }
pageIndex++; await wait(30);
setTimeout(addNextPage, 30); }
}, 30);
};
setTimeout(addNextPage, 30); // Switch back to the first page before applying header/footer
} else { if (keepId) switchPage(keepId);
finishPages(); 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);
}, 20);
} catch (e) {
console.error('Failed to load template:', e);
setLoading(false);
}
setConfirmTemplate(null);
onClose(); onClose();
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]); }
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
// Close on backdrop click // Close on backdrop click
const handleBackdropClick = useCallback( const handleBackdropClick = useCallback(