Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -87,4 +87,33 @@ describe('buildSavePayload', () => {
|
|||||||
expect(payload.header_craft_state).toBe('STALE_HEADER');
|
expect(payload.header_craft_state).toBe('STALE_HEADER');
|
||||||
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
|
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('I-1: activePageId matches no page (dangling, e.g. original Home deleted then reload reset it to "home") -- live serialize still lands in pages_craft_state[0] / index.html, not lost', () => {
|
||||||
|
// pages[0] has id 'page_x' (the replacement landing page after the
|
||||||
|
// original 'home' was deleted); activePageId is stuck at the stale
|
||||||
|
// default 'home', which matches no entry in `pages`.
|
||||||
|
const pageX: PageData = { id: 'page_x', name: 'Home', slug: 'index', craftState: 'STORED_X' };
|
||||||
|
const p2: PageData = { id: 'p2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT_2' };
|
||||||
|
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_EDIT',
|
||||||
|
pages: [pageX, p2],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: 'home',
|
||||||
|
isEditingHeader: false,
|
||||||
|
isEditingFooter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The live edit must land in pages_craft_state[0] (index.html slot),
|
||||||
|
// not be silently dropped to only the legacy top-level fields.
|
||||||
|
expect(payload.pages_craft_state[0].craftState).toBe('LIVE_EDIT');
|
||||||
|
expect(payload.pages[0].filename).toBe('index.html');
|
||||||
|
// Top-level fields (legacy) should also reflect the live edit.
|
||||||
|
expect(payload.craft_state).toBe('LIVE_EDIT');
|
||||||
|
// The other page is untouched.
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
|||||||
|
|
||||||
const isPageActive = !isEditingHeader && !isEditingFooter;
|
const isPageActive = !isEditingHeader && !isEditingFooter;
|
||||||
|
|
||||||
|
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
|
||||||
|
// Home page (id 'home') is deleted, its replacement gets a fresh id
|
||||||
|
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
|
||||||
|
// hardcoded default `'home'` (see PageContext's `useState('home')`) before
|
||||||
|
// `load()` has a chance to point it at the actually-restored page. If a
|
||||||
|
// real page IS active but matches no entry in `pages`, treat `pages[0]`
|
||||||
|
// (the landing page) as the active one so the live canvas serialization
|
||||||
|
// still reaches the index.html page slot / `pages_craft_state[0]` instead
|
||||||
|
// of only the legacy top-level `craft_state`/`html` fields.
|
||||||
|
const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1;
|
||||||
|
const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id;
|
||||||
|
|
||||||
// Fresh header/footer state: the live canvas wins when that zone is the
|
// Fresh header/footer state: the live canvas wins when that zone is the
|
||||||
// one currently being edited; otherwise fall back to the last-committed
|
// one currently being edited; otherwise fall back to the last-committed
|
||||||
// stored state (updated on zone switch by PageContext's saveCurrentState).
|
// stored state (updated on zone switch by PageContext's saveCurrentState).
|
||||||
@@ -115,7 +127,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
|||||||
const filename = i === 0 ? 'index.html' : page.slug + '.html';
|
const filename = i === 0 ? 'index.html' : page.slug + '.html';
|
||||||
let pageHtml = '';
|
let pageHtml = '';
|
||||||
|
|
||||||
if (isPageActive && page.id === activePageId) {
|
if (isPageActive && page.id === effectiveActivePageId) {
|
||||||
// Active page: use the current canvas HTML (already exported above)
|
// Active page: use the current canvas HTML (already exported above)
|
||||||
pageHtml = currentHtml;
|
pageHtml = currentHtml;
|
||||||
} else if (page.craftState) {
|
} else if (page.craftState) {
|
||||||
@@ -146,7 +158,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
|||||||
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
|
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
|
||||||
// name.html) lines up with the file we just wrote (index.html).
|
// name.html) lines up with the file we just wrote (index.html).
|
||||||
slug: i === 0 ? 'index' : page.slug,
|
slug: i === 0 ? 'index' : page.slug,
|
||||||
craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null),
|
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -177,6 +189,7 @@ export function useWhpApi() {
|
|||||||
setHeaderCraftState,
|
setHeaderCraftState,
|
||||||
setFooterCraftState,
|
setFooterCraftState,
|
||||||
setPagesCraftState,
|
setPagesCraftState,
|
||||||
|
setActivePageIdDirect,
|
||||||
} = usePages();
|
} = usePages();
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = useCallback(async () => {
|
||||||
@@ -271,10 +284,23 @@ export function useWhpApi() {
|
|||||||
console.warn('Failed to load page state:', e);
|
console.warn('Failed to load page state:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I-1 (data-loss): point activePageId at the page we just loaded
|
||||||
|
// into the canvas. Without this, activePageId stays at whatever it
|
||||||
|
// was initialized to (the hardcoded default 'home'), which goes
|
||||||
|
// dangling the moment the original Home page has been deleted and
|
||||||
|
// replaced (its replacement gets a fresh `page_<ts>` id) -- the next
|
||||||
|
// edit+save would then only reach the legacy top-level fields
|
||||||
|
// instead of the actual page slot. Only do this when a real page is
|
||||||
|
// being loaded, i.e. we're not currently mid-edit of the header/
|
||||||
|
// footer zone (switching zones is handled separately by switchPage).
|
||||||
|
if (!isEditingHeader && !isEditingFooter) {
|
||||||
|
setActivePageIdDirect(firstPage.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]);
|
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]);
|
||||||
|
|
||||||
const uploadAsset = useCallback(
|
const uploadAsset = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ interface PageContextValue {
|
|||||||
setHeaderCraftState: (craftState: string) => void;
|
setHeaderCraftState: (craftState: string) => void;
|
||||||
setFooterCraftState: (craftState: string) => void;
|
setFooterCraftState: (craftState: string) => void;
|
||||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
||||||
|
/**
|
||||||
|
* Bookkeeping-only: point `activePageId` at an already-loaded page without
|
||||||
|
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
|
||||||
|
* `load()` -- has already put the right state on the canvas itself). Used
|
||||||
|
* to fix I-1: `activePageId` defaults to the hardcoded `'home'` and
|
||||||
|
* `load()` never updated it, so after the original Home page was deleted
|
||||||
|
* (its replacement gets a fresh `page_<ts>` id) and the app reloaded,
|
||||||
|
* `activePageId` pointed at nothing in `pages`, and a subsequent edit+save
|
||||||
|
* only reached the legacy top-level fields.
|
||||||
|
*/
|
||||||
|
setActivePageIdDirect: (pageId: string) => void;
|
||||||
/** AI helpers — replace entire site or page with a new tree */
|
/** AI helpers — replace entire site or page with a new tree */
|
||||||
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
|
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
|
||||||
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
|
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
|
||||||
@@ -149,6 +160,7 @@ const PageContext = createContext<PageContextValue>({
|
|||||||
setHeaderCraftState: () => {},
|
setHeaderCraftState: () => {},
|
||||||
setFooterCraftState: () => {},
|
setFooterCraftState: () => {},
|
||||||
setPagesCraftState: () => {},
|
setPagesCraftState: () => {},
|
||||||
|
setActivePageIdDirect: () => {},
|
||||||
replaceAllPages: () => {},
|
replaceAllPages: () => {},
|
||||||
replaceCurrentPage: () => {},
|
replaceCurrentPage: () => {},
|
||||||
setHeader: () => {},
|
setHeader: () => {},
|
||||||
@@ -385,6 +397,16 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setFooterPage((prev) => ({ ...prev, craftState }));
|
setFooterPage((prev) => ({ ...prev, craftState }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookkeeping-only setter for `activePageId` -- see the doc comment on
|
||||||
|
* `PageContextValue.setActivePageIdDirect`. Does NOT serialize/deserialize
|
||||||
|
* the canvas; callers that need that should use `switchPage` instead.
|
||||||
|
*/
|
||||||
|
const setActivePageIdDirect = useCallback((pageId: string) => {
|
||||||
|
setActivePageId(pageId);
|
||||||
|
activePageIdRef.current = pageId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
||||||
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
|
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
|
||||||
setPages(pagesData.map((p, i) => ({
|
setPages(pagesData.map((p, i) => ({
|
||||||
@@ -492,6 +514,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setHeaderCraftState,
|
setHeaderCraftState,
|
||||||
setFooterCraftState,
|
setFooterCraftState,
|
||||||
setPagesCraftState,
|
setPagesCraftState,
|
||||||
|
setActivePageIdDirect,
|
||||||
replaceAllPages,
|
replaceAllPages,
|
||||||
replaceCurrentPage,
|
replaceCurrentPage,
|
||||||
setHeader,
|
setHeader,
|
||||||
|
|||||||
Reference in New Issue
Block a user