feat(intake): issue named upload links and accept photo submissions (#222) #250
@@ -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 /> }
|
||||
|
||||
@@ -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