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