//! Admin UI (milestone 7): user management and server settings. //! Rendered in place of the browser when an admin opens the "Admin" panel. use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use crate::api::{self, AdminUser, Me, Mode}; use crate::components::toast::{ToastMsg, show}; // --------------------------------------------------------------------------- // Top-level admin view with tabs // --------------------------------------------------------------------------- #[component] pub fn AdminView(me: ReadSignal>) -> impl IntoView { let (tab, set_tab) = signal("users".to_string()); view! {
{move || { if tab.get() == "users" { view! { } .into_view() .into_any() } else { view! { } .into_view() .into_any() } }}
} } // --------------------------------------------------------------------------- // Settings tab // --------------------------------------------------------------------------- #[component] fn SettingsTab() -> impl IntoView { let toast = use_context::().expect("toast context"); let (value, set_value) = signal(Option::::None); let (busy, set_busy) = signal(false); { let set = set_value; let toast2 = toast; spawn_local(async move { match api::get_admin_settings().await { Ok(s) => set.set(Some(s.allow_writable_shares)), Err(e) => show(toast2, e.to_string()), } }); } let save = move |_| { if busy.get() { return; } let Some(v) = value.get() else { return }; set_busy.set(true); let toast2 = toast; spawn_local(async move { match api::update_admin_settings(v).await { Ok(_) => show(toast2, "Settings saved".to_string()), Err(e) => show(toast2, format!("Could not save settings: {e}")), } set_busy.set(false); }); }; view! {

"Server settings"

{move || match value.get() { None => view! {

"Loading…"

} .into_view() .into_any(), Some(v) => view! { } .into_view() .into_any(), }}
} } // --------------------------------------------------------------------------- // Users tab // --------------------------------------------------------------------------- /// What the user form is editing: a brand-new user, or an existing one. #[derive(Clone)] enum Editing { New, Edit(AdminUser), } #[component] fn UsersTab(me: ReadSignal>) -> impl IntoView { let toast = use_context::().expect("toast context"); let (users, set_users) = signal(Option::>::None); let (editing, set_editing) = signal(Option::::None); let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id); let reload = move || { let set = set_users; let toast2 = toast; spawn_local(async move { match api::list_admin_users().await { Ok(u) => set.set(Some(u)), Err(e) => show(toast2, e.to_string()), } }); }; reload(); let delete = move |u: AdminUser| { let is_self = self_id() == Some(u.id); if is_self { return; } let Some(win) = web_sys::window() else { return }; let ok = win .confirm_with_message(&format!("Delete user “{}”? This cannot be undone.", u.name)) .unwrap_or(false); if !ok { return; } let id = u.id; let read = users; let write = set_users; let toast2 = toast; spawn_local(async move { if api::delete_admin_user(id).await.is_ok() { if let Some(mut cur) = read.get() { cur.retain(|x| x.id != id); write.set(Some(cur)); } } else { show(toast2, "Could not delete user".to_string()); } }); }; view! {

"Users"

{move || match users.get() { None => view! {

"Loading…"

} .into_view() .into_any(), Some(ref list) if list.is_empty() => view! {

"No users."

} .into_view() .into_any(), Some(ref list) => { let sid = self_id(); let rows = list .iter() .map(|u| { let is_self = sid == Some(u.id); let roots_desc = if u.roots.is_empty() { "(no folders)".to_string() } else { u.roots .iter() .map(|r| format!("{} ({})", r.path, r.mode.as_str())) .collect::>() .join(", ") }; let u_edit = u.clone(); let u_del = u.clone(); let del = delete; let set_edit = set_editing; view! {
{if u.is_admin { "👑 " } else { "" }} {u.name.clone()} {if is_self { view! { " (you)" } .into_view() .into_any() } else { { view! {}.into_view() }.into_any() }} {if u.is_admin { view! { "admin" } .into_view() .into_any() } else { { view! {}.into_view() }.into_any() }} {if !u.active { view! { "disabled" } .into_view() .into_any() } else { { view! {}.into_view() }.into_any() }}
{roots_desc.clone()}
} }) .collect::>(); view! {
{rows}
} } .into_view() .into_any(), }} {move || match editing.get() { Some(ref e) => { let existing = match e { Editing::Edit(u) => Some(u.clone()), Editing::New => None, }; let sid = self_id(); let set = set_editing; view! { } .into_view() .into_any() } None => { view! {}.into_any() }, }}
} } // --------------------------------------------------------------------------- // User create / edit form // --------------------------------------------------------------------------- #[derive(Clone)] struct RootDraft { path: String, mode: Mode, } #[component] fn UserForm( existing: Option, self_id: Option, close: Callback<()>, on_saved: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let is_new = existing.is_none(); let is_self = existing .as_ref() .map(|u| self_id == Some(u.id)) .unwrap_or(false); let original_name = existing .as_ref() .map(|u| u.name.clone()) .unwrap_or_default(); let (name, set_name) = signal( existing .as_ref() .map(|u| u.name.clone()) .unwrap_or_default(), ); let (password, set_password) = signal(String::new()); let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false)); let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true)); let mut roots0: Vec = existing .as_ref() .map(|u| { u.roots .iter() .map(|r| RootDraft { path: r.path.clone(), mode: r.mode, }) .collect() }) .unwrap_or_default(); if is_new && roots0.is_empty() { roots0.push(RootDraft { path: ".".to_string(), mode: Mode::Rw, }); } let (roots, set_roots) = signal(roots0); let (new_path, set_new_path) = signal(String::new()); let (busy, set_busy) = signal(false); let (error, set_error) = signal(Option::::None); let name_readonly = !is_new; let add_root = move |_| { let p = new_path.get().trim().to_string(); let path = if p.is_empty() { ".".to_string() } else { p }; let mut v = roots.get(); // Avoid duplicate paths. if v.iter().any(|r| r.path == path) { set_error.set(Some("That folder is already in the list.".to_string())); return; } v.push(RootDraft { path: path.clone(), mode: Mode::Rw, }); set_roots.set(v); set_new_path.set(String::new()); set_error.set(None); }; let save = move |_| { if busy.get() { return; } let n = name.get().trim().to_string(); let pw = password.get(); let adm = is_admin.get(); let act = active.get(); let pairs: Vec<(String, Mode)> = roots .get() .iter() .map(|r| (r.path.clone(), r.mode)) .collect(); // Client-side validation. if n.is_empty() { set_error.set(Some("A name is required.".to_string())); return; } if is_new && pw.len() < 8 { set_error.set(Some("Password must be at least 8 characters.".to_string())); return; } if !is_new && pw.is_empty() { // keep password unchanged } else if pw.len() < 8 { set_error.set(Some("Password must be at least 8 characters.".to_string())); return; } if pairs.is_empty() { set_error.set(Some("Give the user at least one folder.".to_string())); return; } set_busy.set(true); set_error.set(None); let toast2 = toast; let on_saved2 = on_saved; let close2 = close; let existing_id = existing.as_ref().map(|u| u.id); spawn_local(async move { let result = if is_new { api::create_admin_user(&n, &pw, adm, &pairs).await } else { let id = existing_id.unwrap_or(0); let pw_opt = if pw.is_empty() { None } else { Some(pw.clone()) }; api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await }; match result { Ok(_) => { show( toast2, if is_new { "User created".to_string() } else { "User updated".to_string() }, ); on_saved2.run(()); close2.run(()); } Err(e) => { show(toast2, format!("Could not save user: {e}")); set_busy.set(false); } } }); }; view! { } }