diff --git a/quotesdb/src/bin/ui/api.rs b/quotesdb/src/bin/ui/api.rs index 72ce874..2f1cf2b 100644 --- a/quotesdb/src/bin/ui/api.rs +++ b/quotesdb/src/bin/ui/api.rs @@ -10,7 +10,7 @@ //! ``` use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// Response type for `GET /api/quotes` (paginated list). #[derive(Debug, Clone, Deserialize)] @@ -44,6 +44,35 @@ pub enum ApiError { /// Failed to parse the response body as JSON. #[error("parse error: {0}")] Parse(String), + /// The server returned 403 Forbidden (wrong admin or auth code). + #[error("forbidden: wrong auth code")] + Forbidden, +} + +/// Response from `GET /api/status`. +#[derive(Deserialize, Clone, PartialEq)] +pub struct StatusResponse { + /// Whether new quote submissions are currently locked by an admin. + pub submissions_locked: bool, +} + +/// Response from `POST /api/admin/reset-auth-code`. +#[derive(Deserialize)] +struct ResetAuthCodeResponse { + pub auth_code: String, +} + +/// Response from `POST /api/admin/lock` or `POST /api/admin/unlock`. +#[derive(Deserialize)] +struct LockResponse { + pub submissions_locked: bool, +} + +/// Body sent to `POST /api/admin/reset-auth-code`. +#[derive(Serialize)] +struct ResetAuthCodeBody<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + new_code: Option<&'a str>, } /// Fetch a paginated list of quotes. @@ -162,6 +191,125 @@ pub async fn delete_quote(id: &str, auth_code: &str) -> Result<(), ApiError> { } } +/// Fetch the current submission lock state from `GET /api/status`. +/// +/// No authentication required. Returns [`StatusResponse`] containing +/// `submissions_locked`. Returns [`ApiError::Network`] on connection failure +/// or [`ApiError::Parse`] if the response is not valid JSON. +pub async fn get_status() -> Result { + fetch_json("/api/status").await +} + +/// Call `POST /api/admin/reset-auth-code` to rotate a quote's auth code. +/// +/// # Arguments +/// - `current` — the current auth code for the quote (sent as `X-Auth-Code` header). +/// - `new_code` — an optional new passphrase; if `None` one is generated server-side. +/// - `admin_code` — the admin super-auth code (sent as `X-Admin-Code` header). +/// +/// Returns the new auth code string on HTTP 200, or: +/// - [`ApiError::Forbidden`] on HTTP 403 (wrong admin code), +/// - [`ApiError::Server`] for other non-200 responses, +/// - [`ApiError::Network`] / [`ApiError::Parse`] for connection/parse errors. +pub async fn admin_reset_auth_code( + current: &str, + new_code: Option<&str>, + admin_code: &str, +) -> Result { + let body = ResetAuthCodeBody { new_code }; + let resp = gloo::net::http::Request::post("/api/admin/reset-auth-code") + .header("X-Auth-Code", current) + .header("X-Admin-Code", admin_code) + .json(&body) + .map_err(|e| ApiError::Network(e.to_string()))? + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + match resp.status() { + 200 => { + let parsed: ResetAuthCodeResponse = resp + .json() + .await + .map_err(|e| ApiError::Parse(e.to_string()))?; + Ok(parsed.auth_code) + } + 403 => Err(ApiError::Forbidden), + status => { + let msg = resp.text().await.unwrap_or_default(); + Err(ApiError::Server { + status, + message: msg, + }) + } + } +} + +/// Call `POST /api/admin/lock` to prevent new quote submissions. +/// +/// Sends `X-Admin-Code: admin_code` in the request header. No request body. +/// +/// Returns `Ok(true)` (submissions now locked) on HTTP 200, or: +/// - [`ApiError::Forbidden`] on HTTP 403 (wrong admin code), +/// - [`ApiError::Server`] for other non-200 responses, +/// - [`ApiError::Network`] / [`ApiError::Parse`] for connection/parse errors. +pub async fn admin_lock(admin_code: &str) -> Result { + let resp = gloo::net::http::Request::post("/api/admin/lock") + .header("X-Admin-Code", admin_code) + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + match resp.status() { + 200 => { + let parsed: LockResponse = resp + .json() + .await + .map_err(|e| ApiError::Parse(e.to_string()))?; + Ok(parsed.submissions_locked) + } + 403 => Err(ApiError::Forbidden), + status => { + let msg = resp.text().await.unwrap_or_default(); + Err(ApiError::Server { + status, + message: msg, + }) + } + } +} + +/// Call `POST /api/admin/unlock` to allow new quote submissions again. +/// +/// Sends `X-Admin-Code: admin_code` in the request header. No request body. +/// +/// Returns `Ok(false)` (submissions now unlocked) on HTTP 200, or: +/// - [`ApiError::Forbidden`] on HTTP 403 (wrong admin code), +/// - [`ApiError::Server`] for other non-200 responses, +/// - [`ApiError::Network`] / [`ApiError::Parse`] for connection/parse errors. +pub async fn admin_unlock(admin_code: &str) -> Result { + let resp = gloo::net::http::Request::post("/api/admin/unlock") + .header("X-Admin-Code", admin_code) + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + match resp.status() { + 200 => { + let parsed: LockResponse = resp + .json() + .await + .map_err(|e| ApiError::Parse(e.to_string()))?; + Ok(parsed.submissions_locked) + } + 403 => Err(ApiError::Forbidden), + status => { + let msg = resp.text().await.unwrap_or_default(); + Err(ApiError::Server { + status, + message: msg, + }) + } + } +} + /// Internal helper: GET a URL and deserialise the response body as JSON. /// /// Returns `ApiError` on non-2xx status or deserialisation failure.