Skip to content

Commit 49cd501

Browse files
authored
feat: CAPTCHA, hidden tracking field, preview fix, tests + CI (#70)
* 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"]). * fix(ci): regenerate package-lock so npm ci is in sync * fix(ci): additive lockfile (keep original dep versions, add vitest/zod) * fix: address review — hidden field tab leak, CSP script-src, captcha fail-closed + timeout * feat(captcha): validate secret key in settings + honest public-form failure message
1 parent 2347e64 commit 49cd501

20 files changed

Lines changed: 814 additions & 31 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/api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function useFormsApi() {
3535
return data;
3636
},
3737

38+
async testCaptcha(provider: string, secretKey: string): Promise<{ ok: boolean; reason?: string }> {
39+
const { data } = await post(`${BASE}/captcha/test`, { provider, secretKey });
40+
return data;
41+
},
42+
3843
async getSubmissions(formId: number, query: Record<string, any> = {}) {
3944
const params = new URLSearchParams(query as any).toString();
4045
const { data } = await get(`${BASE}/submissions/${formId}${params ? `?${params}` : ''}`);

admin/src/components/FieldSettingsPanel.tsx

Lines changed: 20 additions & 3 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+
{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

@@ -198,7 +210,7 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
198210
</div>
199211
)}
200212

201-
{tab === 'validation' && !deco && (
213+
{tab === 'validation' && !deco && !isHidden && (
202214
<div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 12 }}>
203215
{/* Required — flag + custom message */}
204216
<div style={{ border: `1px solid ${C.n200}`, borderRadius: 5, padding: 11, display: 'flex', flexDirection: 'column', gap: 8, background: C.n0 }}>
@@ -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: 70 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,36 @@ 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('');
100+
const [testing, setTesting] = useState(false);
101+
const [testResult, setTestResult] = useState<{ ok: boolean; reason?: string } | null>(null);
102+
const api = useFormsApi();
98103
const patch = (p: Partial<FormSettings>) => setSettings((s) => ({ ...s, ...p }));
99104
const onCancel = onClose;
105+
106+
const testCaptcha = async () => {
107+
if (!settings.captcha) return;
108+
setTesting(true);
109+
setTestResult(null);
110+
try {
111+
const r = await api.testCaptcha(settings.captcha.provider, settings.captcha.secretKey);
112+
setTestResult(r);
113+
} catch {
114+
setTestResult({ ok: false, reason: 'Test request failed' });
115+
} finally {
116+
setTesting(false);
117+
}
118+
};
119+
120+
const handleSave = () => {
121+
const cap = settings.captcha;
122+
if (cap && cap.provider !== 'none' && (!cap.siteKey?.trim() || !cap.secretKey?.trim())) {
123+
setCaptchaErr('Enter both the site key and secret key, or set CAPTCHA to None.');
124+
return;
125+
}
126+
setCaptchaErr('');
127+
onSave(description, settings);
128+
};
100129
const url = slug ? `${window.location.origin}/api/${PLUGIN_ID}/page/${slug}` : '';
101130
const live = !!publishedAt;
102131

@@ -159,6 +188,46 @@ function SettingsDrawer({ initialDescription, initialSettings, slug, publishedAt
159188
<input value={String(settings.maxSubmissionsPerHour ?? '')} onChange={(e) => patch({ maxSubmissionsPerHour: Number(e.target.value.replace(/[^0-9]/g, '')) || 0 })} style={{ ...dInp, width: 120 }} />
160189
</div>
161190
)}
191+
192+
<div style={{ marginTop: 16 }}>
193+
<label style={dLbl}>CAPTCHA</label>
194+
<select
195+
value={settings.captcha?.provider || 'none'}
196+
onChange={(e) => { setCaptchaErr(''); patch({ captcha: { provider: e.target.value as any, siteKey: '', secretKey: '' } }); }}
197+
style={{ ...dInp, cursor: 'pointer' }}
198+
>
199+
<option value="none">None</option>
200+
<option value="turnstile">Cloudflare Turnstile</option>
201+
<option value="recaptcha">Google reCAPTCHA v2</option>
202+
</select>
203+
<div style={dHint}>Human-verification challenge required before a submission is accepted.</div>
204+
</div>
205+
{settings.captcha && settings.captcha.provider !== 'none' && (
206+
<>
207+
<div style={{ marginTop: 12 }}>
208+
<label style={dLbl}>Site key</label>
209+
<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 }} />
210+
</div>
211+
<div style={{ marginTop: 12 }}>
212+
<label style={dLbl}>Secret key</label>
213+
<input type="password" value={settings.captcha.secretKey} onChange={(e) => { setTestResult(null); patch({ captcha: { ...settings.captcha!, secretKey: e.target.value } }); }} placeholder="Private — kept server-side, never exposed" style={{ ...dInp, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12 }} />
214+
</div>
215+
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
216+
<button type="button" onClick={testCaptcha} disabled={testing || !settings.captcha.secretKey.trim()} style={{ height: 30, padding: '0 12px', borderRadius: 4, border: `1px solid ${C.n200}`, background: C.n0, color: C.n800, font: `600 12px ${FF}`, cursor: testing || !settings.captcha.secretKey.trim() ? 'not-allowed' : 'pointer', opacity: testing || !settings.captcha.secretKey.trim() ? 0.5 : 1 }}>
217+
{testing ? 'Testing…' : 'Test secret key'}
218+
</button>
219+
{testResult && (
220+
<span style={{ font: `600 12px ${FF}`, color: testResult.ok ? C.suc600 : C.dng600 }}>
221+
{testResult.ok ? '✓ Secret key valid' : `✗ ${testResult.reason || 'Invalid'}`}
222+
</span>
223+
)}
224+
</div>
225+
<div style={{ ...dHint, marginTop: 6 }}>
226+
Note: the site key can only be checked on the live form — an invalid site key shows no widget there.
227+
</div>
228+
{captchaErr && <div style={{ marginTop: 10, font: `500 12px ${FF}`, color: C.dng600 }}>{captchaErr}</div>}
229+
</>
230+
)}
162231
</div>
163232

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

171240
<div style={{ padding: '16px 24px', borderTop: `1px solid ${C.n150}`, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
172241
<HeaderBtn variant="ghost" onClick={onCancel}>Cancel</HeaderBtn>
173-
<HeaderBtn variant="pri" onClick={() => onSave(description, settings)}>Save settings</HeaderBtn>
242+
<HeaderBtn variant="pri" onClick={handleSave}>Save settings</HeaderBtn>
174243
</div>
175244
</div>
176245
</>

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)