feat(admin): ask for the contributor's address when creating a link (#260)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 18:07:37 -05:00
co-authored by Claude Opus 5
parent b18b3e3a3d
commit 9c5935e8e0
4 changed files with 48 additions and 3 deletions
+33 -1
View File
@@ -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<UploadLink[]>([]);
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<string | null>(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<boolean | null>(null);
const [error, setError] = useState<string | null>(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 }}
/>
<Input
aria-label="Contributor email"
placeholder="Where should the link be sent?"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ width: 260 }}
/>
<Input
placeholder="Max uses"
aria-label="Maximum uses"
@@ -117,13 +134,27 @@ export default function UploadLinks() {
<Checkbox checked={unlimited} onChange={(e) => setUnlimited(e.target.checked)}>
No limit
</Checkbox>
<Button type="primary" onClick={create} loading={creating} disabled={label.trim() === ''}>
<Button
type="primary"
onClick={create}
loading={creating}
disabled={label.trim() === '' || email.trim() === ''}
>
Create link
</Button>
</Space>
{error && <Alert type="error" message={error} showIcon />}
{issued && mailed === false && (
<Alert
type="warning"
showIcon
message="The link was not emailed"
description="Copy it below and send it yourself. This is normal where no mail is configured, and in QA, where delivery is restricted to a fixed list of addresses."
/>
)}
{issued && (
<Alert
type="success"
@@ -151,6 +182,7 @@ export default function UploadLinks() {
pagination={false}
columns={[
{ title: 'Label', dataIndex: 'label' },
{ title: 'Sent to', dataIndex: 'contact_email' },
{
title: 'Used',
render: (_, row) =>
+1 -1
View File
@@ -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;
@@ -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();
});
});
+1 -1
View File
@@ -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;