Files
siftlode/frontend/src/lib/api/admin.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

321 lines
14 KiB
TypeScript

// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
// diagnostics console's server log, the scheduler + maintenance controls, and access invites. Only touches core's req. (Grouped by sub-area below; kept in one
// module as the cohesive "admin" domain.)
import type { Level } from "../log";
import { req } from "./core";
export interface Invite {
id: number;
email: string;
status: "pending" | "approved" | "denied";
note: string | null;
requested_at: string | null;
decided_at: string | null;
decided_by: string | null;
/** An account already exists for this address, so the entry can't be deleted from here — deleting
* it would revoke that user's access (is_allowed reads this row). Delete the user instead. */
has_account: boolean;
/** The address stays allow-listed by the server's env ALLOWED_EMAILS / ADMIN_EMAILS, so removing
* this row would NOT take access away. The delete confirmation says so. */
still_allowed_by_env: boolean;
/** When the admin last invited this person, or null if never. Adding an address to the whitelist
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
* act, and this is how the admin sees they have already done it. */
invited_at: string | null;
/** The language this person asked us in, or null when nobody ever told us — a row the admin
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
* language, so the guess is visible and correctable before anything is sent. */
lang: string | null;
}
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
* one thing the client cannot work out for itself. */
export interface PendingAccessRow extends AdminUserRow {
/** The address is already allowed and the account is merely inactive, so Activate finishes a
* decision the admin has already made. False when there is no approved invite behind it —
* activating then would make an account that signs in with a password but is turned away by
* Google, so the whitelist box is the honest move instead. */
can_activate: boolean;
}
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
key: string;
type: "int" | "str" | "bool" | "choice";
group: string;
secret: boolean;
min: number | null;
max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
choices: string[]; // a "choice" key's options (the backend Literal); [] for any other type
value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists
}
export interface SystemConfigData {
groups: Record<string, ConfigItem[]>;
/** The page's headings, in order, each with its groups in order; every group is in one. */
sections: { id: string; groups: string[] }[];
secrets_manageable: boolean;
}
// Admin Users & roles page.
export interface AdminUserRow {
id: number;
email: string;
display_name: string | null;
role: string; // "user" | "admin"
is_active: boolean;
is_suspended: boolean;
email_verified: boolean;
has_password: boolean;
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
* account) from "we took it away" — different problems needing different help. */
password_revoked_at: string | null;
has_google: boolean;
/** The Google account's address, ONLY when it differs from the account address. */
google_email: string | null;
created_at: string | null;
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill). */
last_login_at: string | null;
}
/** The account panel's view of why someone can or cannot get in. */
export interface AdminUserAccess extends AdminUserRow {
/** The address passes the invite gate — so Google sign-in will be let through, not filed as a
* fresh access request. */
is_allowed: boolean;
can_activate: boolean;
/** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery
* actions are offered or shown as unavailable — rather than failing after the click. */
email_configured: boolean;
}
export interface AuditRow {
id: number;
created_at: string | null;
actor: string | null; // email of the acting admin; null = System (scheduler/background)
action: string; // canonical key, localized on the client via auditActions.<action>
target_type: string | null;
target_id: string | null;
summary: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
}
export interface AuditListResult {
items: AuditRow[];
total: number; // total rows in the log (may exceed `items` if capped)
returned: number;
}
/** One stored log line (`log_events`), as the diagnostics console reads it. Values the server's
* redactor took out arrive as `[redacted]` / `[email]`. */
export interface ServerLogRow {
id: number;
ts: string;
level: Level;
source: string;
logger: string;
event: string | null;
msg: string;
attrs: Record<string, unknown> | null;
exc: string | null;
request_id: string | null;
user_id: number | null;
job_id: number | null;
debug_session_id: string | null;
version: string | null;
}
/** The console's filters, as the API takes them. `none` picks the rows without an account / code. */
export interface LogQuery {
levels?: readonly Level[];
sources?: readonly string[];
user?: string;
event?: string;
request_id?: string;
job_id?: number;
q?: string;
since?: string;
}
export interface LogPage {
items: ServerLogRow[];
/** The accounts this page names, by id; an erased one is absent. */
accounts: Record<string, string>;
next_cursor: string | null;
}
export interface LogFacet {
value: string;
count: number;
}
/** Each facet is counted under every filter but its own; `total` is the all-levels count. */
export interface LogFacets {
levels: LogFacet[];
sources: LogFacet[];
events: LogFacet[];
users: LogFacet[];
accounts: Record<string, string>;
total: number;
}
function logParams(query: LogQuery, extra: Record<string, string | number | null> = {}): string {
const p = new URLSearchParams();
for (const level of query.levels ?? []) p.append("levels", level);
for (const source of query.sources ?? []) p.append("sources", source);
const single: Record<string, string | number | null | undefined> = {
user: query.user,
event: query.event,
request_id: query.request_id,
job_id: query.job_id,
q: query.q,
since: query.since,
...extra,
};
for (const [key, value] of Object.entries(single)) {
if (value !== undefined && value !== null && value !== "") p.set(key, String(value));
}
const s = p.toString();
return s ? `?${s}` : "";
}
export type JobResult = { [key: string]: number | string | JobResult };
export interface SchedulerJob {
id: string;
interval_minutes: number;
next_run: string | null;
running: boolean;
status: "ok" | "error" | "skipped" | null;
last_started: string | null;
last_finished: string | null;
last_result: string | null; // the English one-liner — the fallback for a job with no `result`
// The last result as data: counts, a `skipped` / `reason` word, a nested dict per phase —
// named in the reader's language by lib/jobResult.
result: JobResult | null;
last_error: string | null;
// Live progress while running (null when idle or for jobs that don't report it).
// total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
progress: { current: number; total: number | null; phase: string | null } | null;
}
export interface SchedulerStatus {
running_here: boolean;
enabled: boolean;
paused: boolean;
jobs: SchedulerJob[];
queue: {
channels_recent_pending: number;
channels_deep_pending: number;
deep_eta_seconds: number;
videos_pending_enrich: number;
videos_pending_shorts: number;
videos_live_refresh: number;
};
quota: {
used_today: number;
remaining_today: number;
daily_budget: number;
backfill_reserve: number;
};
maintenance: {
revalidate_batch: number;
revalidate_batch_default: number;
min: number;
max: number;
};
}
export const adminApi = {
// --- discovery / system configuration ---
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
purgeDiscovery: (): Promise<Record<string, number>> =>
req("/api/admin/purge-discovery", { method: "POST" }),
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
resetConfig: (key: string): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
/** The bug-report tracker: answers, takes issues, and is private. */
testIssues: (): Promise<{ ok: true; repo: string; url: string | null }> =>
req("/api/admin/config/test-issues", { method: "POST" }),
// --- users & roles ---
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
// --- diagnostics console ---
adminLogs: (query: LogQuery, before: string | null): Promise<LogPage> =>
req(`/api/admin/logs${logParams(query, { before })}`),
adminLogFacets: (query: LogQuery): Promise<LogFacets> =>
req(`/api/admin/logs/facets${logParams(query)}`),
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
// --- scheduler + maintenance ---
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (
jobId: string,
intervalMinutes: number,
): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }),
}),
runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
req("/api/admin/scheduler/run-all", { method: "POST" }),
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
req("/api/admin/scheduler/maintenance", {
method: "PATCH",
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}),
// --- access invites ---
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
// Let one in. Admin-only and explicit: it changes discoverability, not authority — the same thing
// was always possible by re-entering the address in the whitelist box.
activateUser: (id: number): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/activate`, { method: "POST" }, { quiet: true }),
// Re-send someone's confirmation link. Unlike the public resend this one says what happened,
// because the caller is already an authenticated admin.
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
// What the account panel shows: the two computed gate answers, resolved for one user on open.
adminUserAccess: (id: number): Promise<AdminUserAccess> => req(`/api/admin/users/${id}/access`),
// Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the
// everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password.
createSetPasswordLink: (
id: number,
): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> =>
req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }),
adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
// this is the deliberate act that actually reaches a person.
sendInvitation: (id: number, lang: string): Promise<Invite> =>
req(
`/api/admin/invites/${id}/invite`,
{ method: "POST", body: JSON.stringify({ lang }) },
{ quiet: true },
),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
* ALLOWED_EMAILS / ADMIN_EMAILS, i.e. removing the row did not actually revoke access. */
// quiet: a 409 here ("that address has an account") is a case the UI handles with a translated
// toast; without this the shared handler ALSO raises the server's English detail as a modal.
deleteInvite: (id: number): Promise<{ deleted: number; still_allowed_by_env: boolean }> =>
req(`/api/admin/invites/${id}`, { method: "DELETE" }, { quiet: true }),
};