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:
parent
c29f855ffd
commit
1e976555ef
1 changed files with 242 additions and 0 deletions
242
src/components/admin/intake-links-panel.tsx
Normal file
242
src/components/admin/intake-links-panel.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue