//! Admin UI (milestone 7): user management and server settings. //! Rendered as full views from the shell's "manage" section. use crate::i18n; use crate::util::select_value; use leptos::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use crate::api::{self, AdminUser, Me, Mode}; use crate::components::icon::Icon; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show, show_error}; use crate::icons::IconName; use crate::views::dialogs::{Dialog, admin_root}; // --------------------------------------------------------------------------- // Settings view // --------------------------------------------------------------------------- /// The two settings tabs. Every user sees Profile; Server is admin-only. #[derive(Clone, Copy, PartialEq)] enum SettingsTab { Profile, Server, } impl SettingsTab { const KEY: &'static str = "fbng.settings-tab"; fn load(is_admin: bool) -> Self { match crate::util::storage_get(Self::KEY).as_deref() { Some("server") if is_admin => SettingsTab::Server, Some("profile") => SettingsTab::Profile, // First visit: admins land on the server tab (the pre-existing // default), everyone else on their profile. _ if is_admin => SettingsTab::Server, _ => SettingsTab::Profile, } } fn save(self) { crate::util::storage_set( Self::KEY, match self { SettingsTab::Server => "server", SettingsTab::Profile => "profile", }, ); } } /// One labelled checkbox row for a setting. /// /// The save button belongs to the tab, not the row. The server tab writes /// all of its settings in one PUT, so a per-row button would save the other /// rows' unsaved state too. #[component] fn SettingToggle( label: &'static str, desc: &'static str, value: ReadSignal>, set_value: WriteSignal>, ) -> impl IntoView { view! { {move || match value.get() { None => view! {

{i18n::tr(i18n::k::LOADING)}

} .into_view() .into_any(), Some(v) => view! { } .into_view() .into_any(), }} } } #[component] pub fn SettingsView( me: ReadSignal>, set_me: WriteSignal>, /// The folder picker for the search exclusions is rendered at the shell /// level, like every other dialog. set_dialog: WriteSignal>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin); // The shell re-creates this view when /me changes (saving resets it), so // the active tab is remembered in localStorage like the view mode. let (tab, set_tab) = signal(SettingsTab::load(is_admin())); let pick_tab = move |t: SettingsTab| { t.save(); set_tab.set(t); }; // --- profile: single-click-to-open ------------------------------------ let (single_click, set_single_click) = signal(Option::::None); let (profile_busy, set_profile_busy) = signal(false); // Profile language: Some(None) = auto (follow the browser). let (language, set_language) = signal(Option::>::None); Effect::new(move |_| { if let Some(u) = me.get().and_then(|m| m.user) { set_single_click.set(Some(u.single_click_open)); set_language.set(Some(u.language.clone())); } }); // Saves both profile settings in one call. let save_profile = Callback::new(move |_| { if profile_busy.get() { return; } let Some(v) = single_click.get() else { return }; let Some(lang) = language.get() else { return }; set_profile_busy.set(true); let toast2 = toast; let set_me2 = set_me; let set_val = set_single_click; let set_lang = set_language; spawn_local(async move { match api::update_profile(Some(v), Some(lang.clone())).await { Ok(m) => { show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string()); // The response already carries the fresh /me (which also // flips the UI language via the app-level effect). set_me2.set(Some(m)); set_val.set(Some(v)); set_lang.set(Some(lang)); } Err(e) => show( toast2, i18n::t_fmt(i18n::k::PROFILE_SAVE_ERR, &e.to_string()), ), } set_profile_busy.set(false); }); }); // --- server settings (admin only) -------------------------------------- let (value, set_value) = signal(Option::::None); let (excludes, set_excludes) = signal(Vec::::new()); let (excl_error, set_excl_error) = signal(Option::::None); let (busy, set_busy) = signal(false); { let set = set_value; let set_ex = set_excludes; let toast2 = toast; spawn_local(async move { // Non-admins don't have a Server tab; don't even probe the // endpoint (it would 403 and toast). if !is_admin() { return; } match api::get_admin_settings().await { Ok(s) => { set.set(Some(s.allow_writable_shares)); set_ex.set(s.search_excludes); } Err(e) => show_error(toast2, e.to_string()), } }); } // Opens the same folder picker the user editor uses, over the whole // server root (an exclusion is not tied to any one user's folders). let add_exclude = move |_| { set_excl_error.set(None); set_dialog.set(Some(Dialog::Picker { title: i18n::t(i18n::k::EXCLUDE_FOLDER).to_string(), confirm: i18n::t(i18n::k::EXCLUDE_HERE).to_string(), roots: vec![admin_root()], root: api_types::ADMIN_ROOT, dir: String::new(), for_write: false, on_pick: Callback::new(move |(_, dir): (i64, String)| { // An empty dir is the server root. Excluding it would // switch search off altogether. if dir.is_empty() { set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_ROOT_ERR).to_string())); return; } let mut v = excludes.get(); if v.contains(&dir) { set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_DUP_ERR).to_string())); return; } v.push(dir); set_excludes.set(v); set_excl_error.set(None); }), })); }; let save = move |_| { if busy.get() { return; } let Some(v) = value.get() else { return }; let ex = excludes.get(); set_busy.set(true); let toast2 = toast; let set_me2 = set_me; spawn_local(async move { match api::update_admin_settings(v, ex).await { Ok(_) => { show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string()); // Re-fetch this session's /me so the share dialog (and // anything else) sees the new setting without a reload. if let Ok(m) = api::me().await { set_me2.set(Some(m)); } } Err(e) => show( toast2, i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()), ), } set_busy.set(false); }); }; view! {
{move || match (single_click.get(), language.get()) { (Some(sc), Some(lang)) => { let lang_val = lang.clone().unwrap_or_default(); view! {
{i18n::t(i18n::k::LANGUAGE)} {i18n::t(i18n::k::LANGUAGE_DESC)}
} .into_view() .into_any() } _ => view! {

{i18n::tr(i18n::k::LOADING)}

} .into_view() .into_any(), }}
{i18n::tr(i18n::k::SEARCH_EXCLUDES)}
{move || { let list = excludes.get(); if list.is_empty() { return view! {

{i18n::tr(i18n::k::SEARCH_EXCLUDES_EMPTY)}

} .into_view() .into_any(); } list.iter() .enumerate() .map(|(i, path)| { view! {
{path.clone()}
} }) .collect::>() .into_view() .into_any() }}
{move || { let Some(e) = excl_error.get() else { return view! {}.into_any(); }; view! {

{e}

}.into_view().into_any() }}
} } // --------------------------------------------------------------------------- // Users view // --------------------------------------------------------------------------- /// What the user form is editing: a brand-new user, or an existing one. #[derive(Clone)] enum Editing { New, Edit(AdminUser), } #[component] pub fn UsersView( me: ReadSignal>, set_me: WriteSignal>, set_dialog: WriteSignal>, ) -> 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_error(toast2, e.to_string()), } }); }; reload(); // After a save: reload the list, and — if the saved user is the current // one — refresh /me so the sidebar's folders update in place. let on_saved = Callback::new(move |saved: Option| { reload(); if saved.is_some_and(|id| self_id() == Some(id)) { crate::views::shell::refresh_me(set_me, toast); } }); 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(&i18n::t_fmt(i18n::k::DELETE_USER_CONFIRM, &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_error(toast2, i18n::t(i18n::k::USER_DELETE_ERR).to_string()); } }); }; view! {

