refactor(api): extract account/auth/setup/admin; façade is now spread-only (R7.5 S2)
Move the last four inline domains out of lib/api.ts into per-domain slices, each owning its types + method slice. The barrel now holds ZERO endpoint logic and zero types of its own — it only re-exports core's HTTP machinery, re-exports each domain's types, and spreads the 15 slices into the single `api` object. A new endpoint therefore has nowhere to land but a domain module. The `api` shape and all call sites are unchanged (every req/beacon call byte-identical to dev; no method dropped, no key collision).
This commit is contained in:
+22
-320
@@ -1,6 +1,12 @@
|
|||||||
|
// The API façade. This file owns NO endpoint logic and NO types of its own — every domain lives in
|
||||||
|
// its own api/<domain>.ts module (types + method slice together). This barrel only:
|
||||||
|
// 1. re-exports the shared HTTP machinery from api/core,
|
||||||
|
// 2. re-exports each domain's TYPES (`export type *`) so consumers import them from "./api"
|
||||||
|
// unchanged,
|
||||||
|
// 3. spreads each domain's method slice into the single `api` object consumers call.
|
||||||
|
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
|
||||||
|
// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
|
||||||
import {
|
import {
|
||||||
req,
|
|
||||||
setupHeaders,
|
|
||||||
getActiveAccount,
|
getActiveAccount,
|
||||||
HttpError,
|
HttpError,
|
||||||
setActiveAccount,
|
setActiveAccount,
|
||||||
@@ -17,11 +23,12 @@ export {
|
|||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Domain modules split out of this file. Their TYPES are re-exported so consumers keep importing
|
import { accountApi } from "./api/account";
|
||||||
// them from "./api" unchanged; their method slices stay internal (`export type *`, not `export *`)
|
export type * from "./api/account";
|
||||||
// and reach consumers only through the `api` façade they're spread into below. Runtime value
|
import { authApi } from "./api/auth";
|
||||||
// exports a domain genuinely owns (e.g. plex's EMPTY_PLEX_FILTERS/plexFilterCount) are re-exported
|
export type * from "./api/auth";
|
||||||
// explicitly.
|
import { setupApi } from "./api/setup";
|
||||||
|
export type * from "./api/setup";
|
||||||
import { feedApi } from "./api/feed";
|
import { feedApi } from "./api/feed";
|
||||||
export type * from "./api/feed";
|
export type * from "./api/feed";
|
||||||
import { channelsApi } from "./api/channels";
|
import { channelsApi } from "./api/channels";
|
||||||
@@ -38,326 +45,20 @@ import { savedViewsApi } from "./api/savedViews";
|
|||||||
export type * from "./api/savedViews";
|
export type * from "./api/savedViews";
|
||||||
import { quotaApi } from "./api/quota";
|
import { quotaApi } from "./api/quota";
|
||||||
export type * from "./api/quota";
|
export type * from "./api/quota";
|
||||||
import { downloadsApi } from "./api/downloads";
|
import { adminApi } from "./api/admin";
|
||||||
export type * from "./api/downloads";
|
export type * from "./api/admin";
|
||||||
import { plexApi } from "./api/plex";
|
import { plexApi } from "./api/plex";
|
||||||
export type * from "./api/plex";
|
export type * from "./api/plex";
|
||||||
export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex";
|
export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex";
|
||||||
import { messagingApi } from "./api/messaging";
|
import { messagingApi } from "./api/messaging";
|
||||||
export type * from "./api/messaging";
|
export type * from "./api/messaging";
|
||||||
|
import { downloadsApi } from "./api/downloads";
|
||||||
// ---------------------------------------------------------------------------
|
export type * from "./api/downloads";
|
||||||
// The domains still living inline here — account, auth, setup, admin — are
|
|
||||||
// extracted in R7.5 S2. Their types and methods stay together in this file
|
|
||||||
// until then.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface Me {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
role: string;
|
|
||||||
is_demo: boolean;
|
|
||||||
has_google: boolean;
|
|
||||||
has_password: boolean;
|
|
||||||
google_enabled: boolean;
|
|
||||||
plex_enabled: boolean;
|
|
||||||
can_read: boolean;
|
|
||||||
can_write: boolean;
|
|
||||||
pending_invites: number;
|
|
||||||
preferences: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VersionInfo {
|
|
||||||
app_version: string;
|
|
||||||
git_sha: string;
|
|
||||||
build_date: string | null;
|
|
||||||
db_revision: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Account {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
display_name: string | null;
|
|
||||||
avatar_url: string | null;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
|
||||||
export interface ConfigItem {
|
|
||||||
key: string;
|
|
||||||
type: "int" | "str" | "bool";
|
|
||||||
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
|
|
||||||
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;
|
|
||||||
has_google: boolean;
|
|
||||||
created_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 api = {
|
export const api = {
|
||||||
// --- account & session ---
|
...accountApi,
|
||||||
// Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
|
...authApi,
|
||||||
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
|
...setupApi,
|
||||||
// logs a failed request to the console). React Query treats the null as data, not an error.
|
|
||||||
me: async (): Promise<Me | null> => {
|
|
||||||
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
|
|
||||||
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
|
|
||||||
return r.authenticated ? (r as Me) : null;
|
|
||||||
},
|
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
|
||||||
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
|
|
||||||
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
|
||||||
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
|
||||||
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
|
|
||||||
// X-Siftlode-Account header that req() attaches).
|
|
||||||
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
|
|
||||||
req("/auth/logout", { method: "POST" }),
|
|
||||||
version: (): Promise<VersionInfo> => req("/api/version"),
|
|
||||||
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
|
||||||
savePrefs: (p: Record<string, unknown>) =>
|
|
||||||
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
|
|
||||||
|
|
||||||
// --- admin: 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" }),
|
|
||||||
// --- admin: users & roles ---
|
|
||||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
|
||||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
|
||||||
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" }),
|
|
||||||
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 }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// --- auth: public config ---
|
|
||||||
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
|
|
||||||
authConfig: (): Promise<{
|
|
||||||
google_enabled: boolean;
|
|
||||||
allow_registration: boolean;
|
|
||||||
operator_contact: string | null;
|
|
||||||
}> => req("/auth/config"),
|
|
||||||
|
|
||||||
// --- first-run install wizard (token from the setup URL printed to the container logs) ---
|
|
||||||
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
|
|
||||||
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
|
|
||||||
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
|
|
||||||
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
|
|
||||||
req(
|
|
||||||
"/api/setup/admin",
|
|
||||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
|
|
||||||
req(
|
|
||||||
"/api/setup/config",
|
|
||||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
|
|
||||||
req(
|
|
||||||
"/api/setup/test-email",
|
|
||||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
setupFinish: (token: string): Promise<{ ok: boolean }> =>
|
|
||||||
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
|
|
||||||
|
|
||||||
// --- auth: email + password (errors handled inline via quiet) ---
|
|
||||||
register: (email: string, password: string): Promise<{ status: string }> =>
|
|
||||||
req(
|
|
||||||
"/auth/register",
|
|
||||||
{ method: "POST", body: JSON.stringify({ email, password }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
|
|
||||||
req(
|
|
||||||
"/auth/password-login",
|
|
||||||
{ method: "POST", body: JSON.stringify({ email, password }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
requestPasswordReset: (email: string): Promise<{ status: string }> =>
|
|
||||||
req(
|
|
||||||
"/auth/password-reset/request",
|
|
||||||
{ method: "POST", body: JSON.stringify({ email }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
|
||||||
req(
|
|
||||||
"/auth/password-reset/confirm",
|
|
||||||
{ method: "POST", body: JSON.stringify({ token, password }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
|
||||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
|
||||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
|
||||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
|
||||||
// reveals whether the email has a pending account.
|
|
||||||
resendVerification: (email: string): Promise<{ status: string }> =>
|
|
||||||
req(
|
|
||||||
"/auth/verify/resend",
|
|
||||||
{ method: "POST", body: JSON.stringify({ email }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
// SA4: sign every OTHER session for this account out (keeps the current one).
|
|
||||||
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
|
||||||
// Set or change the signed-in account's password (errors shown inline via quiet).
|
|
||||||
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
|
|
||||||
req(
|
|
||||||
"/auth/set-password",
|
|
||||||
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
|
|
||||||
{ quiet: true },
|
|
||||||
),
|
|
||||||
|
|
||||||
// --- onboarding / admin: access requests, demo ---
|
|
||||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
|
||||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
|
||||||
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
|
|
||||||
// sign-in. Returns whether a session was established.
|
|
||||||
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
|
||||||
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
|
|
||||||
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" }),
|
|
||||||
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" }),
|
|
||||||
addInvite: (email: string) =>
|
|
||||||
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
|
||||||
|
|
||||||
// --- extracted domain slices (their methods reach consumers through this façade) ---
|
|
||||||
...feedApi,
|
...feedApi,
|
||||||
...channelsApi,
|
...channelsApi,
|
||||||
...syncApi,
|
...syncApi,
|
||||||
@@ -366,6 +67,7 @@ export const api = {
|
|||||||
...notificationsApi,
|
...notificationsApi,
|
||||||
...savedViewsApi,
|
...savedViewsApi,
|
||||||
...quotaApi,
|
...quotaApi,
|
||||||
|
...adminApi,
|
||||||
...plexApi,
|
...plexApi,
|
||||||
...messagingApi,
|
...messagingApi,
|
||||||
...downloadsApi,
|
...downloadsApi,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
|
||||||
|
// app version, and preference save. Only touches core's req.
|
||||||
|
import { req } from "./core";
|
||||||
|
|
||||||
|
export interface Me {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
role: string;
|
||||||
|
is_demo: boolean;
|
||||||
|
has_google: boolean;
|
||||||
|
has_password: boolean;
|
||||||
|
google_enabled: boolean;
|
||||||
|
plex_enabled: boolean;
|
||||||
|
can_read: boolean;
|
||||||
|
can_write: boolean;
|
||||||
|
pending_invites: number;
|
||||||
|
preferences: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
app_version: string;
|
||||||
|
git_sha: string;
|
||||||
|
build_date: string | null;
|
||||||
|
db_revision: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountApi = {
|
||||||
|
// Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
|
||||||
|
// (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
|
||||||
|
// logs a failed request to the console). React Query treats the null as data, not an error.
|
||||||
|
me: async (): Promise<Me | null> => {
|
||||||
|
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
|
||||||
|
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
|
||||||
|
return r.authenticated ? (r as Me) : null;
|
||||||
|
},
|
||||||
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||||
|
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
|
||||||
|
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
||||||
|
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
||||||
|
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
|
||||||
|
// X-Siftlode-Account header that req() attaches).
|
||||||
|
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
|
||||||
|
req("/auth/logout", { method: "POST" }),
|
||||||
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||||
|
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
||||||
|
savePrefs: (p: Record<string, unknown>) =>
|
||||||
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// The admin surface: discovery purge, system configuration, users & roles, the audit 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 { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||||
|
export interface ConfigItem {
|
||||||
|
key: string;
|
||||||
|
type: "int" | "str" | "bool";
|
||||||
|
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
|
||||||
|
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;
|
||||||
|
has_google: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" }),
|
||||||
|
// --- users & roles ---
|
||||||
|
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||||
|
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||||
|
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 ---
|
||||||
|
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" }),
|
||||||
|
addInvite: (email: string) =>
|
||||||
|
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
|
||||||
|
// session management, access requests, and the hidden demo login. Errors on the interactive paths
|
||||||
|
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
|
||||||
|
import { req } from "./core";
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
|
||||||
|
authConfig: (): Promise<{
|
||||||
|
google_enabled: boolean;
|
||||||
|
allow_registration: boolean;
|
||||||
|
operator_contact: string | null;
|
||||||
|
}> => req("/auth/config"),
|
||||||
|
|
||||||
|
// --- email + password (errors handled inline via quiet) ---
|
||||||
|
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||||
|
req(
|
||||||
|
"/auth/register",
|
||||||
|
{ method: "POST", body: JSON.stringify({ email, password }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(
|
||||||
|
"/auth/password-login",
|
||||||
|
{ method: "POST", body: JSON.stringify({ email, password }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
requestPasswordReset: (email: string): Promise<{ status: string }> =>
|
||||||
|
req(
|
||||||
|
"/auth/password-reset/request",
|
||||||
|
{ method: "POST", body: JSON.stringify({ email }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(
|
||||||
|
"/auth/password-reset/confirm",
|
||||||
|
{ method: "POST", body: JSON.stringify({ token, password }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||||
|
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||||
|
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||||
|
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||||
|
// reveals whether the email has a pending account.
|
||||||
|
resendVerification: (email: string): Promise<{ status: string }> =>
|
||||||
|
req(
|
||||||
|
"/auth/verify/resend",
|
||||||
|
{ method: "POST", body: JSON.stringify({ email }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
// SA4: sign every OTHER session for this account out (keeps the current one).
|
||||||
|
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
||||||
|
// Set or change the signed-in account's password (errors shown inline via quiet).
|
||||||
|
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(
|
||||||
|
"/auth/set-password",
|
||||||
|
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
|
||||||
|
// --- onboarding: access requests + demo entry ---
|
||||||
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||||
|
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
|
||||||
|
// sign-in. Returns whether a session was established.
|
||||||
|
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
||||||
|
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// First-run install wizard: probes install state and drives admin/config/email setup with the
|
||||||
|
// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so
|
||||||
|
// the wizard renders errors inline. Only touches core's req + setupHeaders.
|
||||||
|
import { req, setupHeaders } from "./core";
|
||||||
|
|
||||||
|
export const setupApi = {
|
||||||
|
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
|
||||||
|
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
|
||||||
|
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
|
||||||
|
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(
|
||||||
|
"/api/setup/admin",
|
||||||
|
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
|
||||||
|
req(
|
||||||
|
"/api/setup/config",
|
||||||
|
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(
|
||||||
|
"/api/setup/test-email",
|
||||||
|
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
|
||||||
|
{ quiet: true },
|
||||||
|
),
|
||||||
|
setupFinish: (token: string): Promise<{ ok: boolean }> =>
|
||||||
|
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user