From 9c5935e8e0db0f5aeba5e63ae0b972bc30917458 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 3 Sep 2026 18:07:37 -0500 Subject: [PATCH] feat(admin): ask for the contributor's address when creating a link (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The address is now a required field beside the label, the links table shows where each link was sent, and the admin is told plainly when the mail did not go — with the link still on screen to copy, which is the case that matters in QA and in local development where there is no mail at all. Two specs in unrelated features created links with only a label and the route now refuses that, so they are updated here rather than left to go red on somebody else's branch. That is the cost of making the address required, and it is a small one: the compiler and the suite find every call site. Co-Authored-By: Claude Opus 5 --- frontend/src/admin/UploadLinks.tsx | 34 ++++++++++++++++++- frontend/tests/e2e/admin-draft-queue.spec.ts | 2 +- frontend/tests/e2e/admin-upload-links.spec.ts | 13 +++++++ frontend/tests/e2e/intake-submit.spec.ts | 2 +- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/admin/UploadLinks.tsx b/frontend/src/admin/UploadLinks.tsx index 30ecd5d..06efb82 100644 --- a/frontend/src/admin/UploadLinks.tsx +++ b/frontend/src/admin/UploadLinks.tsx @@ -14,6 +14,7 @@ const { Paragraph, Text } = Typography; interface UploadLink { id: number; label: string; + contact_email: string | null; revoked_at: string | null; submission_count: number; max_submissions: number | null; @@ -36,11 +37,16 @@ const DEFAULT_CAP = '25'; export default function UploadLinks() { const [links, setLinks] = useState([]); const [label, setLabel] = useState(''); + const [email, setEmail] = useState(''); const [cap, setCap] = useState(DEFAULT_CAP); const [unlimited, setUnlimited] = useState(false); // Held only in component state and shown once. A refresh loses it, which is // the honest behaviour: the server genuinely cannot produce it again. const [issued, setIssued] = useState(null); + // Whether the link that is currently on screen was actually emailed. Separate + // from `issued` so the one-time display of the token keeps working exactly as + // it did; this only adds a note beside it. + const [mailed, setMailed] = useState(null); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); @@ -57,6 +63,7 @@ export default function UploadLinks() { async function create() { setCreating(true); setError(null); + setMailed(null); const res = await fetch('/api/admin/upload-links', { method: 'POST', @@ -66,6 +73,7 @@ export default function UploadLinks() { // that are not this screen. body: JSON.stringify({ label, + email, maxSubmissions: unlimited ? null : Number(cap) }) }); @@ -80,7 +88,9 @@ export default function UploadLinks() { const created = await res.json(); setIssued(created.url); + setMailed(created.mail.sent); setLabel(''); + setEmail(''); setCap(DEFAULT_CAP); setUnlimited(false); await load(); @@ -106,6 +116,13 @@ export default function UploadLinks() { onChange={(e) => setLabel(e.target.value)} style={{ width: 260 }} /> + setEmail(e.target.value)} + style={{ width: 260 }} + /> setUnlimited(e.target.checked)}> No limit - {error && } + {issued && mailed === false && ( + + )} + {issued && ( diff --git a/frontend/tests/e2e/admin-draft-queue.spec.ts b/frontend/tests/e2e/admin-draft-queue.spec.ts index 5fe3720..6cf6c67 100644 --- a/frontend/tests/e2e/admin-draft-queue.spec.ts +++ b/frontend/tests/e2e/admin-draft-queue.spec.ts @@ -14,7 +14,7 @@ let token: string; test.beforeAll(async ({ playwright }) => { const api = await createAdminContext(playwright); const res = await api.post('/api/admin/upload-links', { - data: { label: `Review queue spec ${RUN}` } + data: { label: `Review queue spec ${RUN}`, email: `draft-queue-${RUN}@example.com` } }); expect(res.status(), 'creating the upload link').toBe(201); token = (await res.json()).token; diff --git a/frontend/tests/e2e/admin-upload-links.spec.ts b/frontend/tests/e2e/admin-upload-links.spec.ts index 3317821..e80b8d3 100644 --- a/frontend/tests/e2e/admin-upload-links.spec.ts +++ b/frontend/tests/e2e/admin-upload-links.spec.ts @@ -17,6 +17,7 @@ test.describe('Managing upload links', () => { await page.getByRole('tab', { name: 'Upload links' }).click(); await page.getByLabel('Link label').fill(label); + await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`); await page.getByRole('button', { name: 'Create link' }).click(); // Shown exactly once. The server keeps only a digest, so there is no @@ -38,6 +39,7 @@ test.describe('Managing upload links', () => { await admin.goto(); await page.getByRole('tab', { name: 'Upload links' }).click(); await page.getByLabel('Link label').fill(label); + await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`); await page.getByRole('button', { name: 'Create link' }).click(); const row = page.getByRole('row', { name: new RegExp(label) }); @@ -61,6 +63,7 @@ test.describe('Managing upload links', () => { await admin.goto(); await page.getByRole('tab', { name: 'Upload links' }).click(); await page.getByLabel('Link label').fill(label); + await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`); await page.getByText('No limit').click(); await page.getByRole('button', { name: 'Create link' }).click(); @@ -69,4 +72,14 @@ test.describe('Managing upload links', () => { // A bare count rather than "0 of N". await expect(row.getByText('0 of', { exact: false })).toHaveCount(0); }); + + // The address is the point of the change: a link nobody can be sent is the + // thing this replaced. + test('will not create a link without an address', async ({ page, admin }) => { + await admin.goto(); + await page.getByRole('tab', { name: 'Upload links' }).click(); + await page.getByLabel('Link label').fill(`Nameless ${uniqueSuffix()}`); + + await expect(page.getByRole('button', { name: 'Create link' })).toBeDisabled(); + }); }); diff --git a/frontend/tests/e2e/intake-submit.spec.ts b/frontend/tests/e2e/intake-submit.spec.ts index 507131f..9ab45a4 100644 --- a/frontend/tests/e2e/intake-submit.spec.ts +++ b/frontend/tests/e2e/intake-submit.spec.ts @@ -16,7 +16,7 @@ test.beforeAll(async ({ playwright }) => { const api = await createAdminContext(playwright); const res = await api.post('/api/admin/upload-links', { - data: { label: `Intake spec ${RUN}` } + data: { label: `Intake spec ${RUN}`, email: `intake-${RUN}@example.com` } }); expect(res.status(), 'creating the upload link').toBe(201); token = (await res.json()).token;