feat(quotesdb): admin page auth-first flow, remove admin from nav

- Remove the Admin link from the top navigation bar; /admin remains
  reachable by direct URL but is no longer discoverable from normal
  browsing.
- Rework /admin to an auth-first flow: on load the page shows only a
  password input and an Unlock button. On success, the admin controls
  (submission lock/unlock, auth code reset) are revealed; on failure a
  clear error message is shown and the page stays locked. Refreshing
  always resets to locked state (code is in component state only).
- Add api::verify_admin_code() — calls POST /api/admin/reset-auth-code
  with new_code equal to the entered code, making the call idempotent
  on success (code unchanged) while still returning 403 on mismatch.
- Fix pre-existing wasm build breakage in quote.rs: UpdateQuoteInput
  gained a hidden field in an earlier ticket but quote.rs was never
  updated. Added hidden: None to the struct literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 3 months ago
parent 6c2ee37feb
commit 390f9b6868

@ -200,6 +200,43 @@ pub async fn get_status() -> Result<StatusResponse, ApiError> {
fetch_json("/api/status").await
}
/// 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`].
///
/// # 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")
.header("X-Auth-Code", "")
.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()))?;
match resp.status() {
200 => Ok(()),
403 => Err(ApiError::Forbidden),
status => {
let msg = resp.text().await.unwrap_or_default();
Err(ApiError::Server {
status,
message: msg,
})
}
}
}
/// Call `POST /api/admin/reset-auth-code` to rotate a quote's auth code.
///
/// # Arguments

