feat(quotesdb): add admin verify endpoint, remove reset-auth-code UI

- Add GET /api/admin/verify — side-effect-free code check used by the
  admin unlock flow; registered before reset-auth-code in the router
- Remove "Reset auth code" section from admin panel (UI + dead API code);
  rotation is now CLI-only via `wrangler secret put ADMIN_AUTH_CODE`
- Add rotate-admin-code justfile recipe using pwgen for local key rotation
- Add pwgen to Nix dev shell
- Update OpenAPI spec with /api/admin/verify definition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 9418bd4b0b
commit dc7cfec897

@ -65,6 +65,8 @@
pkgs.mdbook pkgs.mdbook
pkgs.just pkgs.just
pkgs.pwgen
]; ];
shellHook = '' shellHook = ''

@ -0,0 +1,7 @@
+++
title = "Support ADMIN_AUTH_CODE Cloudflare secret for admin auth"
priority = 8
status = "done"
ticket_type = "feature"
dependencies = []
+++

@ -3,7 +3,7 @@ title = "quotesdb/api"
priority = 7 priority = 7
status = "todo" status = "todo"
ticket_type = "project" ticket_type = "project"
dependencies = ["8a7fba", "77237f", "6c5904"] dependencies = ["8a7fba", "77237f", "6c5904", "d4a624"]
+++ +++
<context> <context>

@ -297,6 +297,25 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
/api/admin/verify:
get:
operationId: verifyAdminCode
summary: Verify the admin auth code
description: >
Checks the X-Admin-Code header against the configured admin authority
(ADMIN_AUTH_CODE secret or database value). Has no side effects.
security:
- AdminCode: []
responses:
"200":
description: Code is correct.
"403":
description: Code is missing or wrong.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/admin/lock: /api/admin/lock:
post: post:
operationId: lockSubmissions operationId: lockSubmissions

@ -28,3 +28,10 @@ run-api:
run-ui: run-ui:
trunk serve trunk serve
rotate-admin-code:
#!/usr/bin/env bash
set -euo pipefail
CODE="$(pwgen -s 32 1)"
echo "New ADMIN_AUTH_CODE: $CODE"
echo "$CODE" | wrangler secret put ADMIN_AUTH_CODE --config wrangler.toml

