feat(intake): issue named upload links and accept photo submissions (#222) #250

Merged
bermudalamb merged 9 commits from feature/222-upload-links into main 2026-08-31 15:47:10 -05:00
2 changed files with 193 additions and 0 deletions
Showing only changes of commit 9b4d7f2d03 - Show all commits
+2
View File
@@ -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: <Inventory /> },
{ key: 'categories', label: 'Categories', children: <Categories /> },
{ key: 'tags', label: 'Tags', children: <Tags /> },
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
{ key: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'emails', label: 'Emails', children: <Emails /> },
{ key: 'settings', label: 'Settings', children: <Settings /> }
+191
View File
@@ -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<UploadLink[]>([]);
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<string | 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,
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 (
<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
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() === ''}>
Create link
</Button>
</Space>
{error && <Alert type="error" message={error} showIcon />}
{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' },
{
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>
);
}