diff --git a/quotesdb/src/bin/ui/pages/admin.rs b/quotesdb/src/bin/ui/pages/admin.rs index 60211e5..1abfc79 100644 --- a/quotesdb/src/bin/ui/pages/admin.rs +++ b/quotesdb/src/bin/ui/pages/admin.rs @@ -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> = 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> = use_state(|| None); // Error message for the reset section. let reset_error: UseStateHandle> = use_state(|| None); - // Current submissions lock state, fetched on mount. + // Current submissions lock state, fetched after unlock. let submissions_locked: UseStateHandle> = use_state(|| None); // Error message for the lock/unlock section. let lock_error: UseStateHandle> = 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 {

{ "Admin" }

-
- - -
+ if *locked { + // --- Locked state: show only the auth input --- +
+

+ { "Enter the admin code to access controls." } +

+
+ + +
+
+ +
+ if let Some(err) = (*unlock_error).clone() { +

{ err }

+ } +
+ } else { + // --- Unlocked state: show all admin controls --- -
+
-
-

{ "Reset auth code" }

-
- - -
-
- +
+

{ "Reset auth code" }

+
+ + +
+
+ +
+ if let Some(new_code) = (*reset_result).clone() { +

+ { "New code: " } + { new_code } +

+ } + if let Some(err) = (*reset_error).clone() { +

{ err }

+ }
- if let Some(new_code) = (*reset_result).clone() { -

- { "New code: " } - { new_code } -

- } - if let Some(err) = (*reset_error).clone() { -

{ err }

- } -
-
+
-
-

{ "Submissions" }

- { - match *submissions_locked { - None => html! {

{ "Loading status..." }

}, - Some(locked) => html! { - <> -

- { "Status: " } - if locked { - { "Closed" } - } else { - { "Open" } - } -

-
- if !locked { - - } else { - - } -
- - }, +
+

{ "Submissions" }

+ { + match *submissions_locked { + None => html! {

{ "Loading status..." }

}, + Some(is_locked) => html! { + <> +

+ { "Status: " } + if is_locked { + { "Closed" } + } else { + { "Open" } + } +

+
+ if !is_locked { + + } else { + + } +
+ + }, + } } - } - if let Some(err) = (*lock_error).clone() { -

{ err }

- } -
+ if let Some(err) = (*lock_error).clone() { +

{ err }

+ } +
+ }
} } diff --git a/quotesdb/src/bin/ui/pages/quote.rs b/quotesdb/src/bin/ui/pages/quote.rs index 349c066..f9f4c0f 100644 --- a/quotesdb/src/bin/ui/pages/quote.rs +++ b/quotesdb/src/bin/ui/pages/quote.rs @@ -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) => {