@ -56,25 +56,12 @@ pub struct StatusResponse {
pub submissions_locked: bool, 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`. /// Response from `POST /api/admin/lock` or `POST /api/admin/unlock`.
#[derive(Deserialize)] #[derive(Deserialize)]
struct LockResponse { struct LockResponse {
pub submissions_locked: bool, 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.
/// ///
/// # Arguments /// # Arguments
@ -202,24 +189,16 @@ pub async fn get_status() -> Result<StatusResponse, ApiError> {
/// Verify an admin code without side effects. /// Verify an admin code without side effects.
/// ///
/// Calls `POST /api/admin/reset-auth-code` with `{"new_code": <code>}` and /// Calls `GET /api/admin/verify` with the `X-Admin-Code` header. Returns
/// `X-Admin-Code: <code>`. Because the new code equals the current code, the /// `Ok(())` on `200 OK` (code is correct) or [`ApiError::Forbidden`] on
/// operation is idempotent: the code remains unchanged on success. /// `403 Forbidden` (wrong or missing code). Other errors propagate as
/// /// [`ApiError::Server`], [`ApiError::Network`], or [`ApiError::Parse`].
/// Returns `Ok(())` on HTTP 200 (code is correct) or [`ApiError::Forbidden`]
/// on HTTP 403 (wrong code). Other errors propagate as [`ApiError::Server`],
/// [`ApiError::Network`], or [`ApiError::Parse`].
/// ///
/// # Arguments /// # Arguments
/// - `code` — the admin code to verify. /// - `code` — the admin code to verify.
pub async fn verify_admin_code(code: &str) -> Result<(), ApiError> { pub async fn verify_admin_code(code: &str) -> Result<(), ApiError> {
let body = ResetAuthCodeBody { let resp = gloo::net::http::Request::get("/api/admin/verify")
new_code: Some(code),
};
let resp = gloo::net::http::Request::post("/api/admin/reset-auth-code")
.header("X-Admin-Code", code) .header("X-Admin-Code", code)
.json(&body)
.map_err(|e| ApiError::Network(e.to_string()))?
.send() .send()
.await .await
.map_err(|e| ApiError::Network(e.to_string()))?; .map_err(|e| ApiError::Network(e.to_string()))?;
@ -236,47 +215,6 @@ pub async fn verify_admin_code(code: &str) -> Result<(), ApiError> {
} }
} }
/// Call `POST /api/admin/reset-auth-code` to rotate the admin auth code.
///
/// # Arguments
/// - `new_code` — an optional new passphrase; if `None` one is generated server-side.
/// - `admin_code` — the admin 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(
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-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. /// Call `POST /api/admin/lock` to prevent new quote submissions.
/// ///
/// Sends `X-Admin-Code: admin_code` in the request header. No request body. /// Sends `X-Admin-Code: admin_code` in the request header. No request body.

@ -4,7 +4,7 @@
//! input. Once the admin code is verified against the API, the full admin //! input. Once the admin code is verified against the API, the full admin
//! controls are revealed with two tabs: //! controls are revealed with two tabs:
//! //!
//! - **Settings** — submission lock/unlock and auth code reset. //! - **Settings** — submission lock/unlock.
//! - **Moderation** — paginated list of reported quotes with a detail modal. //! - **Moderation** — paginated list of reported quotes with a detail modal.
//! //!
//! Refreshing the page resets to the locked state. //! Refreshing the page resets to the locked state.
@ -50,12 +50,6 @@ pub fn admin_page() -> Html {
// --- Admin controls state (only used when unlocked) --- // --- Admin controls state (only used when unlocked) ---
// Active tab (settings or moderation). // Active tab (settings or moderation).
let active_tab = use_state(|| AdminTab::Settings); let active_tab = use_state(|| AdminTab::Settings);
// Optional new passphrase for the reset section.
let new_passphrase = use_state(String::new);
// Newly returned auth code after a successful reset.
let reset_result: UseStateHandle<Option<String>> = use_state(|| None);
// Error message for the reset section.
let reset_error: UseStateHandle<Option<String>> = use_state(|| None);
// Current submissions lock state, fetched after unlock. // Current submissions lock state, fetched after unlock.
let submissions_locked: UseStateHandle<Option<bool>> = use_state(|| None); let submissions_locked: UseStateHandle<Option<bool>> = use_state(|| None);
// Error message for the lock/unlock section. // Error message for the lock/unlock section.
@ -125,51 +119,6 @@ pub fn admin_page() -> Html {
}) })
}; };
// --- Reset auth code handler ---
let on_reset = {
let admin_code = admin_code.clone();
let new_passphrase = new_passphrase.clone();
let reset_result = reset_result.clone();
let reset_error = reset_error.clone();
let loading = loading.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if *loading {
return;
}
let code = (*admin_code).clone();
let passphrase = (*new_passphrase).clone();
let reset_result = reset_result.clone();
let reset_error = reset_error.clone();
let loading = loading.clone();
loading.set(true);
reset_result.set(None);
reset_error.set(None);
spawn_local(async move {
let new_code_opt = if passphrase.is_empty() {
None
} else {
Some(passphrase.as_str())
};
match api::admin_reset_auth_code(new_code_opt, &code).await {
Ok(new_code) => {
reset_result.set(Some(new_code));
reset_error.set(None);
}
Err(ApiError::Forbidden) => {
reset_error.set(Some("Wrong admin code.".to_string()));
}
Err(e) => {
reset_error.set(Some(format!("Error: {e}")));
}
}
loading.set(false);
});
})
};
// --- Lock submissions handler --- // --- Lock submissions handler ---
let on_lock = { let on_lock = {
let admin_code = admin_code.clone(); let admin_code = admin_code.clone();
@ -324,49 +273,6 @@ pub fn admin_page() -> Html {
if *active_tab == AdminTab::Settings { if *active_tab == AdminTab::Settings {
<hr class="admin-divider" /> <hr class="admin-divider" />
<div class="admin-section">
<h2 class="admin-section__heading">{ "Reset auth code" }</h2>
<div class="admin-section__field">
<label class="admin-section__label" for="new_passphrase">
{ "New passphrase (optional):" }
</label>
<input
id="new_passphrase"
class="admin-section__input"
type="text"
placeholder="leave blank to auto-generate"
value={(*new_passphrase).clone()}
oninput={{
let new_passphrase = new_passphrase.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
new_passphrase.set(input.value());
})
}}
/>
</div>
<div class="admin-section__actions">
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_reset}
>
{ "Reset" }
</button>
</div>
if let Some(new_code) = (*reset_result).clone() {
<p class="admin-section__success">
{ "New code: " }
<code>{ new_code }</code>
</p>
}
if let Some(err) = (*reset_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
<hr class="admin-divider" />
<div class="admin-section"> <div class="admin-section">
<h2 class="admin-section__heading">{ "Submissions" }</h2> <h2 class="admin-section__heading">{ "Submissions" }</h2>
{ {

@ -519,6 +519,24 @@ struct ResetAuthCodeResponse {
auth_code: String, auth_code: String,
} }
/// `GET /api/admin/verify` — verify the admin code without side effects.
///
/// Checks the `X-Admin-Code` header against the configured authority (secret
/// or database). Returns `200 OK` if the code is correct, `403 Forbidden` if
/// it is missing or wrong. Has no side effects and works regardless of whether
/// the admin code comes from the `ADMIN_AUTH_CODE` secret or the DB.
#[cfg_attr(target_arch = "wasm32", worker::send)]
async fn verify_admin_code_handler(State(state): State<AppState>, headers: HeaderMap) -> Response {
let Some(code) = extract_admin_code(&headers) else {
return error_response(StatusCode::FORBIDDEN, "X-Admin-Code header is required");
};
if verify_admin_code(&state, &code).await {
StatusCode::OK.into_response()
} else {
error_response(StatusCode::FORBIDDEN, "invalid admin code")
}
}
/// `POST /api/admin/reset-auth-code` — replace the stored admin auth code. /// `POST /api/admin/reset-auth-code` — replace the stored admin auth code.
/// ///
/// Requires the `X-Admin-Code` header containing the **current** admin /// Requires the `X-Admin-Code` header containing the **current** admin
@ -787,6 +805,7 @@ pub fn router(
// Public status — exposes whether submissions are currently locked. // Public status — exposes whether submissions are currently locked.
.route("/api/status", get(get_status)) .route("/api/status", get(get_status))
// Admin endpoints — toggle the global submissions lock and reset auth code. // Admin endpoints — toggle the global submissions lock and reset auth code.
.route("/api/admin/verify", get(verify_admin_code_handler))
.route("/api/admin/lock", post(lock_submissions)) .route("/api/admin/lock", post(lock_submissions))
.route("/api/admin/unlock", post(unlock_submissions)) .route("/api/admin/unlock", post(unlock_submissions))
.route("/api/admin/reset-auth-code", post(reset_auth_code)) .route("/api/admin/reset-auth-code", post(reset_auth_code))

Loading…
Cancel
Save