@ -84,7 +84,6 @@ fn app() -> Html {
<div class="nav__links">
<Link<Route> to={Route::Browse} classes="nav__link">{ "Browse" }</Link<Route>>
<Link<Route> to={Route::Submit} classes="nav__link">{ "Submit" }</Link<Route>>
<Link<Route> to={Route::Admin} classes="nav__link">{ "Admin" }</Link<Route>>
</div>
</nav>
<main class="main-content">

@ -1,8 +1,8 @@
//! Admin page — manage submissions lock and reset auth codes.
//! Admin page — auth-first flow for managing submissions and auth codes.
//!
//! Provides a persistent admin auth code input, an auth code reset section,
//! and a submissions lock/unlock section. The lock state is fetched on mount
//! from `GET /api/status`.
//! The page renders in a locked state on load, showing only an auth code
//! input. Once the admin code is verified against the API, the full admin
//! controls are revealed. Refreshing the page resets to the locked state.
use crate::api::{self, ApiError};
use wasm_bindgen_futures::spawn_local;
@ -11,48 +11,102 @@ use yew::prelude::*;
/// Admin page component.
///
/// Holds all local admin state and renders three sections:
/// 1. A persistent admin auth code input shared by all actions.
/// 2. A "Reset auth code" section that calls `POST /api/admin/reset-auth-code`.
/// 3. A "Submissions" section that shows current lock state and allows toggling.
///
/// The submissions lock state is fetched from `GET /api/status` on mount.
/// Implements an auth-first flow:
/// - On load: locked state — shows only an auth code input and an "Unlock" button.
/// - On successful verification: unlocked state — shows all admin controls
/// (submission lock/unlock and auth code reset), passing the verified code
/// to each operation.
/// - On failed verification (403): error message is shown; page remains locked.
/// - Refreshing always resets to locked — the code is kept in component state only.
#[function_component(AdminPage)]
pub fn admin_page() -> Html {
// Persistent admin auth code used by all admin actions.
// --- Auth gate state ---
// The code the user is typing into the unlock input.
let code_input = use_state(String::new);
// Whether the page is locked (true = locked, showing only auth input).
let locked = use_state(|| true);
// Error shown when unlock fails.
let unlock_error: UseStateHandle<Option<String>> = use_state(|| None);
// Disables the unlock button during the verification request.
let unlocking = use_state(|| false);
// The verified admin code, stored after a successful unlock.
let admin_code = use_state(String::new);
// --- Admin controls state (only used when unlocked) ---
// 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 on mount.
// Current submissions lock state, fetched after unlock.
let submissions_locked: UseStateHandle<Option<bool>> = use_state(|| None);
// Error message for the lock/unlock section.
let lock_error: UseStateHandle<Option<String>> = use_state(|| None);
// Disables buttons during in-flight requests.
// Disables admin action buttons during in-flight requests.
let loading = use_state(|| false);
// Fetch submission lock state on mount.
{
// --- Unlock handler ---
let on_unlock = {
let code_input = code_input.clone();
let locked = locked.clone();
let unlock_error = unlock_error.clone();
let unlocking = unlocking.clone();
let admin_code = admin_code.clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
use_effect_with((), move |_| {
Callback::from(move |e: MouseEvent| {
e.prevent_default();
if *unlocking {
return;
}
let code = (*code_input).clone();
if code.is_empty() {
unlock_error.set(Some("Please enter the admin code.".to_string()));
return;
}
let locked = locked.clone();
let unlock_error = unlock_error.clone();
let unlocking = unlocking.clone();
let admin_code = admin_code.clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
unlocking.set(true);
unlock_error.set(None);
spawn_local(async move {
match api::get_status().await {
Ok(status) => {
submissions_locked.set(Some(status.submissions_locked));
match api::verify_admin_code(&code).await {
Ok(()) => {
// Store the verified code and switch to unlocked state.
admin_code.set(code.clone());
locked.set(false);
unlocking.set(false);
// Fetch the current submission lock state now that we're unlocked.
match api::get_status().await {
Ok(status) => {
submissions_locked.set(Some(status.submissions_locked));
}
Err(_) => {
submissions_locked.set(Some(false));
lock_error.set(Some("Could not fetch status.".to_string()));
}
}
}
Err(_) => {
// Fail-open: treat as unlocked so the UI doesn't hang on "Loading status...".
submissions_locked.set(Some(false));
lock_error.set(Some("Could not fetch status.".to_string()));
Err(ApiError::Forbidden) => {
unlock_error.set(Some("Wrong admin code. Access denied.".to_string()));
unlocking.set(false);
}
Err(e) => {
unlock_error.set(Some(format!("Error verifying code: {e}")));
unlocking.set(false);
}
}
});
});
}
})
};
// --- Reset auth code handler ---
let on_reset = {
@ -82,7 +136,7 @@ pub fn admin_page() -> Html {
} else {
Some(passphrase.as_str())
};
// The server only validates X-Admin-Code; the `current` parameter is not used server-side.
// The server only validates X-Admin-Code; the `current` parameter is unused server-side.
match api::admin_reset_auth_code("", new_code_opt, &code).await {
Ok(new_code) => {
reset_result.set(Some(new_code));
@ -138,7 +192,7 @@ pub fn admin_page() -> Html {
};
// --- Unlock submissions handler ---
let on_unlock = {
let on_unlock_submissions = {
let admin_code = admin_code.clone();
let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone();
@ -178,113 +232,139 @@ pub fn admin_page() -> Html {
<div class="page-admin">
<h1 class="page-admin__title">{ "Admin" }</h1>
<div class="admin-section">
<label class="admin-section__label" for="admin_code">
{ "Admin auth code:" }
</label>
<input
id="admin_code"
class="admin-section__input"
type="text"
placeholder="word-word-word-word"
value={(*admin_code).clone()}
oninput={{
let admin_code = admin_code.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
admin_code.set(input.value());
})
}}
/>
</div>
if *locked {
// --- Locked state: show only the auth input ---
<div class="admin-lock-gate">
<p class="admin-lock-gate__description">
{ "Enter the admin code to access controls." }
</p>
<div class="admin-lock-gate__field">
<label class="admin-section__label" for="unlock_code">
{ "Admin code:" }
</label>
<input
id="unlock_code"
class="admin-section__input"
type="password"
placeholder="word-word-word-word"
value={(*code_input).clone()}
oninput={{
let code_input = code_input.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
code_input.set(input.value());
})
}}
/>
</div>
<div class="admin-lock-gate__actions">
<button
class="btn btn--primary"
disabled={*unlocking}
onclick={on_unlock}
>
if *unlocking {
{ "Verifying..." }
} else {
{ "Unlock" }
}
</button>
</div>
if let Some(err) = (*unlock_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
} else {
// --- Unlocked state: show all admin controls ---
<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 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>
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" />
<hr class="admin-divider" />
<div class="admin-section">
<h2 class="admin-section__heading">{ "Submissions" }</h2>
{
match *submissions_locked {
None => html! { <p class="admin-section__status">{ "Loading status..." }</p> },
Some(locked) => html! {
<>
<p class="admin-section__status">
{ "Status: " }
if locked {
<strong>{ "Closed" }</strong>
} else {
<strong>{ "Open" }</strong>
}
</p>
<div class="admin-section__actions">
if !locked {
<button
class="btn btn--danger"
disabled={*loading}
onclick={on_lock}
>
{ "Lock submissions" }
</button>
} else {
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_unlock}
>
{ "Unlock submissions" }
</button>
}
</div>
</>
},
<div class="admin-section">
<h2 class="admin-section__heading">{ "Submissions" }</h2>
{
match *submissions_locked {
None => html! { <p class="admin-section__status">{ "Loading status..." }</p> },
Some(is_locked) => html! {
<>
<p class="admin-section__status">
{ "Status: " }
if is_locked {
<strong>{ "Closed" }</strong>
} else {
<strong>{ "Open" }</strong>
}
</p>
<div class="admin-section__actions">
if !is_locked {
<button
class="btn btn--danger"
disabled={*loading}
onclick={on_lock}
>
{ "Lock submissions" }
</button>
} else {
<button
class="btn btn--primary"
disabled={*loading}
onclick={on_unlock_submissions}
>
{ "Unlock submissions" }
</button>
}
</div>
</>
},
}
}
}
if let Some(err) = (*lock_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
if let Some(err) = (*lock_error).clone() {
<p class="admin-section__error">{ err }</p>
}
</div>
}
</div>
}
}

@ -180,6 +180,7 @@ pub fn quote_page(props: &QuotePageProps) -> Html {
},
date: if date.is_empty() { None } else { Some(date) },
tags: Some(tags),
hidden: None,
};
match api::update_quote(&id, &input, &auth_code).await {
Ok(updated) => {

Loading…
Cancel
Save