The table headed its address column "Sent to", but contact_email records only the intent an admin typed in, never whether delivery happened — that outcome is shown once, at creation, and is not persisted. In QA, where every send is blocked by design, every row read "Sent to ..." for links that were never emailed, and the honest warning that appears at creation is guarded on `issued`, so it vanishes on refresh, leaving the false heading as the only surviving statement. Renamed to "Email", which is true of what the column actually stores. Separately, create() called setMailed(null) before every request, including one that would go on to 400. If an admin creates link A while mail is down (warning shown, token A still on screen) and then mistypes an address on a second attempt, the 400 path returned early — but the reset had already run, so the warning for link A disappeared while token A was still displayed on the same screen. setMailed is now only called after a successful create, alongside setIssued, so a request that never produces a new link can no longer clear a warning that belongs to the one still shown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
7.8 KiB
TypeScript
232 lines
7.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import Table from 'antd/es/table';
|
|
import Button from 'antd/es/button';
|
|
import Input from 'antd/es/input';
|
|
import Space from 'antd/es/space';
|
|
import Alert from 'antd/es/alert';
|
|
import Typography from 'antd/es/typography';
|
|
import Popconfirm from 'antd/es/popconfirm';
|
|
import Checkbox from 'antd/es/checkbox';
|
|
import Tag from 'antd/es/tag';
|
|
|
|
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;
|
|
last_used_at: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
/** What a link gets when the form is left alone. Mirrors the server's default. */
|
|
const DEFAULT_CAP = '25';
|
|
|
|
/**
|
|
* Issuing and retiring the links that let someone without an account send in
|
|
* photos (#222).
|
|
*
|
|
* The token is shown exactly once, at creation, and cannot be recovered — the
|
|
* server stores only a digest. That is a deliberate property rather than an
|
|
* oversight, so this screen has to make the one-time nature obvious rather
|
|
* than leaving somebody to discover it by refreshing.
|
|
*/
|
|
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);
|
|
|
|
async function load() {
|
|
const res = await fetch('/api/admin/upload-links');
|
|
if (res.ok) setLinks(await res.json());
|
|
}
|
|
|
|
// Load-on-mount, the same shape Tags and Categories use. `load` only sets
|
|
// state after its fetch resolves, so nothing here is synchronous.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
useEffect(() => { void load(); }, []);
|
|
|
|
async function create() {
|
|
setCreating(true);
|
|
setError(null);
|
|
|
|
const res = await fetch('/api/admin/upload-links', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
// Sent explicitly in all three cases rather than omitted. null is how
|
|
// unlimited is asked for; the server's default only has to cover callers
|
|
// that are not this screen.
|
|
body: JSON.stringify({
|
|
label,
|
|
email,
|
|
maxSubmissions: unlimited ? null : Number(cap)
|
|
})
|
|
});
|
|
|
|
setCreating(false);
|
|
|
|
if (!res.ok) {
|
|
const payload = await res.json().catch(() => ({}));
|
|
setError(payload.error ?? 'Could not create the link.');
|
|
return;
|
|
}
|
|
|
|
const created = await res.json();
|
|
// Reset here, not before the request: a 400 returns before this point, so
|
|
// rejecting a second link (say, a mistyped address) can no longer clear
|
|
// the warning that belongs to a still-displayed token from an earlier,
|
|
// successful create.
|
|
setMailed(created.mail.sent);
|
|
setIssued(created.url);
|
|
setLabel('');
|
|
setEmail('');
|
|
setCap(DEFAULT_CAP);
|
|
setUnlimited(false);
|
|
await load();
|
|
}
|
|
|
|
async function revoke(id: number) {
|
|
await fetch(`/api/admin/upload-links/${id}/revoke`, { method: 'POST' });
|
|
await load();
|
|
}
|
|
|
|
return (
|
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
<Paragraph>
|
|
Give one link per person or purpose. If a link is shared further than you meant, revoke that
|
|
one — everything already sent through it is kept.
|
|
</Paragraph>
|
|
|
|
<Space wrap>
|
|
<Input
|
|
placeholder="Who or what is this for?"
|
|
aria-label="Link label"
|
|
value={label}
|
|
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"
|
|
value={cap}
|
|
disabled={unlimited}
|
|
onChange={(e) => setCap(e.target.value)}
|
|
style={{ width: 140 }}
|
|
/>
|
|
<Checkbox checked={unlimited} onChange={(e) => setUnlimited(e.target.checked)}>
|
|
No limit
|
|
</Checkbox>
|
|
<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"
|
|
showIcon
|
|
message="Copy this link now"
|
|
description={
|
|
<>
|
|
<Paragraph copyable={{ text: issued }}>
|
|
<Text code>{issued}</Text>
|
|
</Paragraph>
|
|
<Text type="secondary">
|
|
It is not stored and cannot be shown again. If you lose it, revoke this link and
|
|
make another.
|
|
</Text>
|
|
</>
|
|
}
|
|
closable
|
|
onClose={() => setIssued(null)}
|
|
/>
|
|
)}
|
|
|
|
<Table<UploadLink>
|
|
rowKey="id"
|
|
dataSource={links}
|
|
pagination={false}
|
|
columns={[
|
|
{ title: 'Label', dataIndex: 'label' },
|
|
// Not "Sent to": contact_email records only the address the admin
|
|
// gave when the link was created, never whether delivery actually
|
|
// happened — that outcome is shown once, at creation, and is not
|
|
// persisted. In QA, where every send is blocked by design, "Sent
|
|
// to" would be false for every row on the page.
|
|
{ title: 'Email', dataIndex: 'contact_email' },
|
|
{
|
|
title: 'Used',
|
|
render: (_, row) =>
|
|
row.max_submissions === null
|
|
? row.submission_count
|
|
: `${row.submission_count} of ${row.max_submissions}`
|
|
},
|
|
{
|
|
title: 'Status',
|
|
render: (_, row) =>
|
|
row.revoked_at ? <Tag>Revoked</Tag> : <Tag color="green">Active</Tag>
|
|
},
|
|
{
|
|
title: '',
|
|
render: (_, row) =>
|
|
row.revoked_at ? null : (
|
|
<Popconfirm
|
|
title="Revoke this link?"
|
|
description="Anyone holding it stops being able to send anything. Items already sent are kept."
|
|
// Named after the action rather than left as "OK", matching
|
|
// the okText this admin's other destructive confirms use.
|
|
// A confirm button that says OK makes the reader re-read the
|
|
// question to find out what they are agreeing to.
|
|
okText="Revoke"
|
|
okButtonProps={{ danger: true }}
|
|
onConfirm={() => revoke(row.id)}
|
|
>
|
|
<Button danger size="small">
|
|
Revoke
|
|
</Button>
|
|
</Popconfirm>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
</Space>
|
|
);
|
|
}
|