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)}
+ />
+ )}
+
+