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