- {selected.size} selected. Promotion to public links is coming in a later task.
-
+ setShowPromote(false)}
+ />
+ ) : null}
+
+ {promoteError ? {promoteError}
: null}
+
{showCreate ? (
Create link
diff --git a/src/styles.css b/src/styles.css
index 15f7104..93aafbd 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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 {
diff --git a/tests/api.promotions-ui.test.ts b/tests/api.promotions-ui.test.ts
new file mode 100644
index 0000000..55579ef
--- /dev/null
+++ b/tests/api.promotions-ui.test.ts
@@ -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',
+ });
+ });
+});