{i18n::tr(i18n::k::USERS)}

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

{i18n::tr(i18n::k::LOADING)}

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

{i18n::tr(i18n::k::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 is_admin = u.is_admin; let u_edit = u.clone(); let u_del = u.clone(); let del = delete; let set_edit = set_editing; view! {
{move || { if is_admin { view! { } .into_view() .into_any() } else { view! {}.into_any() } }} {u.name.clone()} {if is_self { view! { " (you)" } .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() }}
} }) .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>, set_dialog: WriteSignal>, ) -> 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 (busy, set_busy) = signal(false); let (error, set_error) = signal(Option::::None); let name_readonly = !is_new; // Pick a folder under the server root; "" (the root itself) is stored // as ".". let add_root = move |_| { set_dialog.set(Some(Dialog::Picker { title: i18n::t(i18n::k::ADD_FOLDER).to_string(), confirm: i18n::t(i18n::k::ADD_HERE).to_string(), roots: vec![admin_root()], root: api_types::ADMIN_ROOT, dir: String::new(), for_write: false, on_pick: Callback::new(move |(_, dir): (i64, String)| { let path = if dir.is_empty() { ".".to_string() } else { dir }; let mut v = roots.get(); if v.iter().any(|r| r.path == path) { set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string())); return; } v.push(RootDraft { path, mode: Mode::Rw, }); set_roots.set(v); 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(i18n::t(i18n::k::NAME_REQUIRED).to_string())); return; } if is_new && pw.len() < 8 { set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string())); return; } if !is_new && pw.is_empty() { // keep password unchanged } else if pw.len() < 8 { set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string())); return; } if pairs.is_empty() { set_error.set(Some(i18n::t(i18n::k::NEED_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 { i18n::t(i18n::k::USER_CREATED).to_string() } else { i18n::t(i18n::k::USER_UPDATED).to_string() }, ); on_saved2.run(existing_id); close2.run(()); } Err(e) => { show_error(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string())); set_busy.set(false); } } }); }; view! { {move || { let n0 = name.get(); view! { } }} {move || if !is_new { view! { } .into_view() .into_any() } else { view! {}.into_any() }}
{i18n::tr(i18n::k::FOLDERS)}

{i18n::tr(i18n::k::FOLDERS_PATHS_HINT)}

{move || { let set_roots_c = set_roots; let roots_c = roots; roots .get() .iter() .enumerate() .map(|(i, r)| { let rw_selected = r.mode == Mode::Rw; let ro_selected = r.mode == Mode::Ro; view! {
{r.path.clone()}
} }) .collect::>() }}
{move || match error.get() { Some(ref e) => view! {

{e.clone()}

} .into_view() .into_any(), None => { view! {}.into_any() }, }}
} }