feat(quotesdb): /admin page component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>quotesdb
parent
a4d59b4371
commit
49f70cc5e8
@ -0,0 +1,281 @@
|
||||
//! Admin page — manage submissions lock and reset 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`.
|
||||
|
||||
use crate::api::{self, ApiError};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::HtmlInputElement;
|
||||
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.
|
||||
#[function_component(AdminPage)]
|
||||
pub fn admin_page() -> Html {
|
||||
// Persistent admin auth code used by all admin actions.
|
||||
let admin_code = use_state(String::new);
|
||||
// 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.
|
||||
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.
|
||||
let loading = use_state(|| false);
|
||||
|
||||
// Fetch submission lock state on mount.
|
||||
{
|
||||
let submissions_locked = submissions_locked.clone();
|
||||
use_effect_with((), move |_| {
|
||||
spawn_local(async move {
|
||||
if let Ok(status) = api::get_status().await {
|
||||
submissions_locked.set(Some(status.submissions_locked));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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(&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 auth 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();
|
||||
let submissions_locked = submissions_locked.clone();
|
||||
let lock_error = lock_error.clone();
|
||||
let loading = loading.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
if *loading {
|
||||
return;
|
||||
}
|
||||
let code = (*admin_code).clone();
|
||||
let submissions_locked = submissions_locked.clone();
|
||||
let lock_error = lock_error.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
loading.set(true);
|
||||
lock_error.set(None);
|
||||
|
||||
spawn_local(async move {
|
||||
match api::admin_lock(&code).await {
|
||||
Ok(_) => {
|
||||
submissions_locked.set(Some(true));
|
||||
lock_error.set(None);
|
||||
}
|
||||
Err(ApiError::Forbidden) => {
|
||||
lock_error.set(Some("Wrong auth code.".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
lock_error.set(Some(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// --- Unlock submissions handler ---
|
||||
let on_unlock = {
|
||||
let admin_code = admin_code.clone();
|
||||
let submissions_locked = submissions_locked.clone();
|
||||
let lock_error = lock_error.clone();
|
||||
let loading = loading.clone();
|
||||
Callback::from(move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
if *loading {
|
||||
return;
|
||||
}
|
||||
let code = (*admin_code).clone();
|
||||
let submissions_locked = submissions_locked.clone();
|
||||
let lock_error = lock_error.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
loading.set(true);
|
||||
lock_error.set(None);
|
||||
|
||||
spawn_local(async move {
|
||||
match api::admin_unlock(&code).await {
|
||||
Ok(_) => {
|
||||
submissions_locked.set(Some(false));
|
||||
lock_error.set(None);
|
||||
}
|
||||
Err(ApiError::Forbidden) => {
|
||||
lock_error.set(Some("Wrong auth code.".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
lock_error.set(Some(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
{
|
||||
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>
|
||||
</>
|
||||
},
|
||||
}
|
||||
}
|
||||
if let Some(err) = (*lock_error).clone() {
|
||||
<p class="admin-section__error">{ err }</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue