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
7 changed files with 100 additions and 12 deletions
Showing only changes of commit 46ebd253f3 - Show all commits
+3 -3
View File
@@ -1,5 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y';
interface LayerNodeProps { interface LayerNodeProps {
nodeId: string; nodeId: string;
@@ -17,8 +18,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}; };
}); });
const handleClick = useCallback((e: React.MouseEvent) => { const handleActivate = useCallback(() => {
e.stopPropagation();
actions.selectNode(nodeId); actions.selectNode(nodeId);
}, [actions, nodeId]); }, [actions, nodeId]);
@@ -38,7 +38,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
return ( return (
<div> <div>
<div <div
onClick={handleClick} {...clickableProps(handleActivate)}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
+2 -1
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { usePages } from '../../state/PageContext'; import { usePages } from '../../state/PageContext';
import { clickableProps } from '../../utils/a11y';
export const PagesPanel: React.FC = () => { export const PagesPanel: React.FC = () => {
const { const {
@@ -286,7 +287,7 @@ export const PagesPanel: React.FC = () => {
) : ( ) : (
/* Normal page item */ /* Normal page item */
<div <div
onClick={() => switchPage(page.id)} {...clickableProps(() => switchPage(page.id))}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
+2 -1
View File
@@ -9,6 +9,7 @@ import {
TemplateCategory, TemplateCategory,
} from '../../templates'; } from '../../templates';
import { componentResolver } from '../../components/resolver'; import { componentResolver } from '../../components/resolver';
import { clickableProps } from '../../utils/a11y';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -392,7 +393,7 @@ const TemplateCard: React.FC<{
return ( return (
<div <div
onClick={onSelect} {...clickableProps(onSelect)}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
style={{ style={{
+10 -6
View File
@@ -123,7 +123,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
<nav className="topbar"> <nav className="topbar">
<div className="topbar-left"> <div className="topbar-left">
{isWHP && ( {isWHP && (
<a href={whpConfig!.backUrl} className="topbar-btn back-btn"> <a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
<i className="fa fa-arrow-left" /> Back to Panel <i className="fa fa-arrow-left" /> Back to Panel
</a> </a>
)} )}
@@ -141,6 +141,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
className={`device-btn ${device === d ? 'active' : ''}`} className={`device-btn ${device === d ? 'active' : ''}`}
onClick={() => onDeviceChange(d)} onClick={() => onDeviceChange(d)}
title={d.charAt(0).toUpperCase() + d.slice(1)} title={d.charAt(0).toUpperCase() + d.slice(1)}
aria-label={d.charAt(0).toUpperCase() + d.slice(1)}
aria-pressed={device === d}
> >
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} /> <i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
</button> </button>
@@ -149,20 +151,20 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</div> </div>
<div className="topbar-right"> <div className="topbar-right">
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo"> <button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo" aria-label="Undo">
<i className="fa fa-undo" /> <i className="fa fa-undo" />
</button> </button>
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo"> <button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo" aria-label="Redo">
<i className="fa fa-repeat" /> <i className="fa fa-repeat" />
</button> </button>
<span className="topbar-divider" /> <span className="topbar-divider" />
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}> <button className="topbar-btn" title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
<i className="fa fa-th-large" /> Templates <i className="fa fa-th-large" /> Templates
</button> </button>
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}> <button className="topbar-btn" title="Custom Head Code" aria-label="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
<i className="fa fa-code" /> Code <i className="fa fa-code" /> Code
</button> </button>
<button className="topbar-btn" title="Preview" onClick={() => { <button className="topbar-btn" title="Preview" aria-label="Preview" onClick={() => {
try { try {
const serialized = query.serialize(); const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => { import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
@@ -255,6 +257,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
onClick={handleSave} onClick={handleSave}
disabled={saveStatus === 'saving'} disabled={saveStatus === 'saving'}
title="Save Draft" title="Save Draft"
aria-label="Save Draft"
> >
{saveStatus === 'saving' ? ( {saveStatus === 'saving' ? (
<><i className="fa fa-spinner fa-spin" /> Saving...</> <><i className="fa fa-spinner fa-spin" /> Saving...</>
@@ -269,6 +272,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
onClick={handlePublish} onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'} disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
title="Publish to live site" title="Publish to live site"
aria-label="Publish to live site"
> >
{publishStatus === 'publishing' ? ( {publishStatus === 'publishing' ? (
<><i className="fa fa-spinner fa-spin" /> Publishing...</> <><i className="fa fa-spinner fa-spin" /> Publishing...</>
+2 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react'; import React, { useState, useCallback, useRef, useEffect, CSSProperties } from 'react';
import { uploadAsset, listAssets, Asset } from '../utils/assets'; import { uploadAsset, listAssets, Asset } from '../utils/assets';
import { clickableProps } from '../utils/a11y';
export interface AssetPickerProps { export interface AssetPickerProps {
value: string; value: string;
@@ -192,7 +193,7 @@ export const AssetPicker: React.FC<AssetPickerProps> = ({
{browserAssets.map((asset) => ( {browserAssets.map((asset) => (
<div <div
key={asset.name} key={asset.name}
onClick={() => handleSelectAsset(asset)} {...clickableProps(() => handleSelectAsset(asset))}
data-testid={`asset-picker-thumb-${asset.name}`} data-testid={`asset-picker-thumb-${asset.name}`}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }} style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
> >
+51
View File
@@ -0,0 +1,51 @@
import { describe, test, expect, vi } from 'vitest';
import { clickableProps } from './a11y';
function makeKeyEvent(key: string) {
return {
key,
preventDefault: vi.fn(),
} as unknown as Parameters<ReturnType<typeof clickableProps>['onKeyDown']>[0];
}
describe('clickableProps', () => {
test('sets role=button and tabIndex=0', () => {
const props = clickableProps(() => {});
expect(props.role).toBe('button');
expect(props.tabIndex).toBe(0);
});
test('onClick invokes the callback', () => {
const onActivate = vi.fn();
const props = clickableProps(onActivate);
props.onClick();
expect(onActivate).toHaveBeenCalledTimes(1);
});
test('onKeyDown invokes the callback and preventDefaults on Enter', () => {
const onActivate = vi.fn();
const props = clickableProps(onActivate);
const e = makeKeyEvent('Enter');
props.onKeyDown(e);
expect(onActivate).toHaveBeenCalledTimes(1);
expect(e.preventDefault).toHaveBeenCalledTimes(1);
});
test('onKeyDown invokes the callback and preventDefaults on Space', () => {
const onActivate = vi.fn();
const props = clickableProps(onActivate);
const e = makeKeyEvent(' ');
props.onKeyDown(e);
expect(onActivate).toHaveBeenCalledTimes(1);
expect(e.preventDefault).toHaveBeenCalledTimes(1);
});
test('onKeyDown ignores other keys', () => {
const onActivate = vi.fn();
const props = clickableProps(onActivate);
const e = makeKeyEvent('Tab');
props.onKeyDown(e);
expect(onActivate).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { KeyboardEvent } from 'react';
export interface ClickableProps {
role: 'button';
tabIndex: number;
onClick: () => void;
onKeyDown: (e: KeyboardEvent) => void;
}
/**
* Props to spread onto a non-interactive element (typically a `<div>`) that
* acts like a button (e.g. a clickable list row or grid tile), so it
* becomes keyboard-operable: `role="button"`, a tab stop, and Enter/Space
* activation in addition to the existing click behavior.
*
* Usage: `<div {...clickableProps(() => doThing())}>...</div>`
*/
export function clickableProps(onActivate: () => void): ClickableProps {
return {
role: 'button',
tabIndex: 0,
onClick: onActivate,
onKeyDown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onActivate();
}
},
};
}