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>
quotesdb
Elijah Voigt 3 months ago
parent 995fff4046
commit 9827dcc5b9

@ -200,6 +200,43 @@ pub async fn get_status() -> Result<StatusResponse, ApiError> {
fetch_json("/api/status").await 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. /// Call `POST /api/admin/reset-auth-code` to rotate a quote's auth code.
/// ///
/// # Arguments /// # Arguments

@ -84,7 +84,6 @@ fn app() -> Html {
<div class="nav__links"> <div class="nav__links">
<Link<Route> to={Route::Browse} classes="nav__link">{ "Browse" }</Link<Route>> <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::Submit} classes="nav__link">{ "Submit" }</Link<Route>>
<Link<Route> to={Route::Admin} classes="nav__link">{ "Admin" }</Link<Route>>
</div> </div>
</nav> </nav>
<main class="main-content"> <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, //! The page renders in a locked state on load, showing only an auth code
//! and a submissions lock/unlock section. The lock state is fetched on mount //! input. Once the admin code is verified against the API, the full admin
//! from `GET /api/status`. //! controls are revealed. Refreshing the page resets to the locked state.
use crate::api::{self, ApiError}; use crate::api::{self, ApiError};
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
@ -11,48 +11,102 @@ use yew::prelude::*;
/// Admin page component. /// Admin page component.
/// ///
/// Holds all local admin state and renders three sections: /// Implements an auth-first flow:
/// 1. A persistent admin auth code input shared by all actions. /// - On load: locked state — shows only an auth code input and an "Unlock" button.
/// 2. A "Reset auth code" section that calls `POST /api/admin/reset-auth-code`. /// - On successful verification: unlocked state — shows all admin controls
/// 3. A "Submissions" section that shows current lock state and allows toggling. /// (submission lock/unlock and auth code reset), passing the verified code
/// /// to each operation.
/// The submissions lock state is fetched from `GET /api/status` on mount. /// - 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)] #[function_component(AdminPage)]
pub fn admin_page() -> Html { 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); let admin_code = use_state(String::new);
// --- Admin controls state (only used when unlocked) ---
// Optional new passphrase for the reset section. // Optional new passphrase for the reset section.
let new_passphrase = use_state(String::new); let new_passphrase = use_state(String::new);
// Newly returned auth code after a successful reset. // Newly returned auth code after a successful reset.
let reset_result: UseStateHandle<Option<String>> = use_state(|| None); let reset_result: UseStateHandle<Option<String>> = use_state(|| None);
// Error message for the reset section. // Error message for the reset section.
let reset_error: UseStateHandle<Option<String>> = use_state(|| None); 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); let submissions_locked: UseStateHandle<Option<bool>> = use_state(|| None);
// Error message for the lock/unlock section. // Error message for the lock/unlock section.
let lock_error: UseStateHandle<Option<String>> = use_state(|| None); 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); 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 submissions_locked = submissions_locked.clone();
let lock_error = lock_error.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 { spawn_local(async move {
match api::get_status().await { match api::verify_admin_code(&code).await {
Ok(status) => { Ok(()) => {
submissions_locked.set(Some(status.submissions_locked)); // 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(_) => { Err(ApiError::Forbidden) => {
// Fail-open: treat as unlocked so the UI doesn't hang on "Loading status...". unlock_error.set(Some("Wrong admin code. Access denied.".to_string()));
submissions_locked.set(Some(false)); unlocking.set(false);
lock_error.set(Some("Could not fetch status.".to_string())); }
Err(e) => {
unlock_error.set(Some(format!("Error verifying code: {e}")));
unlocking.set(false);
} }
} }
}); });
}); })
} };
// --- Reset auth code handler --- // --- Reset auth code handler ---
let on_reset = { let on_reset = {
@ -82,7 +136,7 @@ pub fn admin_page() -> Html {
} else { } else {
Some(passphrase.as_str()) 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 { match api::admin_reset_auth_code("", new_code_opt, &code).await {
Ok(new_code) => { Ok(new_code) => {
reset_result.set(Some(new_code)); reset_result.set(Some(new_code));
@ -138,7 +192,7 @@ pub fn admin_page() -> Html {
}; };
// --- Unlock submissions handler --- // --- Unlock submissions handler ---
let on_unlock = { let on_unlock_submissions = {
let admin_code = admin_code.clone(); let admin_code = admin_code.clone();
let submissions_locked = submissions_locked.clone(); let submissions_locked = submissions_locked.clone();
let lock_error = lock_error.clone(); let lock_error = lock_error.clone();
@ -178,113 +232,139 @@ pub fn admin_page() -> Html {
<div class="page-admin"> <div class="page-admin">
<h1 class="page-admin__title">{ "Admin" }</h1> <h1 class="page-admin__title">{ "Admin" }</h1>
<div class="admin-section"> if *locked {
<label class="admin-section__label" for="admin_code"> // --- Locked state: show only the auth input ---
{ "Admin auth code:" } <div class="admin-lock-gate">
</label> <p class="admin-lock-gate__description">
<input { "Enter the admin code to access controls." }
id="admin_code" </p>
class="admin-section__input" <div class="admin-lock-gate__field">
type="text" <label class="admin-section__label" for="unlock_code">
placeholder="word-word-word-word" { "Admin code:" }
value={(*admin_code).clone()} </label>
oninput={{ <input
let admin_code = admin_code.clone(); id="unlock_code"
Callback::from(move |e: InputEvent| { class="admin-section__input"
let input: HtmlInputElement = e.target_unchecked_into(); type="password"
admin_code.set(input.value()); placeholder="word-word-word-word"
}) value={(*code_input).clone()}
}} oninput={{
/> let code_input = code_input.clone();
</div> 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"> <div class="admin-section">
<h2 class="admin-section__heading">{ "Reset auth code" }</h2> <h2 class="admin-section__heading">{ "Reset auth code" }</h2>
<div class="admin-section__field"> <div class="admin-section__field">
<label class="admin-section__label" for="new_passphrase"> <label class="admin-section__label" for="new_passphrase">
{ "New passphrase (optional):" } { "New passphrase (optional):" }
</label> </label>
<input <input
id="new_passphrase" id="new_passphrase"
class="admin-section__input" class="admin-section__input"
type="text" type="text"
placeholder="leave blank to auto-generate" placeholder="leave blank to auto-generate"
value={(*new_passphrase).clone()} value={(*new_passphrase).clone()}
oninput={{ oninput={{
let new_passphrase = new_passphrase.clone(); let new_passphrase = new_passphrase.clone();
Callback::from(move |e: InputEvent| { Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into(); let input: HtmlInputElement = e.target_unchecked_into();
new_passphrase.set(input.value()); new_passphrase.set(input.value());
}) })
}} }}
/> />
</div> </div>
<div class="admin-section__actions"> <div class="admin-section__actions">
<button <button
class="btn btn--primary" class="btn btn--primary"
disabled={*loading} disabled={*loading}
onclick={on_reset} onclick={on_reset}
> >
{ "Reset" } { "Reset" }
</button> </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> </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"> <div class="admin-section">
<h2 class="admin-section__heading">{ "Submissions" }</h2> <h2 class="admin-section__heading">{ "Submissions" }</h2>
{ {
match *submissions_locked { match *submissions_locked {
None => html! { <p class="admin-section__status">{ "Loading status..." }</p> }, None => html! { <p class="admin-section__status">{ "Loading status..." }</p> },
Some(locked) => html! { Some(is_locked) => html! {
<> <>
<p class="admin-section__status"> <p class="admin-section__status">
{ "Status: " } { "Status: " }
if locked { if is_locked {
<strong>{ "Closed" }</strong> <strong>{ "Closed" }</strong>
} else { } else {
<strong>{ "Open" }</strong> <strong>{ "Open" }</strong>
} }
</p> </p>
<div class="admin-section__actions"> <div class="admin-section__actions">
if !locked { if !is_locked {
<button <button
class="btn btn--danger" class="btn btn--danger"
disabled={*loading} disabled={*loading}
onclick={on_lock} onclick={on_lock}
> >
{ "Lock submissions" } { "Lock submissions" }
</button> </button>
} else { } else {
<button <button
class="btn btn--primary" class="btn btn--primary"
disabled={*loading} disabled={*loading}
onclick={on_unlock} onclick={on_unlock_submissions}
> >
{ "Unlock submissions" } { "Unlock submissions" }
</button> </button>
} }
</div> </div>
</> </>
}, },
}
} }
} if let Some(err) = (*lock_error).clone() {
if let Some(err) = (*lock_error).clone() { <p class="admin-section__error">{ err }</p>
<p class="admin-section__error">{ err }</p> }
} </div>
</div> }
</div> </div>
} }
} }

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

Loading…
Cancel
Save