From 1e976555efe84330fba224181b1bce849eebb89f Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 02:28:49 +0200 Subject: [PATCH] feat(admin): intake links panel with mint, copy, rotate, revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8a Task 15. Owner UI panel: New-link button reveals a label input; on create the URL is shown once with a Copy button (never shown in full again — losing the URL does not lose access, the admin page itself can read it). Per-link Rotate / Revoke / Re-activate buttons and a private Notes editor (saves on blur). --- src/components/admin/intake-links-panel.tsx | 242 ++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 src/components/admin/intake-links-panel.tsx diff --git a/src/components/admin/intake-links-panel.tsx b/src/components/admin/intake-links-panel.tsx new file mode 100644 index 0000000..b321b7d --- /dev/null +++ b/src/components/admin/intake-links-panel.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Copy, RefreshCw, Power, PowerOff, Plus } from "lucide-react"; + +import { + createIntakeLinkAction, + rotateIntakeTokenAction, + setIntakeLinkActiveAction, + setIntakeLinkNotesAction, +} from "@/app/actions/intake-links"; +import type { IntakeLinkRecord } from "@/lib/pb/types"; + +/** + * Owner UI for public intake links. + * + * The URL is shown once on creation and on rotation. After that the owner + * can only re-copy the same URL — losing the URL does not lose access, + * because the admin page itself can read it. The token itself never leaves + * this panel except via a clipboard write the owner explicitly triggers. + * + * Notes are persisted on save; the others act on click. The list shows + * status, expiry, daily cap and the lifetime submission count, which is + * what the spec's §5.3 said the owner UI should read. + */ +export function IntakeLinksPanel({ + projectId, + links, +}: { + projectId: string; + links: IntakeLinkRecord[]; +}) { + const [pending, startTransition] = useTransition(); + const [newUrl, setNewUrl] = useState(null); + const [newLabel, setNewLabel] = useState(""); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + + function create() { + if (!newLabel.trim()) return; + setError(null); + startTransition(async () => { + try { + const result = await createIntakeLinkAction({ + projectId, + label: newLabel, + }); + setNewUrl(result.url); + setNewLabel(""); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not create link"); + } + }); + } + + function rotate(link: IntakeLinkRecord) { + startTransition(async () => { + const result = await rotateIntakeTokenAction({ + linkId: link.id, + projectId, + }); + setNewUrl(result.url); + }); + } + + function toggle(link: IntakeLinkRecord) { + startTransition(async () => { + await setIntakeLinkActiveAction({ + linkId: link.id, + projectId, + active: !link.active, + }); + }); + } + + function saveNotes(link: IntakeLinkRecord, notes: string) { + startTransition(async () => { + await setIntakeLinkNotesAction({ + linkId: link.id, + projectId, + notes, + }); + }); + } + + const field = + "w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs text-text-secondary"; + + return ( +
+
+
+

+ Public intake links +

+

+ Each link is a URL a client can open without signing in. Treat + them like passwords — they do not expire on their own. +

+
+ +
+ + {showCreate && ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ )} + + {newUrl && ( +
+

+ New URL — copy it now, it won't be shown again in full: +

+
+ + + +
+
+ )} + + {links.length === 0 ? ( +

No links yet.

+ ) : ( +
    + {links.map((link) => ( +
  • +
    + + {link.label} + + + {link.active ? "Active" : "Revoked"} ·{" "} + {link.submission_count}{" "} + total + {link.expires_at && ( + <> · expires {link.expires_at.slice(0, 10)} + )} + + + +
    + + +
  • + ))} +
+ )} +
+ ); +} + +function NotesEditor({ + link, + onSave, +}: { + link: IntakeLinkRecord; + onSave: (link: IntakeLinkRecord, notes: string) => void; +}) { + const [value, setValue] = useState(link.notes); + return ( +
+ + Notes + +