feat: add promotion review UI

This commit is contained in:
Hermes Agent
2026-06-20 13:33:14 +10:00
parent d9298239c9
commit 3b20137a98
7 changed files with 733 additions and 6 deletions
+5 -2
View File
@@ -2,17 +2,19 @@ import { useEffect, useState } from 'react';
import LoginPage from './routes/LoginPage';
import PrivateLinksPage from './routes/PrivateLinksPage';
import PublicLinksPage from './routes/PublicLinksPage';
import AdminReviewPage from './routes/AdminReviewPage';
type RouteId = 'private' | 'public' | 'login';
type RouteId = 'private' | 'public' | 'admin' | 'login';
const ROUTES: readonly { id: RouteId; label: string }[] = [
{ id: 'private', label: 'Private Links' },
{ id: 'public', label: 'Public Directory' },
{ id: 'admin', label: 'Admin Review' },
{ id: 'login', label: 'Login' },
];
function readRouteFromHash(): RouteId {
const match = window.location.hash.match(/^#\/(private|public|login)/);
const match = window.location.hash.match(/^#\/(private|public|admin|login)/);
return (match?.[1] as RouteId) ?? 'private';
}
@@ -54,6 +56,7 @@ export default function App() {
<main className="app-main">
{route === 'private' ? <PrivateLinksPage /> : null}
{route === 'public' ? <PublicLinksPage /> : null}
{route === 'admin' ? <AdminReviewPage /> : null}
{route === 'login' ? <LoginPage /> : null}
</main>
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import type { Link } from '../lib/api';
interface PromotionDialogProps {
/** Links selected for promotion in this submission. Exactly one must be chosen. */
readonly links: Link[];
readonly onSubmit: (input: { privateLinkId: string; proposedAlias: string; note?: string }) => Promise<void>;
readonly onClose: () => void;
}
const EMPTY_NOTE = '';
function defaultAlias(link: Link): string {
return link.alias;
}
export default function PromotionDialog({ links, onSubmit, onClose }: PromotionDialogProps) {
const [selectedLinkId, setSelectedLinkId] = useState<string>(() => links[0]?.id ?? '');
const [proposedAlias, setProposedAlias] = useState<string>(() => links[0]?.alias ?? '');
const [note, setNote] = useState<string>(EMPTY_NOTE);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Re-seed selection/alias when the set of links changes (e.g. user toggles
// checkboxes while the dialog is open).
useEffect(() => {
setSelectedLinkId((prev) => (links.some((l) => l.id === prev) ? prev : (links[0]?.id ?? '')));
}, [links]);
useEffect(() => {
const current = links.find((l) => l.id === selectedLinkId);
setProposedAlias(current ? defaultAlias(current) : '');
}, [selectedLinkId, links]);
const single = links.length === 1;
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitting || !selectedLinkId) {
return;
}
setError(null);
setSubmitting(true);
try {
await onSubmit({
privateLinkId: selectedLinkId,
proposedAlias: proposedAlias.trim(),
note: note.trim() ? note.trim() : undefined,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit promotion');
} finally {
setSubmitting(false);
}
}
if (links.length === 0) {
return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Promote private link">
<div className="dialog-card">
<h2>Promote to public</h2>
<p className="muted">Select at least one private link to promote.</p>
<div className="form-actions">
<button type="button" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
}
return (
<div className="dialog-backdrop" role="dialog" aria-modal="true" aria-label="Promote private link">
<form className="dialog-card link-form" onSubmit={handleSubmit}>
<div className="dialog-header">
<h2>Promote to public directory</h2>
<button type="button" className="dialog-close" aria-label="Close" onClick={onClose} disabled={submitting}>×</button>
</div>
{!single ? (
<div className="field">
<label htmlFor="promotion-link">Private link</label>
<select
id="promotion-link"
value={selectedLinkId}
onChange={(e) => setSelectedLinkId(e.target.value)}
disabled={submitting}
>
{links.map((link) => (
<option key={link.id} value={link.id}>{link.alias}</option>
))}
</select>
<small className="hint">Choose which private link to submit for promotion.</small>
</div>
) : (
<p className="muted">
Promoting <code>{links[0].alias}</code>
{links[0].targetUrl ? <> <span className="truncate">{links[0].targetUrl}</span></> : null}
</p>
)}
<div className="field">
<label htmlFor="promotion-alias">Proposed public alias</label>
<input
id="promotion-alias"
name="proposedAlias"
value={proposedAlias}
onChange={(e) => setProposedAlias(e.target.value)}
required
maxLength={100}
autoComplete="off"
placeholder="public-alias"
disabled={submitting}
/>
<small className="hint">The alias reviewers will publish this link under.</small>
</div>
<div className="field">
<label htmlFor="promotion-note">Note for reviewers (optional)</label>
<textarea
id="promotion-note"
name="note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={1000}
placeholder="Why should this be public?"
disabled={submitting}
/>
</div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<div className="form-actions">
<button type="submit" disabled={submitting || !proposedAlias.trim()}>
{submitting ? 'Submitting…' : 'Submit for review'}
</button>
<button type="button" onClick={onClose} disabled={submitting}>Cancel</button>
</div>
</form>
</div>
);
}
+88
View File
@@ -158,6 +158,94 @@ async function deleteLink(path: string): Promise<DeleteResponse> {
return result ?? { ok: true };
}
// ---- Promotion submissions ----
export type SubmissionStatus = 'pending' | 'approved' | 'rejected' | 'needs_changes';
export type AdminSubmissionFilter = SubmissionStatus;
export interface PromotionSubmission {
id: string;
privateLinkId: string;
submittedByUserId: string;
proposedAlias: string;
note: string | null;
status: SubmissionStatus;
reviewedByUserId: string | null;
rejectionReason: string | null;
publicLinkId: string | null;
createdAt: string;
reviewedAt: string | null;
privateLinkAlias: string | null;
privateLinkTargetUrl: string | null;
}
export interface SubmissionListResponse {
submissions: PromotionSubmission[];
}
export interface SubmissionResponse {
submission: PromotionSubmission;
}
export interface ApproveSubmissionResponse extends SubmissionResponse {
publicLink: Link;
}
export interface PromoteLinkInput {
privateLinkId: string;
proposedAlias: string;
note?: string;
}
export interface ReviewActionInput {
reason?: string;
}
export function promoteLink(input: PromoteLinkInput): Promise<SubmissionResponse> {
return request<SubmissionResponse>('/api/promotions', {
method: 'POST',
body: JSON.stringify({
privateLinkId: input.privateLinkId,
proposedAlias: input.proposedAlias,
...(input.note !== undefined && input.note !== '' ? { note: input.note } : {}),
}),
});
}
export function listMySubmissions(): Promise<SubmissionListResponse> {
return request<SubmissionListResponse>('/api/promotions/mine');
}
export function listAdminSubmissions(status: AdminSubmissionFilter): Promise<SubmissionListResponse> {
const query = `?status=${encodeURIComponent(status)}`;
return request<SubmissionListResponse>(`/api/admin/promotions${query}`);
}
export function approveSubmission(id: string, input: ReviewActionInput = {}): Promise<ApproveSubmissionResponse> {
return request<ApproveSubmissionResponse>(`/api/admin/promotions/${encodeURIComponent(id)}/approve`, {
method: 'POST',
body: JSON.stringify(reviewBody(input)),
});
}
export function rejectSubmission(id: string, input: ReviewActionInput = {}): Promise<SubmissionResponse> {
return request<SubmissionResponse>(`/api/admin/promotions/${encodeURIComponent(id)}/reject`, {
method: 'POST',
body: JSON.stringify(reviewBody(input)),
});
}
export function needsChangesSubmission(id: string, input: ReviewActionInput = {}): Promise<SubmissionResponse> {
return request<SubmissionResponse>(`/api/admin/promotions/${encodeURIComponent(id)}/needs-changes`, {
method: 'POST',
body: JSON.stringify(reviewBody(input)),
});
}
function reviewBody(input: ReviewActionInput): Record<string, string> {
return input.reason !== undefined && input.reason !== '' ? { reason: input.reason } : {};
}
// ---- Pure helper: build a preview URL for parameterized redirect links ----
// Returns null when the target URL is not usable as a template.
export function buildPreviewUrl(targetUrl: string | null, params: Record<string, string> = {}): string | null {
+183
View File
@@ -0,0 +1,183 @@
import { useCallback, useEffect, useState } from 'react';
import {
type AdminSubmissionFilter,
type PromotionSubmission,
type SubmissionStatus,
approveSubmission,
listAdminSubmissions,
needsChangesSubmission,
rejectSubmission,
} from '../lib/api';
const STATUS_FILTERS: readonly AdminSubmissionFilter[] = ['pending', 'needs_changes', 'approved', 'rejected'];
const STATUS_LABELS: Record<SubmissionStatus, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
needs_changes: 'Needs changes',
};
interface PendingAction {
readonly id: string;
readonly kind: 'approve' | 'reject' | 'needs-changes';
}
export default function AdminReviewPage() {
const [status, setStatus] = useState<AdminSubmissionFilter>('pending');
const [submissions, setSubmissions] = useState<PromotionSubmission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<PendingAction | null>(null);
const [reason, setReason] = useState<string>('');
const refresh = useCallback(async (filter: AdminSubmissionFilter) => {
setLoading(true);
setError(null);
try {
const result = await listAdminSubmissions(filter);
setSubmissions(result.submissions ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load submissions');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh(status);
}, [refresh, status]);
function startAction(id: string, kind: PendingAction['kind']) {
setPending({ id, kind });
setReason('');
}
function cancelAction() {
setPending(null);
setReason('');
}
async function confirmAction() {
if (!pending) {
return;
}
const { id, kind } = pending;
const trimmed = reason.trim() ? reason.trim() : undefined;
try {
if (kind === 'approve') {
await approveSubmission(id, trimmed ? { reason: trimmed } : {});
} else if (kind === 'reject') {
await rejectSubmission(id, trimmed ? { reason: trimmed } : {});
} else {
await needsChangesSubmission(id, trimmed ? { reason: trimmed } : {});
}
setPending(null);
setReason('');
await refresh(status);
} catch (err) {
setError(err instanceof Error ? err.message : 'Review action failed');
}
}
return (
<section className="panel">
<header className="panel-header">
<div>
<h1>Admin review</h1>
<p className="muted">Review promotion submissions from private to public links.</p>
</div>
<div className="panel-actions">
{STATUS_FILTERS.map((filter) => (
<button
key={filter}
type="button"
className={`tab-button${status === filter ? ' is-active' : ''}`}
onClick={() => setStatus(filter)}
>
{STATUS_LABELS[filter]}
</button>
))}
<button type="button" onClick={() => void refresh(status)} disabled={loading}>Refresh</button>
</div>
</header>
{error ? <p className="form-error" role="alert">{error}</p> : null}
{loading ? (
<p className="table-status" aria-busy="true">Loading submissions</p>
) : submissions.length === 0 ? (
<p className="table-empty">No {STATUS_LABELS[status].toLowerCase()} submissions.</p>
) : (
<table className="link-table review-table">
<thead>
<tr>
<th scope="col">Proposed alias</th>
<th scope="col">Private link</th>
<th scope="col">Submitted</th>
<th scope="col">Status</th>
<th scope="col" className="col-actions">Actions</th>
</tr>
</thead>
<tbody>
{submissions.map((submission) => (
<tr key={submission.id}>
<td>
<code>{submission.proposedAlias}</code>
{submission.note ? <small className="row-description">{submission.note}</small> : null}
{submission.rejectionReason ? (
<small className="row-description review-reason">Reason: {submission.rejectionReason}</small>
) : null}
</td>
<td>
{submission.privateLinkAlias ? <code>{submission.privateLinkAlias}</code> : <span className="muted"></span>}
{submission.privateLinkTargetUrl ? (
<small className="row-description truncate">{submission.privateLinkTargetUrl}</small>
) : null}
</td>
<td>
<time dateTime={submission.createdAt}>{new Date(submission.createdAt).toLocaleString()}</time>
</td>
<td>
<span className={`status-badge status-${submission.status}`}>{STATUS_LABELS[submission.status]}</span>
</td>
<td className="col-actions">
{pending?.id === submission.id ? (
<div className="inline-review">
<input
type="text"
aria-label="Reason"
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
/>
<button type="button" className="link-action link-action--confirm" onClick={() => void confirmAction()} disabled={loading}>
Confirm
</button>
<button type="button" className="link-action" onClick={cancelAction} disabled={loading}>Cancel</button>
</div>
) : submission.status === 'pending' ? (
<>
<button type="button" className="link-action link-action--approve" onClick={() => startAction(submission.id, 'approve')} disabled={loading}>
Approve
</button>
<button type="button" className="link-action link-action--danger" onClick={() => startAction(submission.id, 'reject')} disabled={loading}>
Reject
</button>
<button type="button" className="link-action" onClick={() => startAction(submission.id, 'needs-changes')} disabled={loading}>
Needs changes
</button>
</>
) : (
<span className="muted"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
+43 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import LinkForm from '../components/LinkForm';
import LinkTable from '../components/LinkTable';
import PromotionDialog from '../components/PromotionDialog';
import {
ApiClientError,
type Link,
@@ -8,6 +9,7 @@ import {
createPrivateLink,
deletePrivateLink,
listPrivateLinks,
promoteLink,
updatePrivateLink,
} from '../lib/api';
@@ -18,6 +20,13 @@ export default function PrivateLinksPage() {
const [editing, setEditing] = useState<Link | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [showPromote, setShowPromote] = useState(false);
const [promoteError, setPromoteError] = useState<string | null>(null);
const selectedLinks = useMemo(
() => links.filter((link) => selected.has(link.id)),
[links, selected],
);
const refresh = useCallback(async () => {
setLoading(true);
@@ -92,6 +101,18 @@ export default function PrivateLinksPage() {
}
}
async function handlePromote(input: { privateLinkId: string; proposedAlias: string; note?: string }) {
setPromoteError(null);
try {
await promoteLink(input);
setShowPromote(false);
setSelected(new Set());
setError(null);
} catch (err) {
throw new Error(err instanceof ApiClientError ? err.message : 'Failed to submit promotion');
}
}
return (
<section className="panel">
<header className="panel-header">
@@ -108,11 +129,29 @@ export default function PrivateLinksPage() {
</header>
{selected.size > 0 ? (
<p className="selection-bar" role="status">
{selected.size} selected. Promotion to public links is coming in a later task.
</p>
<div className="selection-bar" role="status">
<span>{selected.size} selected.</span>
<button
type="button"
className="link-action link-action--approve"
onClick={() => { setPromoteError(null); setShowPromote(true); }}
>
Promote to public
</button>
<button type="button" className="link-action" onClick={() => setSelected(new Set())}>Clear</button>
</div>
) : null}
{showPromote ? (
<PromotionDialog
links={selectedLinks}
onSubmit={handlePromote}
onClose={() => setShowPromote(false)}
/>
) : null}
{promoteError ? <p className="form-error" role="alert">{promoteError}</p> : null}
{showCreate ? (
<div className="form-card">
<h2>Create link</h2>
+121
View File
@@ -315,6 +315,127 @@ button.link-action--danger:hover { background: var(--danger-soft); }
padding: 0.5rem 0.75rem;
}
.selection-bar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.selection-bar > span { font-weight: 600; }
/* ---- Promotion dialog ---- */
.dialog-backdrop {
align-items: center;
background: rgb(23 32 51 / 45%);
display: flex;
inset: 0;
justify-content: center;
padding: 1rem;
position: fixed;
z-index: 50;
}
.dialog-card {
background: white;
border-radius: var(--radius);
box-shadow: 0 24px 60px rgb(23 32 51 / 18%);
max-width: 480px;
padding: 1.25rem 1.5rem;
width: 100%;
}
.dialog-header {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.dialog-header h2 { font-size: 1.15rem; margin: 0; }
.dialog-close {
background: transparent;
border: none;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
padding: 0 0.35rem;
}
.dialog-close:hover { background: var(--accent-soft); color: var(--accent); }
.dialog-card select {
border: 1px solid var(--border-strong);
border-radius: 8px;
font: inherit;
padding: 0.5rem 0.65rem;
width: 100%;
}
/* ---- Admin review table ---- */
.tab-button {
background: white;
border: 1px solid var(--border-strong);
color: #172033;
font-size: 0.85rem;
padding: 0.3rem 0.7rem;
}
.tab-button:hover { background: var(--accent-soft); }
.tab-button.is-active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.review-table .review-reason { color: var(--danger); }
.status-badge {
border-radius: 999px;
display: inline-block;
font-size: 0.75rem;
font-weight: 700;
padding: 0.15rem 0.55rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-rejected { background: var(--danger-soft); color: var(--danger); }
.status-needs_changes { background: #e0e7ff; color: #3730a3; }
.link-action--approve {
border-color: #bbf7d0;
color: #166534;
}
.link-action--approve:hover { background: #dcfce7; }
.link-action--confirm {
border-color: var(--accent);
color: var(--accent);
}
.inline-review {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.inline-review input[type="text"] {
border: 1px solid var(--border-strong);
border-radius: 8px;
font: inherit;
padding: 0.3rem 0.5rem;
width: 12rem;
}
/* ---- Admin tools ---- */
.admin-tools {
+151
View File
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
approveSubmission,
listAdminSubmissions,
listMySubmissions,
needsChangesSubmission,
promoteLink,
rejectSubmission,
} from '../src/lib/api';
const originalFetch = globalThis.fetch;
function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => {
status?: number;
body?: unknown;
statusText?: string;
}) {
const calls: { path: string; init?: RequestInit }[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = typeof input === 'string' ? input : new URL(input.toString()).pathname;
calls.push({ path, init });
const { status = 200, body = {}, statusText = 'OK' } = responder(input, init);
return new Response(JSON.stringify(body), {
status,
statusText,
headers: { 'content-type': 'application/json' },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return { calls, fetchMock };
}
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('promotion api client', () => {
it('promoteLink POSTs privateLinkId, proposedAlias, note to /api/promotions', async () => {
const { calls } = mockFetch((_input, init) => ({
status: 201,
body: { submission: { id: 'sub_1', status: 'pending', proposedAlias: 'docs' } },
}));
const result = await promoteLink({
privateLinkId: 'priv_1',
proposedAlias: 'docs',
note: 'please review',
});
expect(calls[0].path).toBe('/api/promotions');
expect(calls[0].init?.method).toBe('POST');
expect(calls[0].init?.credentials).toBe('include');
const body = JSON.parse(calls[0].init?.body as string);
expect(body).toEqual({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: 'please review' });
expect(result.submission.id).toBe('sub_1');
});
it('promoteLink omits the note field when it is empty or undefined', async () => {
const { calls } = mockFetch(() => ({
status: 201,
body: { submission: { id: 'sub_2', status: 'pending' } },
}));
await promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs', note: '' });
const body = JSON.parse(calls[0].init?.body as string);
expect(body).not.toHaveProperty('note');
});
it('listMySubmissions GETs /api/promotions/mine with credentials', async () => {
const { calls } = mockFetch(() => ({
body: { submissions: [{ id: 'sub_1', status: 'pending', proposedAlias: 'docs' }] },
}));
const result = await listMySubmissions();
expect(calls[0].path).toBe('/api/promotions/mine');
expect(calls[0].init?.method).toBeUndefined();
expect(calls[0].init?.credentials).toBe('include');
expect(result.submissions).toHaveLength(1);
});
it('listAdminSubmissions encodes the status filter into the query string', async () => {
const { calls } = mockFetch(() => ({ body: { submissions: [] } }));
await listAdminSubmissions('needs_changes');
expect(calls[0].path).toBe('/api/admin/promotions?status=needs_changes');
});
it('approveSubmission POSTs to /api/admin/promotions/:id/approve and returns publicLink', async () => {
const { calls } = mockFetch(() => ({
body: {
submission: { id: 'sub_1', status: 'approved' },
publicLink: { id: 'pub_1', alias: 'docs', scope: 'public' },
},
}));
const result = await approveSubmission('sub_1', { reason: 'lgtm' });
expect(calls[0].path).toBe('/api/admin/promotions/sub_1/approve');
expect(calls[0].init?.method).toBe('POST');
const body = JSON.parse(calls[0].init?.body as string);
expect(body).toEqual({ reason: 'lgtm' });
expect(result.publicLink.id).toBe('pub_1');
});
it('rejectSubmission posts an empty object when no reason is given', async () => {
const { calls } = mockFetch(() => ({
body: { submission: { id: 'sub_1', status: 'rejected', rejectionReason: null } },
}));
await rejectSubmission('sub_1');
expect(calls[0].path).toBe('/api/admin/promotions/sub_1/reject');
const body = JSON.parse(calls[0].init?.body as string);
expect(body).toEqual({});
});
it('needsChangesSubmission targets the needs-changes endpoint', async () => {
const { calls } = mockFetch(() => ({
body: { submission: { id: 'sub_1', status: 'needs_changes' } },
}));
await needsChangesSubmission('sub_1', { reason: 'tighten description' });
expect(calls[0].path).toBe('/api/admin/promotions/sub_1/needs-changes');
const body = JSON.parse(calls[0].init?.body as string);
expect(body).toEqual({ reason: 'tighten description' });
});
it('encodes submission ids with special characters in review paths', async () => {
const { calls } = mockFetch(() => ({ body: { submission: { id: 'a/b', status: 'approved' } } }));
await approveSubmission('a/b');
expect(calls[0].path).toBe('/api/admin/promotions/a%2Fb/approve');
});
it('surfaces server errors as ApiClientError', async () => {
mockFetch(() => ({ status: 409, body: { error: 'A pending submission for this link and alias already exists' } }));
await expect(promoteLink({ privateLinkId: 'priv_1', proposedAlias: 'docs' }))
.rejects.toMatchObject({
name: 'ApiClientError',
status: 409,
message: 'A pending submission for this link and alias already exists',
});
});
});