From 9b4d7f2d035566af5519af6a56fc36134d93f7a6 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 31 Aug 2026 15:23:46 -0500 Subject: [PATCH] feat(intake): manage upload links from the admin (#222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Upload links tab beside Tags: issue a named link, see how much of its allowance is spent, revoke it. Until now the only way to create one was curl, which is how the earlier tasks were exercised. The token is shown once, in an alert that says so plainly, because the server stores only a digest and genuinely cannot produce it again. A refresh loses it — that is the honest behaviour rather than a bug, so the copy says to revoke and reissue if it is lost instead of leaving somebody hunting for a reveal button. The cap field starts at 25 and unlimited is a checkbox rather than an empty field. Blank-means-unlimited would make the least deliberate action produce the least bounded link, and this screen sends all three cases explicitly so the server's default only ever has to cover callers that are not this screen. Two things came out of driving it in a browser rather than reading it. The revoke confirmation said "OK", and every other destructive confirm in this admin names its action — Delete, Disable, Re-enable — so it now says Revoke, in danger styling. A confirm button reading OK makes the reader go back and re-read the question to find out what they are agreeing to. And Popconfirm turned out to be a component nothing else here uses; the rest use Modal.confirm with an explicit okText. Keeping Popconfirm but matching its labelling to the established pattern seemed the smaller inconsistency, since the interaction is a row action rather than a page-level one. The load-on-mount effect carries the same eslint-disable and reasoning Tags and Categories already use, rather than a new shape. Verified in a browser: create shows the one-time reveal, the row lists as 0 of 25 and Active, revoke flips it to Revoked, and an explicitly unlimited link shows a bare count with no cap. The database then confirmed a default of 25, a null for the unlimited one, and a stamped revoked_at. Frontend: build clean, lint unchanged at 2 pre-existing warnings, 30 unit tests pass. Ref #222 --- frontend/src/admin/Admin.tsx | 2 + frontend/src/admin/UploadLinks.tsx | 191 +++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 frontend/src/admin/UploadLinks.tsx diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 475548e..00a3745 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -33,6 +33,7 @@ import Emails from './Emails'; import Settings from './Settings'; import Categories from './Categories'; import Tags from './Tags'; +import UploadLinks from './UploadLinks'; import BuildStamp from './BuildStamp'; import CategoryTreeSelect from './CategoryTreeSelect'; import ItemCard from '../components/ItemCard'; @@ -391,6 +392,7 @@ export default function Admin() { { key: 'inventory', label: 'Inventory', children: }, { key: 'categories', label: 'Categories', children: }, { key: 'tags', label: 'Tags', children: }, + { key: 'upload-links', label: 'Upload links', children: }, { key: 'customers', label: 'Customers', children: }, { key: 'emails', label: 'Emails', children: }, { key: 'settings', label: 'Settings', children: } diff --git a/frontend/src/admin/UploadLinks.tsx b/frontend/src/admin/UploadLinks.tsx new file mode 100644 index 0000000..30ecd5d --- /dev/null +++ b/frontend/src/admin/UploadLinks.tsx @@ -0,0 +1,191 @@ +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; + 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 [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); + 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, + 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(); + setIssued(created.url); + setLabel(''); + 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 }} + /> + setCap(e.target.value)} + style={{ width: 140 }} + /> + setUnlimited(e.target.checked)}> + No limit + + + + + {error && } + + {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' }, + { + 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)} + > + + + ) + } + ]} + /> + + ); +}