Files
minstrel/web/src/lib/api/admin.ts
T
bvandeusen 72f46a885b feat(web/m7-user-mgmt): /settings expansion + /forgot-password (U3-T5a)
Lands four of the U3 frontend surfaces. Splits T5 because the
dispatch covering everything in one shot crashed the runtime
mid-execution; this is the salvageable half.

- /settings gains three cards alongside Appearance: Profile
  (display name + email), Password (current + new + confirm with
  client-side mismatch check), API Token (display + copy +
  regenerate-with-double-click-confirm). Server error codes map
  to clear toasts.
- /forgot-password — public; takes an email and always shows the
  success message regardless of whether the email is on file
  (mirrors the server's no-enumeration posture).
- Login page gets a "Forgot password?" link below the existing
  register link.
- API client functions for the four /me endpoints (changePassword,
  updateProfile, getAPIToken, regenerateAPIToken), the SMTP admin
  trio (getSMTPConfig, updateSMTPConfig, testSMTPConfig), and the
  forgot/reset auth pair (forgotPassword, resetPassword).

Tests for these surfaces + /reset-password page + admin SMTP card
land in T5b (next follow-up).
2026-05-07 17:33:48 -04:00

495 lines
14 KiB
TypeScript

import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
ActionResult,
AdminQuarantineRow,
LidarrConfig,
LidarrMetadataProfile,
LidarrQualityProfile,
LidarrQuarantineActionRow,
LidarrRequest,
LidarrRequestStatus,
LidarrRootFolder,
LidarrTestResult
} from './types';
// Admin Lidarr config -----------------------------------------------------
export async function getLidarrConfig(): Promise<LidarrConfig> {
return api.get<LidarrConfig>('/api/admin/lidarr/config');
}
export async function putLidarrConfig(cfg: LidarrConfig): Promise<LidarrConfig> {
return api.put<LidarrConfig>('/api/admin/lidarr/config', cfg);
}
// testLidarrConnection always returns 200 with a discriminated-union body —
// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants
// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed").
export async function testLidarrConnection(
body: { base_url?: string; api_key?: string } = {}
): Promise<LidarrTestResult> {
return api.post<LidarrTestResult>('/api/admin/lidarr/test', body);
}
export async function listQualityProfiles(): Promise<LidarrQualityProfile[]> {
return api.get<LidarrQualityProfile[]>('/api/admin/lidarr/quality-profiles');
}
export async function listMetadataProfiles(): Promise<LidarrMetadataProfile[]> {
return api.get<LidarrMetadataProfile[]>('/api/admin/lidarr/metadata-profiles');
}
export async function listRootFolders(): Promise<LidarrRootFolder[]> {
return api.get<LidarrRootFolder[]>('/api/admin/lidarr/root-folders');
}
// Admin request queue -----------------------------------------------------
export async function listAdminRequests(
status?: LidarrRequestStatus,
limit?: number
): Promise<LidarrRequest[]> {
const params = new URLSearchParams();
if (status) params.set('status', status);
if (limit !== undefined) params.set('limit', String(limit));
const qs = params.toString();
return api.get<LidarrRequest[]>(
qs ? `/api/admin/requests?${qs}` : '/api/admin/requests'
);
}
export async function approveRequest(
id: string,
overrides: { quality_profile_id?: number; root_folder_path?: string } = {}
): Promise<LidarrRequest> {
return api.post<LidarrRequest>(`/api/admin/requests/${id}/approve`, overrides);
}
export async function rejectRequest(
id: string,
notes?: string
): Promise<LidarrRequest> {
const body = notes !== undefined ? { notes } : {};
return api.post<LidarrRequest>(`/api/admin/requests/${id}/reject`, body);
}
// Query factories ---------------------------------------------------------
export function createLidarrConfigQuery() {
return createQuery({
queryKey: qk.lidarrConfig(),
queryFn: getLidarrConfig,
staleTime: 60_000
});
}
// `enabled` is passed in by the caller — typically derived from
// LidarrConfig.enabled — so the query only fires once Lidarr is configured.
// Keeping it as a prop (vs. reading config inside this factory) preserves
// purity and lets the caller choose its own gating logic.
export function createQualityProfilesQuery(enabled: boolean = true) {
return createQuery({
queryKey: qk.lidarrQualityProfiles(),
queryFn: listQualityProfiles,
enabled
});
}
export function createMetadataProfilesQuery(enabled: boolean = true) {
return createQuery({
queryKey: qk.lidarrMetadataProfiles(),
queryFn: listMetadataProfiles,
enabled
});
}
export function createRootFoldersQuery(enabled: boolean = true) {
return createQuery({
queryKey: qk.lidarrRootFolders(),
queryFn: listRootFolders,
enabled
});
}
export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
return createQuery({
queryKey: qk.adminRequests(status),
queryFn: () => listAdminRequests(status)
});
}
// Admin quarantine --------------------------------------------------------
export async function listAdminQuarantine(): Promise<AdminQuarantineRow[]> {
return api.get<AdminQuarantineRow[]>('/api/admin/quarantine');
}
export async function resolveQuarantine(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {});
}
export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {});
}
export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {});
}
export async function listQuarantineActions(
limit: number = 50
): Promise<LidarrQuarantineActionRow[]> {
return api.get<LidarrQuarantineActionRow[]>(
`/api/admin/quarantine/actions?limit=${limit}`
);
}
export function createAdminQuarantineQuery() {
return createQuery({
queryKey: qk.adminQuarantine(),
queryFn: listAdminQuarantine,
staleTime: 30_000
});
}
export function createQuarantineActionsQuery(limit: number = 50) {
return createQuery({
queryKey: qk.adminQuarantineActions(limit),
queryFn: () => listQuarantineActions(limit),
staleTime: 60_000
});
}
// Admin cover art ---------------------------------------------------------
export type RefetchAlbumCoverResponse = {
album_id: string;
cover_art_path: string | null;
cover_art_source: string | null;
};
export async function refetchAlbumCover(albumId: string): Promise<RefetchAlbumCoverResponse> {
return api.post<RefetchAlbumCoverResponse>(`/api/admin/albums/${albumId}/cover/refetch`, {});
}
export type RefetchMissingResponse = {
queued: number;
};
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
}
// Library scan -------------------------------------------------------------
export type ScanStageLibrary = {
scanned: number;
added: number;
updated: number;
skipped: number;
errored: number;
};
export type ScanStageMbidBackfill = {
processed: number;
healed: number;
skipped: number;
duplicates?: number;
};
export type ScanStageCoverEnrich = {
processed: number;
succeeded: number;
failed: number;
};
export type ScanStageArtistArtEnrich = {
processed: number;
succeeded: number;
failed: number;
};
export type ScanStatus = {
id: string;
started_at: string;
finished_at: string | null;
library?: ScanStageLibrary;
mbid_backfill?: ScanStageMbidBackfill;
cover_enrich?: ScanStageCoverEnrich;
artist_art_enrich?: ScanStageArtistArtEnrich;
error_message?: string;
in_flight: boolean;
};
export async function getScanStatus(): Promise<ScanStatus> {
return api.get<ScanStatus>('/api/admin/scan/status');
}
export type TriggerScanResp = { id?: string };
export async function triggerScan(): Promise<TriggerScanResp> {
return api.post<TriggerScanResp>('/api/admin/scan/run', {});
}
// Polls every 3s while a scan is in flight; falls back to a 30s stale-time
// when idle so the section auto-updates as workers complete each stage.
export function createScanStatusQuery() {
return createQuery({
queryKey: qk.scanStatus(),
queryFn: getScanStatus,
staleTime: 30_000,
refetchInterval: 3_000
});
}
// Library coverage --------------------------------------------------------
export type CoverageRollup = {
total: number;
with_art: number;
pending: number;
settled: number;
pending_no_mbid: number;
};
export async function getCoverageRollup(): Promise<CoverageRollup> {
return api.get<CoverageRollup>('/api/admin/library/coverage');
}
// Same pacing as scan-status — 3s while polling, 30s stale-time. The
// query is cheap server-side (one aggregate per call) and we want
// the gauge to tick during a scan.
export function createCoverageQuery() {
return createQuery({
queryKey: qk.coverage(),
queryFn: getCoverageRollup,
staleTime: 30_000,
refetchInterval: 3_000
});
}
// Cover-art providers ------------------------------------------------------
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
export type CoverProvider = {
id: string;
display_name: string;
requires_api_key: boolean;
supports: CoverProviderCapability[];
enabled: boolean;
api_key_set: boolean;
display_order: number;
testable: boolean;
};
export type CoverProvidersResponse = {
providers: CoverProvider[];
sources_version: number;
};
export async function getCoverProviders(): Promise<CoverProvidersResponse> {
return api.get<CoverProvidersResponse>('/api/admin/cover-sources');
}
export type UpdateCoverProviderPatch = {
enabled?: boolean;
api_key?: string;
};
export type UpdateCoverProviderResponse = CoverProvider & {
version_bumped: boolean;
};
export async function updateCoverProvider(
id: string,
patch: UpdateCoverProviderPatch
): Promise<UpdateCoverProviderResponse> {
return api.patch<UpdateCoverProviderResponse>(`/api/admin/cover-sources/${id}`, patch);
}
export type TestCoverProviderResponse = {
ok: boolean;
duration_ms?: number;
error?: string;
};
export async function testCoverProvider(id: string): Promise<TestCoverProviderResponse> {
return api.post<TestCoverProviderResponse>(`/api/admin/cover-sources/${id}/test`, {});
}
// Re-search missing art --------------------------------------------------
export type ResearchMissingArtResponse = { version: number };
// Bumps cover_art_sources_meta.current_version. Every 'none' row becomes
// eligible for retry against the current provider chain on the next
// enrichment pass. Existing positively-sourced rows are not affected.
export async function researchMissingArt(): Promise<ResearchMissingArtResponse> {
return api.post<ResearchMissingArtResponse>('/api/admin/cover-sources/research', {});
}
// Settings rarely change in operator time. 60s staleTime; refetches
// on window focus + after any mutation rather than polling.
export function createCoverProvidersQuery() {
return createQuery({
queryKey: qk.coverProviders(),
queryFn: getCoverProviders,
staleTime: 60_000
});
}
// Scan schedule -----------------------------------------------------------
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
export type ScanSchedule = {
mode: ScanScheduleMode;
interval_hours: number | null;
time_of_day: string | null; // 'HH:MM'
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
};
export type ScanScheduleUpdate = {
mode: ScanScheduleMode;
interval_hours?: number | null;
time_of_day?: string | null;
weekly_day?: number | null;
};
export async function getScanSchedule(): Promise<ScanSchedule> {
return api.get<ScanSchedule>('/api/admin/scan/schedule');
}
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
}
// Settings rarely change in operator time. 60s staleTime; refetches
// on window focus + after any PATCH invalidation — no polling.
export function createScanScheduleQuery() {
return createQuery({
queryKey: qk.scanSchedule(),
queryFn: getScanSchedule,
staleTime: 60_000
});
}
// Admin user-management ------------------------------------------------------
export type AdminUser = {
id: string;
username: string;
display_name: string | null;
is_admin: boolean;
auto_approve_requests: boolean;
created_at: string;
};
export type AdminInvite = {
token: string;
invited_by: string;
note: string | null;
created_at: string;
expires_at: string;
redeemed_at: string | null;
redeemed_by: string | null;
};
export async function listUsers(): Promise<AdminUser[]> {
const res = await api.get<{ users: AdminUser[] }>('/api/admin/users');
return res.users;
}
export async function updateUserAdmin(id: string, isAdmin: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/admin`, { is_admin: isAdmin });
}
export async function listInvites(): Promise<AdminInvite[]> {
const res = await api.get<{ invites: AdminInvite[] }>('/api/admin/invites');
return res.invites;
}
export async function createInvite(note?: string): Promise<AdminInvite> {
return api.post<AdminInvite>('/api/admin/invites', note ? { note } : {});
}
export async function deleteInvite(token: string): Promise<void> {
await api.del(`/api/admin/invites/${token}`);
}
// U2 user-management actions -----------------------------------------------
export type CreateUserInput = {
username: string;
password: string;
display_name?: string;
is_admin?: boolean;
};
export async function createUser(input: CreateUserInput): Promise<AdminUser> {
return api.post<AdminUser>('/api/admin/users', input);
}
export async function deleteUser(id: string): Promise<void> {
await api.del(`/api/admin/users/${id}`);
}
export async function resetUserPassword(id: string, password: string): Promise<void> {
await api.post(`/api/admin/users/${id}/reset-password`, { password });
}
export async function updateUserAutoApprove(id: string, autoApprove: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/auto-approve`, { auto_approve: autoApprove });
}
export function createAdminUsersQuery() {
return createQuery({
queryKey: qk.adminUsers(),
queryFn: listUsers
});
}
export function createAdminInvitesQuery() {
return createQuery({
queryKey: qk.adminInvites(),
queryFn: listInvites
});
}
// SMTP config (U3) ----------------------------------------------------------
export type SMTPConfig = {
enabled: boolean;
host: string;
port: number;
username: string;
password: string; // "***" when set, "" when unset
from_address: string;
from_name: string;
use_tls: boolean;
};
export async function getSMTPConfig(): Promise<SMTPConfig> {
return api.get<SMTPConfig>('/api/admin/smtp-config');
}
export async function updateSMTPConfig(input: SMTPConfig): Promise<void> {
await api.put('/api/admin/smtp-config', input);
}
export async function testSMTPConfig(): Promise<void> {
await api.post('/api/admin/smtp-config/test', {});
}
export function createSMTPConfigQuery() {
return createQuery({
queryKey: qk.smtpConfig(),
queryFn: getSMTPConfig,
staleTime: 60_000
});
}