@ -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 u nused 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 >
}
}
}
}