feat(admin): intake links panel with mint, copy, rotate, revoke

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).
This commit is contained in:
Dave 2026-09-21 02:28:49 +02:00
parent c29f855ffd
commit 1e976555ef

View file

@ -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<string | null>(null);
const [newLabel, setNewLabel] = useState("");
const [error, setError] = useState<string | null>(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 (
<section className="space-y-4 rounded-[var(--radius-tile)] border border-border-subtle p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-text-primary">
Public intake links
</h2>
<p className="mt-0.5 text-xs text-text-muted">
Each link is a URL a client can open without signing in. Treat
them like passwords — they do not expire on their own.
</p>
</div>
<button
type="button"
onClick={() => setShowCreate((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised"
>
<Plus size={13} strokeWidth={2} />
{showCreate ? "Cancel" : "New link"}
</button>
</div>
{showCreate && (
<div className="space-y-2 rounded-lg border border-border-subtle p-3">
<label className="block space-y-1">
<span className="text-xs text-text-muted">Label</span>
<input
autoFocus
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
placeholder="Acme — till phone"
className={field}
/>
</label>
<button
type="button"
disabled={pending || !newLabel.trim()}
onClick={create}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg hover:bg-brand-hover disabled:opacity-50"
>
Create link
</button>
{error && (
<p role="alert" className="text-xs text-accent">
{error}
</p>
)}
</div>
)}
{newUrl && (
<div className="space-y-1 rounded-lg border border-border-subtle bg-surface-raised p-3">
<p className="text-xs text-text-muted">
New URL — copy it now, it won&apos;t be shown again in full:
</p>
<div className="flex items-center gap-2">
<input readOnly value={newUrl} className={`${field} font-mono`} />
<button
type="button"
onClick={() => navigator.clipboard.writeText(newUrl)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-2 text-xs text-text-secondary hover:bg-surface"
>
<Copy size={13} strokeWidth={2} />
Copy
</button>
<button
type="button"
onClick={() => setNewUrl(null)}
className="px-2 py-2 text-xs text-text-muted hover:text-text-secondary"
>
Dismiss
</button>
</div>
</div>
)}
{links.length === 0 ? (
<p className="text-xs text-text-muted">No links yet.</p>
) : (
<ul className="space-y-2">
{links.map((link) => (
<li
key={link.id}
className="space-y-2 rounded-lg border border-border-subtle p-3"
>
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 text-sm text-text-primary">
{link.label}
</span>
<span className="text-[11px] text-text-muted">
{link.active ? "Active" : "Revoked"} ·{" "}
<span className="tabular">{link.submission_count}</span>{" "}
total
{link.expires_at && (
<> · expires {link.expires_at.slice(0, 10)}</>
)}
</span>
<button
type="button"
disabled={pending}
onClick={() => rotate(link)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2 py-1 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
title="Generate a new token — the old URL stops working"
>
<RefreshCw size={12} strokeWidth={2} />
Rotate
</button>
<button
type="button"
disabled={pending}
onClick={() => toggle(link)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2 py-1 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
title={link.active ? "Revoke this link" : "Re-activate"}
>
{link.active ? <PowerOff size={12} strokeWidth={2} /> : <Power size={12} strokeWidth={2} />}
{link.active ? "Revoke" : "Re-activate"}
</button>
</div>
<NotesEditor link={link} onSave={saveNotes} />
</li>
))}
</ul>
)}
</section>
);
}
function NotesEditor({
link,
onSave,
}: {
link: IntakeLinkRecord;
onSave: (link: IntakeLinkRecord, notes: string) => void;
}) {
const [value, setValue] = useState(link.notes);
return (
<div className="space-y-1">
<span className="text-[11px] uppercase tracking-wide text-text-muted">
Notes
</span>
<textarea
rows={2}
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
if (value !== link.notes) onSave(link, value);
}}
placeholder="Private to you"
className="w-full rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs text-text-secondary outline-none focus:border-brand"
/>
</div>
);
}