feat(quotesdb): admin API client functions in UI

Add ApiError::Forbidden variant, StatusResponse / ResetAuthCodeResponse /
LockResponse / ResetAuthCodeBody types, and four new async functions to
src/bin/ui/api.rs:
- get_status()           → GET /api/status
- admin_reset_auth_code() → POST /api/admin/reset-auth-code (X-Admin-Code + X-Auth-Code)
- admin_lock()           → POST /api/admin/lock (X-Admin-Code)
- admin_unlock()         → POST /api/admin/unlock (X-Admin-Code)

HTTP 403 responses map to ApiError::Forbidden in all three admin functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent a7bd635b59
commit 177a892d94

@ -10,7 +10,7 @@
//! ``` //! ```
use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput}; use quotesdb::{CreateQuoteInput, Quote, UpdateQuoteInput};
use serde::Deserialize; use serde::{Deserialize, Serialize};
/// Response type for `GET /api/quotes` (paginated list). /// Response type for `GET /api/quotes` (paginated list).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -44,6 +44,35 @@ pub enum ApiError {
/// Failed to parse the response body as JSON. /// Failed to parse the response body as JSON.
#[error("parse error: {0}")] #[error("parse error: {0}")]
Parse(String), 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. /// 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<StatusResponse, ApiError> {
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<String, ApiError> {
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<bool, ApiError> {
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<bool, ApiError> {
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. /// Internal helper: GET a URL and deserialise the response body as JSON.
/// ///
/// Returns `ApiError` on non-2xx status or deserialisation failure. /// Returns `ApiError` on non-2xx status or deserialisation failure.

Loading…
Cancel
Save