Skip to content

Commit 4eae4a8

Browse files
committed
feat: CAPTCHA, hidden tracking field, preview fix, tests + CI
- fix(admin): Preview modal scrolls internally instead of overflowing on tall forms (flex: 1 / minHeight: 0 / alignItems: flex-start). - feat(captcha): per-form CAPTCHA (Cloudflare Turnstile or Google reCAPTCHA v2). Server-side verify (fail-closed), secret key never exposed to the browser, CSP relaxed for the provider on the public page, client guard blocks submit without a token. Saving is blocked when a provider is chosen without keys; keys reset on provider change. - feat(fields): hidden tracking field — captures a URL query param (e.g. utm_source) or a default value, stored with the submission and included in CSV export. Never rendered visibly. - feat(admin): "Test & learn regex" help link on pattern validation. - refactor(admin): drop the redundant Actions column in the submissions inbox (the whole row already opens the detail drawer). - test: Vitest + seed tests for verifyCaptcha (fail-closed), public schema secret-key stripping, and field validation. - ci: run the test suite in CI. devDeps: vitest; zod pinned to align the transitive ai/zod-v4 resolution for local symlinked plugin dev (not shipped — files: ["dist"]).
1 parent 2347e64 commit 4eae4a8

18 files changed

Lines changed: 701 additions & 253 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ jobs:
2929
- name: Type check (admin)
3030
run: npm run test:ts:front
3131

32+
- name: Run tests
33+
run: npm test
34+
3235
- name: Build
3336
run: npm run build
3437

