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([]); 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); 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 ( 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. setLabel(e.target.value)} style={{ width: 260 }} /> setEmail(e.target.value)} style={{ width: 260 }} /> setCap(e.target.value)} style={{ width: 140 }} /> setUnlimited(e.target.checked)}> No limit {error && } {issued && mailed === false && ( )} {issued && ( {issued} It is not stored and cannot be shown again. If you lose it, revoke this link and make another. } closable onClose={() => setIssued(null)} /> )} 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 ? Revoked : Active }, { title: '', render: (_, row) => row.revoked_at ? null : ( revoke(row.id)} > ) } ]} /> ); }