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.just
pkgs.pwgen
];
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
status = "todo"
ticket_type = "project"
dependencies = ["8a7fba", "77237f", "6c5904"]
dependencies = ["8a7fba", "77237f", "6c5904", "d4a624"]
+++
<context>

@ -297,6 +297,25 @@ paths:
schema:
$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:
post:
operationId: lockSubmissions

@ -28,3 +28,10 @@ run-api:
run-ui:
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,
}
/// 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.
///
/// # Arguments
@ -202,24 +189,16 @@ pub async fn get_status() -> Result<StatusResponse, ApiError> {
/// Verify an admin code without side effects.
///
/// Calls `POST /api/admin/reset-auth-code` with `{"new_code": <code>}` and
/// `X-Admin-Code: <code>`. Because the new code equals the current code, the
/// operation is idempotent: the code remains unchanged on success.
///
/// 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`].
/// Calls `GET /api/admin/verify` with the `X-Admin-Code` header. Returns
/// `Ok(())` on `200 OK` (code is correct) or [`ApiError::Forbidden`] on
/// `403 Forbidden` (wrong or missing code). Other errors propagate as
/// [`ApiError::Server`], [`ApiError::Network`], or [`ApiError::Parse`].
///
/// # Arguments
/// - `code` — the admin code to verify.
pub async fn verify_admin_code(code: &str) -> Result<(), ApiError> {
let body = ResetAuthCodeBody {
new_code: Some(code),
};
let resp = gloo::net::http::Request::post("/api/admin/reset-auth-code")
let resp = gloo::net::http::Request::get("/api/admin/verify")
.header("X-Admin-Code", code)
.json(&body)
.map_err(|e| ApiError::Network(e.to_string()))?
.send()
.await
.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.
///
/// 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
//! 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.
//!
//! Refreshing the page resets to the locked state.
@ -50,12 +50,6 @@ pub fn admin_page() -> Html {
// --- Admin controls state (only used when unlocked) ---
// Active tab (settings or moderation).
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.
let submissions_locked: UseStateHandle<Option<bool>> = use_state(|| None);
// 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 ---
let on_lock = {
let admin_code = admin_code.clone();
@ -324,49 +273,6 @@ pub fn admin_page() -> Html {
if *active_tab == AdminTab::Settings {
<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">
<h2 class="admin-section__heading">{ "Submissions" }</h2>
{

@ -519,6 +519,24 @@ struct ResetAuthCodeResponse {
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.
///
/// 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.
.route("/api/status", get(get_status))
// 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/unlock", post(unlock_submissions))
.route("/api/admin/reset-auth-code", post(reset_auth_code))

Loading…
Cancel
Save