admin/src/components/FieldSettingsPanel.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ function Seg({ value, onChange }: { value: 'full' | 'half'; onChange: (v: 'full'
9898
export function FieldSettingsPanel({ field, onChange }: Props) {
9999
ensureStyle();
100100
const deco = isDecorative(field.type);
101+
const isHidden = field.type === 'hidden';
101102
const [tab, setTab] = useState<'general' | 'validation'>('general');
102103
const update = (patch: Partial<FormField>) => onChange({ ...field, ...patch });
103104

@@ -151,10 +152,21 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
151152

152153
<div style={{ display: 'flex', gap: 2, padding: '12px 18px 0', borderBottom: `1px solid ${C.n150}` }}>
153154
{tabBtn('general', 'General')}
154-
{!deco && tabBtn('validation', 'Validation')}
155+
{!deco && !isHidden && tabBtn('validation', 'Validation')}
155156
</div>
156157

157-
{(tab === 'general' || deco) && (
158+
{(tab === 'general' || deco) && isHidden && (
159+
<div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}>
160+
<div style={{ font: `400 12px ${FF}`, color: C.n500, lineHeight: 1.5 }}>
161+
A hidden tracking field. It never shows on the form but its value is stored with every submission — ideal for UTM parameters, referrers or campaign IDs.
162+
</div>
163+
<TextField label="Name (technical)" value={field.name} onChange={(v) => update({ name: v })} mono hint="Key stored with each submission (e.g. utm_source)." />
164+
<TextField label="Prefill from URL parameter" value={field.queryParam || ''} onChange={(v) => update({ queryParam: v })} mono placeholder="utm_source" hint="If the form URL has ?utm_source=… its value is captured automatically." />
165+
<TextField label="Default value" value={String(field.defaultValue ?? '')} onChange={(v) => update({ defaultValue: v })} hint="Used when the URL parameter above is absent." />
166+
</div>
167+
)}
168+
169+
{(tab === 'general' || deco) && !isHidden && (
158170
<div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}>
159171
<TextField label={deco ? (field.type === 'divider' ? 'Label (internal)' : 'Text') : 'Label'} value={field.label} onChange={(v) => update({ label: v })} />
160172

@@ -241,6 +253,11 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
241253
{hasValue && (
242254
<input className="sfb-inp" placeholder={rule.type === 'pattern' ? '^[a-z0-9]+$' : 'Value'} value={String(rule.value ?? '')} onChange={(e) => updateValidation(i, { value: e.target.value })} style={{ ...inpStyle, ...(rule.type === 'pattern' ? { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12 } : {}) }} />
243255
)}
256+
{rule.type === 'pattern' && (
257+
<a href="https://regex101.com/" target="_blank" rel="noopener noreferrer" style={{ font: `500 11px ${FF}`, color: C.p600, textDecoration: 'none' }}>
258+
Test &amp; learn regex ↗
259+
</a>
260+
)}
244261
<input className="sfb-inp" placeholder="Custom error message (optional)" value={rule.message || ''} onChange={(e) => updateValidation(i, { message: e.target.value })} style={inpStyle} />
245262
</div>
246263
);

admin/src/components/FormPreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export function FormPreview({ title, description, fields, settings, open, onClos
308308
</div>
309309

310310
{/* body */}
311-
<div style={{ background: '#f5f5f9', overflowY: 'auto', display: 'flex', justifyContent: 'center', padding: 24 }}>
311+
<div style={{ background: '#f5f5f9', flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', padding: 24 }}>
312312
<div style={{ width: '100%', background: '#fff', border: '1px solid #eaeaef', borderRadius: 12, padding: '32px 32px 28px' }}>
313313
<h1 style={{ fontSize: 22, fontWeight: 700, color: TOKEN.text, margin: '0 0 8px' }}>{title}</h1>
314314
{description && <p style={{ fontSize: 14, color: TOKEN.sub, margin: '0 0 22px', lineHeight: 1.55 }}>{description}</p>}

admin/src/pages/FormBuilderPage.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULT_SETTINGS: FormSettings = {
2323
redirectUrl: '',
2424
customCss: '',
2525
publicPage: false,
26+
captcha: { provider: 'none', siteKey: '', secretKey: '' },
2627
};
2728

2829
// Order-insensitive stringify so key ordering in stored JSON doesn't create false diffs.
@@ -95,8 +96,19 @@ function SettingsDrawer({ initialDescription, initialSettings, slug, publishedAt
9596
// edits stay in a local buffer — they only reach the form on "Save settings"
9697
const [description, setDescription] = useState(initialDescription);
9798
const [settings, setSettings] = useState<FormSettings>(initialSettings);
99+
const [captchaErr, setCaptchaErr] = useState('');
98100
const patch = (p: Partial<FormSettings>) => setSettings((s) => ({ ...s, ...p }));
99101
const onCancel = onClose;
102+
103+
const handleSave = () => {
104+
const cap = settings.captcha;
105+
if (cap && cap.provider !== 'none' && (!cap.siteKey?.trim() || !cap.secretKey?.trim())) {
106+
setCaptchaErr('Enter both the site key and secret key, or set CAPTCHA to None.');
107+
return;
108+
}
109+
setCaptchaErr('');
110+
onSave(description, settings);
111+
};
100112
const url = slug ? `${window.location.origin}/api/${PLUGIN_ID}/page/${slug}` : '';
101113
const live = !!publishedAt;
102114

@@ -159,6 +171,33 @@ function SettingsDrawer({ initialDescription, initialSettings, slug, publishedAt
159171
<input value={String(settings.maxSubmissionsPerHour ?? '')} onChange={(e) => patch({ maxSubmissionsPerHour: Number(e.target.value.replace(/[^0-9]/g, '')) || 0 })} style={{ ...dInp, width: 120 }} />
160172
</div>
161173
)}
174+
175+
<div style={{ marginTop: 16 }}>
176+
<label style={dLbl}>CAPTCHA</label>
177+
<select
178+
value={settings.captcha?.provider || 'none'}
179+
onChange={(e) => { setCaptchaErr(''); patch({ captcha: { provider: e.target.value as any, siteKey: '', secretKey: '' } }); }}
180+
style={{ ...dInp, cursor: 'pointer' }}
181+
>
182+
<option value="none">None</option>
183+
<option value="turnstile">Cloudflare Turnstile</option>
184+
<option value="recaptcha">Google reCAPTCHA v2</option>
185+
</select>
186+
<div style={dHint}>Human-verification challenge required before a submission is accepted.</div>
187+
</div>
188+
{settings.captcha && settings.captcha.provider !== 'none' && (
189+
<>
190+
<div style={{ marginTop: 12 }}>
191+
<label style={dLbl}>Site key</label>
192+
<input value={settings.captcha.siteKey} onChange={(e) => patch({ captcha: { ...settings.captcha!, siteKey: e.target.value } })} placeholder="Public — sent to the browser" style={{ ...dInp, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12 }} />
193+
</div>
194+
<div style={{ marginTop: 12 }}>
195+
<label style={dLbl}>Secret key</label>
196+
<input type="password" value={settings.captcha.secretKey} onChange={(e) => patch({ captcha: { ...settings.captcha!, secretKey: e.target.value } })} placeholder="Private — kept server-side, never exposed" style={{ ...dInp, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12 }} />
197+
</div>
198+
{captchaErr && <div style={{ marginTop: 10, font: `500 12px ${FF}`, color: C.dng600 }}>{captchaErr}</div>}
199+
</>
200+
)}
162201
</div>
163202

164203
<div style={{ padding: '20px 0' }}>
@@ -170,7 +209,7 @@ function SettingsDrawer({ initialDescription, initialSettings, slug, publishedAt
170209

171210
<div style={{ padding: '16px 24px', borderTop: `1px solid ${C.n150}`, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
172211
<HeaderBtn variant="ghost" onClick={onCancel}>Cancel</HeaderBtn>
173-
<HeaderBtn variant="pri" onClick={() => onSave(description, settings)}>Save settings</HeaderBtn>
212+
<HeaderBtn variant="pri" onClick={handleSave}>Save settings</HeaderBtn>
174213
</div>
175214
</div>
176215
</>

admin/src/pages/SubmissionsPage.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,6 @@ export function SubmissionsPage() {
311311
{shownColumns.map((c) => (
312312
<Th key={c.name}><Typography variant="sigma">{c.label}</Typography></Th>
313313
))}
314-
<Th><Typography variant="sigma">Actions</Typography></Th>
315314
</Tr>
316315
</Thead>
317316
<Tbody>
@@ -334,9 +333,6 @@ export function SubmissionsPage() {
334333
</Td>
335334
);
336335
})}
337-
<Td onClick={(e: React.MouseEvent) => e.stopPropagation()}>
338-
<button type="button" onClick={() => setSelectedId(sub.id)} style={{ width: 28, height: 28, borderRadius: 4, border: `1px solid ${C.n200}`, background: C.n0, color: C.n500, cursor: 'pointer' }}></button>
339-
</Td>
340336
</Tr>
341337
))}
342338
</Tbody>

admin/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface FormField {
3636
placeholder?: string;
3737
helpText?: string;
3838
defaultValue?: any;
39+
queryParam?: string;
3940
required: boolean;
4041
order: number;
4142
width: 'full' | 'half';
@@ -66,6 +67,11 @@ export interface FormSettings {
6667
redirectUrl: string;
6768
customCss: string;
6869
publicPage: boolean;
70+
captcha?: {
71+
provider: 'none' | 'turnstile' | 'recaptcha';
72+
siteKey: string;
73+
secretKey: string;
74+
};
6975
}
7076

7177
export interface Form {

admin/src/ui.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const FIELD_CATEGORIES: { cat: string; items: FieldMeta[] }[] = [
3535
{ type: 'url', name: 'URL', cat: 'Basic' },
3636
{ type: 'password', name: 'Password', cat: 'Basic' },
3737
{ type: 'textarea', name: 'Textarea', cat: 'Basic' },
38+
{ type: 'hidden', name: 'Hidden', cat: 'Basic' },
3839
]},
3940
{ cat: 'Choice', items: [
4041
{ type: 'select', name: 'Select', cat: 'Choice' },
@@ -70,7 +71,7 @@ const ICON_COMPONENTS: Partial<Record<FieldType, React.ComponentType<any>>> = {
7071
'checkbox-group': ListPlus, date: Calendar, time: Clock,
7172
};
7273
const GLYPHS: Partial<Record<FieldType, string>> = {
73-
text: 'T', radio: '◉', heading: 'H', paragraph: '¶', divider: '—',
74+
text: 'T', radio: '◉', heading: 'H', paragraph: '¶', divider: '—', hidden: '⊘',
7475
};
7576

7677
export function FieldIcon({ type, size = 16 }: { type: FieldType; size?: number }) {

0 commit comments

Comments
 (0)