Feature/260 email the upload link #292

Merged
bermudalamb merged 13 commits from feature/260-email-the-upload-link into main 2026-09-03 18:50:37 -05:00
4 changed files with 48 additions and 3 deletions
Showing only changes of commit 9c5935e8e0 - Show all commits
+33 -1
View File
@@ -14,6 +14,7 @@ const { Paragraph, Text } = Typography;
interface UploadLink { interface UploadLink {
id: number; id: number;
label: string; label: string;
contact_email: string | null;
revoked_at: string | null; revoked_at: string | null;
submission_count: number; submission_count: number;
max_submissions: number | null; max_submissions: number | null;
@@ -36,11 +37,16 @@ const DEFAULT_CAP = '25';
export default function UploadLinks() { export default function UploadLinks() {
const [links, setLinks] = useState<UploadLink[]>([]); const [links, setLinks] = useState<UploadLink[]>([]);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [email, setEmail] = useState('');
const [cap, setCap] = useState(DEFAULT_CAP); const [cap, setCap] = useState(DEFAULT_CAP);
const [unlimited, setUnlimited] = useState(false); const [unlimited, setUnlimited] = useState(false);
// Held only in component state and shown once. A refresh loses it, which is // Held only in component state and shown once. A refresh loses it, which is
// the honest behaviour: the server genuinely cannot produce it again. // the honest behaviour: the server genuinely cannot produce it again.
const [issued, setIssued] = useState<string | null>(null); 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 [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -57,6 +63,7 @@ export default function UploadLinks() {
async function create() { async function create() {
setCreating(true); setCreating(true);
setError(null); setError(null);
setMailed(null);
const res = await fetch('/api/admin/upload-links', { const res = await fetch('/api/admin/upload-links', {
method: 'POST', method: 'POST',
@@ -66,6 +73,7 @@ export default function UploadLinks() {
// that are not this screen. // that are not this screen.
body: JSON.stringify({ body: JSON.stringify({
label, label,
email,
maxSubmissions: unlimited ? null : Number(cap) maxSubmissions: unlimited ? null : Number(cap)
}) })
}); });
@@ -80,7 +88,9 @@ export default function UploadLinks() {
const created = await res.json(); const created = await res.json();
setIssued(created.url); setIssued(created.url);
setMailed(created.mail.sent);
setLabel(''); setLabel('');
setEmail('');
setCap(DEFAULT_CAP); setCap(DEFAULT_CAP);
setUnlimited(false); setUnlimited(false);
await load(); await load();
@@ -106,6 +116,13 @@ export default function UploadLinks() {
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
style={{ width: 260 }} 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 <Input
placeholder="Max uses" placeholder="Max uses"
aria-label="Maximum uses" aria-label="Maximum uses"
@@ -117,13 +134,27 @@ export default function UploadLinks() {
<Checkbox checked={unlimited} onChange={(e) => setUnlimited(e.target.checked)}> <Checkbox checked={unlimited} onChange={(e) => setUnlimited(e.target.checked)}>
No limit No limit
</Checkbox> </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 Create link
</Button> </Button>
</Space> </Space>
{error && <Alert type="error" message={error} showIcon />} {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 && ( {issued && (
<Alert <Alert
type="success" type="success"
@@ -151,6 +182,7 @@ export default function UploadLinks() {
pagination={false} pagination={false}
columns={[ columns={[
{ title: 'Label', dataIndex: 'label' }, { title: 'Label', dataIndex: 'label' },
{ title: 'Sent to', dataIndex: 'contact_email' },
{ {
title: 'Used', title: 'Used',
render: (_, row) => render: (_, row) =>
+1 -1
View File
@@ -14,7 +14,7 @@ let token: string;
test.beforeAll(async ({ playwright }) => { test.beforeAll(async ({ playwright }) => {
const api = await createAdminContext(playwright); const api = await createAdminContext(playwright);
const res = await api.post('/api/admin/upload-links', { 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); expect(res.status(), 'creating the upload link').toBe(201);
token = (await res.json()).token; token = (await res.json()).token;
@@ -17,6 +17,7 @@ test.describe('Managing upload links', () => {
await page.getByRole('tab', { name: 'Upload links' }).click(); await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label); await page.getByLabel('Link label').fill(label);
await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`);
await page.getByRole('button', { name: 'Create link' }).click(); await page.getByRole('button', { name: 'Create link' }).click();
// Shown exactly once. The server keeps only a digest, so there is no // 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 admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click(); await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label); await page.getByLabel('Link label').fill(label);
await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`);
await page.getByRole('button', { name: 'Create link' }).click(); await page.getByRole('button', { name: 'Create link' }).click();
const row = page.getByRole('row', { name: new RegExp(label) }); const row = page.getByRole('row', { name: new RegExp(label) });
@@ -61,6 +63,7 @@ test.describe('Managing upload links', () => {
await admin.goto(); await admin.goto();
await page.getByRole('tab', { name: 'Upload links' }).click(); await page.getByRole('tab', { name: 'Upload links' }).click();
await page.getByLabel('Link label').fill(label); await page.getByLabel('Link label').fill(label);
await page.getByLabel('Contributor email').fill(`${uniqueSuffix()}@example.com`);
await page.getByText('No limit').click(); await page.getByText('No limit').click();
await page.getByRole('button', { name: 'Create link' }).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". // A bare count rather than "0 of N".
await expect(row.getByText('0 of', { exact: false })).toHaveCount(0); 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 api = await createAdminContext(playwright);
const res = await api.post('/api/admin/upload-links', { 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); expect(res.status(), 'creating the upload link').toBe(201);
token = (await res.json()).token; token = (await res.json()).token;