feat(web): add /admin/requests approval queue with override modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:24:34 -04:00
parent b1b50187b3
commit b895e9ef7e
2 changed files with 574 additions and 0 deletions
+444
View File
@@ -0,0 +1,444 @@
<script lang="ts">
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import StatusPill from '$lib/components/StatusPill.svelte';
import type { LidarrRequest, LidarrRequestKind, LidarrRequestStatus } from '$lib/api/types';
// Approval queue. Tabs filter by status; rows expose Override/Approve/Reject
// per spec. The override modal collapses by default — most requests get the
// snapshot defaults, so we don't make the operator stare at two dropdowns
// they almost never touch.
const client = useQueryClient();
// The four tab filters. "Pending" is default — that's the actionable bucket;
// the rest are review-only views.
const tabs: { status: LidarrRequestStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'completed', label: 'Completed' },
{ status: 'rejected', label: 'Rejected' }
];
let activeStatus = $state<LidarrRequestStatus>('pending');
// The query factory is re-created on every status flip so TanStack treats
// each status as its own cache key. We don't need a single shared store.
const queryStore = $derived(createAdminRequestsQuery(activeStatus));
const query = $derived($queryStore);
const rows = $derived((query.data ?? []) as LidarrRequest[]);
// Quality + root dropdowns for the override modal. Always enabled; if Lidarr
// is disabled the modal is unreachable from this page anyway (the queue
// would be empty), and on this page we'd rather see the dropdown options
// than block the modal on a separate gate.
const profilesStore = createQualityProfilesQuery(true);
const profiles = $derived($profilesStore);
const foldersStore = createRootFoldersQuery(true);
const folders = $derived($foldersStore);
// Modal state. `overrideOpen` and `rejectOpen` carry the request id when
// open; null otherwise. The current row is looked up by id at render time.
let overrideOpen = $state<string | null>(null);
let qualityOverride = $state<number | ''>('');
let rootOverride = $state<string>('');
let rejectOpen = $state<string | null>(null);
let rejectNotes = $state<string>('');
// Toast surface — single line, fixed bottom-right, auto-clears after 5s.
// No third-party lib for v1; this is enough to surface the lidarr-unreachable
// error per spec §7 without dragging in a toast framework.
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | null = null;
function showToast(msg: string) {
if (toastTimer) clearTimeout(toastTimer);
toast = msg;
toastTimer = setTimeout(() => {
toast = null;
}, 5000);
}
function errorCopy(code: string): string {
switch (code) {
case 'lidarr_unreachable':
return "Lidarr is unreachable right now. Try again, or check Settings → Integrations.";
case 'lidarr_disabled':
return 'Lidarr integration is not enabled.';
case 'lidarr_auth_failed':
return 'Lidarr authentication failed.';
case 'request_not_pending':
return 'This request is no longer pending.';
default:
return code ? code : "Couldn't reach Lidarr.";
}
}
function fallbackIcon(kind: LidarrRequestKind) {
if (kind === 'artist') return Disc3;
if (kind === 'album') return Album;
return Music2;
}
function rowTitle(r: LidarrRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? '—';
return r.track_title ?? '—';
}
function rowAccessibleName(r: LidarrRequest): string {
if (r.kind === 'artist') return r.artist_name;
if (r.kind === 'album') return r.album_title ?? `this album by ${r.artist_name}`;
return r.track_title ?? `this track by ${r.artist_name}`;
}
// The id is a UUID — first 8 chars is plenty to disambiguate without a real
// username, and matches what the polish pass will replace with the real
// username surface.
function rowMeta(r: LidarrRequest): string {
const when = new Date(r.requested_at).toLocaleDateString();
const userBit = `by user ${r.user_id.slice(0, 8)}`;
return `${userBit} · ${when}`;
}
async function invalidate() {
// Invalidate every status bucket so e.g. an approve flips a row from the
// pending list to the approved list once the operator switches tabs.
await client.invalidateQueries({ queryKey: ['adminRequests'] });
}
async function onApprove(
r: LidarrRequest,
overrides?: { quality_profile_id?: number; root_folder_path?: string }
) {
try {
if (overrides && (overrides.quality_profile_id !== undefined || overrides.root_folder_path !== undefined)) {
await approveRequest(r.id, overrides);
} else {
await approveRequest(r.id);
}
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
}
}
function openOverride(r: LidarrRequest) {
overrideOpen = r.id;
// Reset to "Use defaults" each time — operators should consciously opt in
// to an override, not inherit the previous row's choice.
qualityOverride = '';
rootOverride = '';
}
function cancelOverride() {
overrideOpen = null;
}
async function confirmOverride(r: LidarrRequest) {
const overrides: { quality_profile_id?: number; root_folder_path?: string } = {};
if (qualityOverride !== '') overrides.quality_profile_id = Number(qualityOverride);
if (rootOverride !== '') overrides.root_folder_path = rootOverride;
overrideOpen = null;
await onApprove(r, overrides);
}
function openReject(r: LidarrRequest) {
rejectOpen = r.id;
rejectNotes = '';
}
function cancelReject() {
rejectOpen = null;
}
async function confirmReject(r: LidarrRequest) {
const notes = rejectNotes.trim();
rejectOpen = null;
try {
await rejectRequest(r.id, notes ? notes : undefined);
await invalidate();
} catch (e) {
const code = (e as { code?: string }).code ?? 'unknown';
showToast(errorCopy(code));
}
}
// Modal lookups: if the active tab has been refetched while a modal is
// open, the row may no longer be in `rows`. Guard against that by treating
// missing-row as closed.
const overrideRow = $derived(
overrideOpen ? rows.find((r) => r.id === overrideOpen) ?? null : null
);
const rejectRow = $derived(
rejectOpen ? rows.find((r) => r.id === rejectOpen) ?? null : null
);
</script>
<div class="space-y-6">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Requests</h2>
<p class="text-text-secondary">Approve or set aside what users have asked for.</p>
</header>
<nav aria-label="Request status filters" class="border-b border-border">
<ul class="flex gap-2">
{#each tabs as tab (tab.status)}
{@const isActive = activeStatus === tab.status}
<li>
<button
type="button"
role="tab"
aria-selected={isActive}
class="border-b-2 px-3 py-2 text-sm transition-colors {isActive
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
onclick={() => (activeStatus = tab.status)}
>
{tab.label}
{#if isActive}
<span
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
data-testid="active-tab-count"
>
{rows.length}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</nav>
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError}
<p class="text-error">Couldn't load requests.</p>
{:else if rows.length === 0}
<p class="text-text-secondary">Nothing here.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each rows as r (r.id)}
{@const Icon = fallbackIcon(r.kind)}
<li class="flex items-start gap-4 p-3" data-testid="admin-request-row" data-status={r.status}>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
aria-hidden="true"
>
<Icon size={24} strokeWidth={1} class="text-text-muted" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<span class="kind-pill">{r.kind}</span>
<StatusPill status={r.status} />
</div>
<div class="truncate text-base font-medium text-text-primary">
{rowTitle(r)}
</div>
<div class="truncate text-sm text-text-secondary">
{rowMeta(r)}
</div>
{#if r.kind === 'track' && r.album_title}
<div class="text-sm text-text-secondary" data-testid="track-disclosure">
Approving will add the album <em class="font-medium text-text-primary">{r.album_title}</em>.
</div>
{/if}
{#if r.notes}
<div class="text-sm text-text-secondary">{r.notes}</div>
{/if}
</div>
{#if r.status === 'pending'}
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`Override ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
onclick={() => openOverride(r)}
>
<SlidersHorizontal size={14} strokeWidth={1.5} />
Override
</button>
<button
type="button"
aria-label={`Approve ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
onclick={() => onApprove(r)}
>
<Check size={14} strokeWidth={2} />
Approve
</button>
<button
type="button"
aria-label={`Reject ${rowAccessibleName(r)}`}
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={() => openReject(r)}
>
<X size={14} strokeWidth={2} />
Reject
</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{#if overrideRow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelOverride}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="override-title" class="font-display text-lg font-medium text-text-primary">
Approve with override
</h3>
<p class="mt-2 text-sm text-text-secondary">
Leave fields blank to use the saved defaults.
</p>
<label class="mt-4 block">
<span class="block text-sm text-text-secondary">Quality profile</span>
<select
bind:value={qualityOverride}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each profiles.data ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</label>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Root folder</span>
<select
bind:value={rootOverride}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">Use default</option>
{#each folders.data ?? [] as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (not accessible)'}</option>
{/each}
</select>
</label>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={cancelOverride}
>
Cancel
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary"
onclick={() => confirmOverride(overrideRow!)}
>
<Check size={14} strokeWidth={2} />
Approve with override
</button>
</div>
</div>
</div>
{/if}
{#if rejectRow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.5);"
onclick={cancelReject}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="reject-title"
class="w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-lg"
onclick={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3 id="reject-title" class="font-display text-lg font-medium text-text-primary">
Reject request?
</h3>
<p class="mt-2 text-sm text-text-secondary">
Notes are shown to the requester.
</p>
<label class="mt-3 block">
<span class="block text-sm text-text-secondary">Notes</span>
<textarea
bind:value={rejectNotes}
rows="3"
placeholder="Optional — why this is being set aside"
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
></textarea>
</label>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
onclick={cancelReject}
>
Cancel
</button>
<button
type="button"
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-text-primary"
onclick={() => confirmReject(rejectRow!)}
>
<X size={14} strokeWidth={2} />
Confirm reject
</button>
</div>
</div>
</div>
{/if}
{#if toast}
<div
role="status"
aria-live="polite"
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
data-testid="toast"
>
{toast}
</div>
{/if}
<style>
.kind-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
text-transform: capitalize;
}
</style>
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrRequest } from '$lib/api/types';
// Wrap useQueryClient so the page can call invalidateQueries() without a
// real QueryClient context. Everything else from svelte-query passes through.
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/admin', () => ({
createAdminRequestsQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
approveRequest: vi.fn(),
rejectRequest: vi.fn()
}));
import AdminRequestsPage from './+page.svelte';
import {
createAdminRequestsQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
approveRequest,
rejectRequest
} from '$lib/api/admin';
const baseRow: LidarrRequest = {
id: 'r1',
user_id: 'u1234567-aaaa-bbbb-cccc-deadbeef0001',
status: 'pending',
kind: 'album',
lidarr_artist_mbid: 'a-mbid',
lidarr_album_mbid: 'al-mbid',
artist_name: 'Boards of Canada',
album_title: 'Geogaddi',
requested_at: '2026-04-29T10:00:00Z',
updated_at: '2026-04-29T10:00:00Z'
};
afterEach(() => vi.clearAllMocks());
function setup(rows: LidarrRequest[] = [baseRow]) {
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: rows })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ id: 1, name: 'Standard' }] })
);
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
);
return render(AdminRequestsPage);
}
describe('/admin/requests', () => {
test('Tab switch refetches with new status filter', async () => {
setup();
await fireEvent.click(screen.getByRole('tab', { name: /approved/i }));
expect(createAdminRequestsQuery).toHaveBeenCalledWith('approved');
});
test('Approve without override fires POST with no override body', async () => {
setup();
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
expect(approveRequest).toHaveBeenCalledWith('r1');
});
test('Override modal Confirm sends chosen values', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /override geogaddi/i }));
await fireEvent.change(screen.getByLabelText(/quality profile/i), {
target: { value: '1' }
});
await fireEvent.change(screen.getByLabelText(/root folder/i), {
target: { value: '/music' }
});
(approveRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await fireEvent.click(
screen.getByRole('button', { name: /approve with override/i })
);
expect(approveRequest).toHaveBeenCalledWith('r1', {
quality_profile_id: 1,
root_folder_path: '/music'
});
});
test('Reject opens notes textarea; confirm sends notes', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /reject geogaddi/i }));
const textarea = screen.getByLabelText(/notes/i) as HTMLTextAreaElement;
await fireEvent.input(textarea, { target: { value: 'duplicate' } });
(rejectRequest as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...baseRow,
status: 'rejected'
});
await fireEvent.click(
screen.getByRole('button', { name: /confirm reject/i })
);
expect(rejectRequest).toHaveBeenCalledWith('r1', 'duplicate');
});
test('Lidarr-unreachable error shows toast', async () => {
setup();
(approveRequest as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
code: 'lidarr_unreachable',
message: 'unreachable',
status: 503
});
await fireEvent.click(screen.getByRole('button', { name: /approve geogaddi/i }));
await waitFor(() =>
expect(screen.getByText(/lidarr is unreachable/i)).toBeInTheDocument()
);
});
test('Track-kind row renders disclosure copy about adding the album', () => {
setup([
{
...baseRow,
kind: 'track',
track_title: 'Roygbiv',
album_title: 'Geogaddi'
}
]);
expect(screen.getByText(/will add the album/i)).toBeInTheDocument();
});
});