diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a33513..3293216 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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/.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 { - req, - setupHeaders, getActiveAccount, HttpError, setActiveAccount, @@ -17,11 +23,12 @@ export { setUnauthorizedHandler, }; -// Domain modules split out of this file. Their TYPES are re-exported so consumers keep importing -// them from "./api" unchanged; their method slices stay internal (`export type *`, not `export *`) -// and reach consumers only through the `api` façade they're spread into below. Runtime value -// exports a domain genuinely owns (e.g. plex's EMPTY_PLEX_FILTERS/plexFilterCount) are re-exported -// explicitly. +import { accountApi } from "./api/account"; +export type * from "./api/account"; +import { authApi } from "./api/auth"; +export type * from "./api/auth"; +import { setupApi } from "./api/setup"; +export type * from "./api/setup"; import { feedApi } from "./api/feed"; export type * from "./api/feed"; import { channelsApi } from "./api/channels"; @@ -38,326 +45,20 @@ import { savedViewsApi } from "./api/savedViews"; export type * from "./api/savedViews"; import { quotaApi } from "./api/quota"; export type * from "./api/quota"; -import { downloadsApi } from "./api/downloads"; -export type * from "./api/downloads"; +import { adminApi } from "./api/admin"; +export type * from "./api/admin"; import { plexApi } from "./api/plex"; export type * from "./api/plex"; export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex"; import { messagingApi } from "./api/messaging"; export type * from "./api/messaging"; - -// --------------------------------------------------------------------------- -// 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; -} - -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; - 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. - target_type: string | null; - target_id: string | null; - summary: string | null; - before: Record | null; - after: Record | 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; - }; -} +import { downloadsApi } from "./api/downloads"; +export type * from "./api/downloads"; export const api = { - // --- account & session --- - // 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 => { - // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`. - const r = await req<{ authenticated: boolean } & Partial>("/api/me"); - return r.authenticated ? (r as Me) : null; - }, - accounts: (): Promise => 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 => req("/api/version"), - // Overwriting preferences is idempotent, so let it retry on a transient gateway blip. - savePrefs: (p: Record) => - 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> => - req("/api/admin/purge-discovery", { method: "POST" }), - adminConfig: (): Promise => req("/api/admin/config"), - setConfig: (key: string, value: string | number | boolean): Promise => - req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }), - resetConfig: (key: string): Promise => - 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 => req(`/api/admin/audit?limit=${limit}`), - clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), - adminUsers: (): Promise => req("/api/admin/users"), - setUserRole: (id: number, role: "user" | "admin"): Promise => - req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), - setUserSuspended: (id: number, suspended: boolean): Promise => - 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 => 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): 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 => req("/api/admin/demo/whitelist"), - addDemoWhitelist: (email: string): Promise => - 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 => - 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) --- + ...accountApi, + ...authApi, + ...setupApi, ...feedApi, ...channelsApi, ...syncApi, @@ -366,6 +67,7 @@ export const api = { ...notificationsApi, ...savedViewsApi, ...quotaApi, + ...adminApi, ...plexApi, ...messagingApi, ...downloadsApi, diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts new file mode 100644 index 0000000..7dda353 --- /dev/null +++ b/frontend/src/lib/api/account.ts @@ -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; +} + +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 => { + // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`. + const r = await req<{ authenticated: boolean } & Partial>("/api/me"); + return r.authenticated ? (r as Me) : null; + }, + accounts: (): Promise => 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 => req("/api/version"), + // Overwriting preferences is idempotent, so let it retry on a transient gateway blip. + savePrefs: (p: Record) => + req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }), +}; diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts new file mode 100644 index 0000000..6258390 --- /dev/null +++ b/frontend/src/lib/api/admin.ts @@ -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; + 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. + target_type: string | null; + target_id: string | null; + summary: string | null; + before: Record | null; + after: Record | 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> => + req("/api/admin/purge-discovery", { method: "POST" }), + adminConfig: (): Promise => req("/api/admin/config"), + setConfig: (key: string, value: string | number | boolean): Promise => + req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }), + resetConfig: (key: string): Promise => + 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 => req(`/api/admin/audit?limit=${limit}`), + clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), + adminUsers: (): Promise => req("/api/admin/users"), + setUserRole: (id: number, role: "user" | "admin"): Promise => + req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), + setUserSuspended: (id: number, suspended: boolean): Promise => + 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 => 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 => req("/api/admin/demo/whitelist"), + addDemoWhitelist: (email: string): Promise => + 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 => + 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 }) }), +}; diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..5567837 --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -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 }) }), +}; diff --git a/frontend/src/lib/api/setup.ts b/frontend/src/lib/api/setup.ts new file mode 100644 index 0000000..c47d7b3 --- /dev/null +++ b/frontend/src/lib/api/setup.ts @@ -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): 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 }), +};