feat(intake): manage upload links from the admin (#222)
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
This commit is contained in:
@@ -33,6 +33,7 @@ import Emails from './Emails';
|
|||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
|
import UploadLinks from './UploadLinks';
|
||||||
import BuildStamp from './BuildStamp';
|
import BuildStamp from './BuildStamp';
|
||||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||||
import ItemCard from '../components/ItemCard';
|
import ItemCard from '../components/ItemCard';
|
||||||
@@ -391,6 +392,7 @@ export default function Admin() {
|
|||||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||||
|
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
|
||||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||||
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
||||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user