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