- admin config group "Bug reports": Gitea address, repository, token (secret),
and "Check the connection" — the repository must answer, take issues and
be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
upload each file on its own request (the measured API), then show the
screenshots inline; progress is kept in the draft, so a send that stops
half-way keeps its issue and the retry sends only what is missing; 5 new
issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
repository), a partial-send banner with retry, and a Preview — the tracker's
own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
331 lines
14 KiB
TypeScript
331 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, the demo-account
|
|
// whitelist, 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 DemoWhitelistEntry {
|
|
id: number;
|
|
email: string;
|
|
note: string | null;
|
|
added_by: string | null;
|
|
created_at: string | null;
|
|
}
|
|
|
|
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[]>;
|
|
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;
|
|
is_demo: 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 (E9.5 S3). */
|
|
google_email: string | null;
|
|
created_at: string | null;
|
|
/** Last sign-in (Google or password). Null = not since 0.88 (no backfill), or the demo account. */
|
|
last_login_at: string | null;
|
|
}
|
|
|
|
/** The account panel's view of why someone can or cannot get in (E12 S5). */
|
|
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 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;
|
|
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 (E16): 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 }),
|
|
}),
|
|
// --- demo whitelist ---
|
|
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
|
|
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
|
|
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
|
|
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
|
|
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
|
|
req("/api/admin/demo/reset", { method: "POST" }),
|
|
// --- 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 }),
|
|
};
|