//! Admin UI (milestone 7): user management and server settings. //! Rendered as full views from the shell's "manage" section. use crate::i18n; use leptos::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api::{self, AdminUser, Me, Mode, RoomInfo, RoomKind}; use crate::components::icon::Icon; use crate::components::modal::Modal; use crate::components::toast::{ToastMsg, show, show_error}; use crate::icons::IconName; use crate::router::{Location, Section, navigate}; use crate::views::dialogs::{Dialog, admin_root}; // --------------------------------------------------------------------------- // Settings view // --------------------------------------------------------------------------- /// The settings tabs. Every user sees Profile and Security; Server is /// admin-only. #[derive(Clone, Copy, PartialEq)] enum SettingsTab { Profile, Security, Server, } impl SettingsTab { const KEY: &'static str = "dovenest.settings-tab"; fn parse(s: &str, is_admin: bool) -> Option { match s { "server" if is_admin => Some(SettingsTab::Server), "profile" => Some(SettingsTab::Profile), "security" => Some(SettingsTab::Security), _ => None, } } const fn as_str(self) -> &'static str { match self { SettingsTab::Server => "server", SettingsTab::Security => "security", SettingsTab::Profile => "profile", } } /// The tab in the URL, else the last one used. fn current(url_tab: Option<&str>, is_admin: bool) -> Self { url_tab .and_then(|t| Self::parse(t, is_admin)) .or_else(|| Self::parse(&crate::util::storage_get(Self::KEY)?, is_admin)) // First visit: admins land on the server tab (the pre-existing // default), everyone else on their profile. .unwrap_or(if is_admin { SettingsTab::Server } else { SettingsTab::Profile }) } fn save(self) { crate::util::storage_set(Self::KEY, self.as_str()); } } /// 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 { // Built once the value loads, then only `checked` follows it: rebuilding // the input on each toggle would drop keyboard focus. view! { {i18n::tr(i18n::k::LOADING)}

} >
} } #[component] pub fn SettingsView( me: ReadSignal>, /// Its path holds the tab (`#/settings/security`). loc: 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 URL names the tab, so a link can point at one and Back switches // back. The plain `#/settings` link opens the last one used. let tab = Memo::new(move |_| { loc.with(|l| SettingsTab::current(l.path.first().map(String::as_str), is_admin())) }); let pick_tab = move |t: SettingsTab| { t.save(); if t != tab.get_untracked() { navigate(&Location { section: Section::Settings, path: vec![t.as_str().to_string()], ..Location::root() }); } }; // --- 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); let (thumbnails, set_thumbnails) = signal(Option::::None); // Default root: Some(None) = the folder overview. let (default_root, set_default_root) = signal(Option::>::None); // Without `--cache` the server cannot make thumbnails, so hide the row // rather than offer a setting that does nothing. let thumbs_available = move || me.get().is_some_and(|m| m.thumbnails_available); Effect::new(move |_| { if let Some(u) = me.get().and_then(|m| m.user) { set_single_click.set(Some(u.single_click_open)); set_thumbnails.set(Some(u.thumbnails)); set_language.set(Some(u.language.clone())); set_default_root.set(Some(u.default_root_id)); } }); // 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(th) = thumbnails.get() else { return }; let Some(lang) = language.get() else { return }; let Some(root) = default_root.get() else { return; }; set_profile_busy.set(true); spawn_local(async move { match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root)).await { Ok(m) => { show(toast, 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_me.set(Some(m)); set_single_click.set(Some(v)); set_thumbnails.set(Some(th)); set_language.set(Some(lang)); set_default_root.set(Some(root)); } Err(e) => show( toast, 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); 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_value.set(Some(s.allow_writable_shares)); set_excludes.set(s.search_excludes); } Err(e) => show_error(toast, 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); spawn_local(async move { match api::update_admin_settings(v, ex).await { Ok(_) => { show(toast, 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_me.set(Some(m)); } } Err(e) => show( toast, i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()), ), } set_busy.set(false); }); }; view! {
{[ (SettingsTab::Profile, i18n::k::PROFILE), (SettingsTab::Security, i18n::k::SECURITY), (SettingsTab::Server, i18n::k::SERVER), ] .map(|(t, label)| view! { })}
{move || match (language.get(), default_root.get()) { (Some(lang), Some(root)) => { let lang_val = lang.clone().unwrap_or_default(); let roots = me.get().map(|m| m.roots).unwrap_or_default(); view! {
{i18n::t(i18n::k::LANGUAGE)} {i18n::t(i18n::k::LANGUAGE_DESC)}
{i18n::t(i18n::k::DEFAULT_ROOT_LABEL)} {i18n::t(i18n::k::DEFAULT_ROOT_DESC)}
} .into_any() } _ => view! {

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

} .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_any(); } list.iter() .enumerate() .map(|(i, path)| { view! {
{path.clone()}
} }) .collect::>() .into_any() }}
{move || excl_error.get().map(|e| view! {

{e}

})}
} } // --------------------------------------------------------------------------- // 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 || { spawn_local(async move { match api::list_admin_users().await { Ok(u) => set_users.set(Some(u)), Err(e) => show_error(toast, 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; spawn_local(async move { if api::delete_admin_user(id).await.is_ok() { if let Some(mut cur) = users.get() { cur.retain(|x| x.id != id); set_users.set(Some(cur)); } } else { show_error(toast, 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_any(), Some(ref list) if list.is_empty() => view! {

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

} .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(); view! {
{is_admin.then(|| view! { })} {u.name.clone()} {is_self.then(|| view! { " " {i18n::tr(i18n::k::USER_YOU)} })} {(!u.active).then(|| view! { {i18n::tr(i18n::k::USER_DISABLED)} })}
} }) .collect::>(); view! {
{rows}
} } .into_any(), }} {move || editing.get().map(|e| { let existing = match e { Editing::Edit(u) => Some(u), Editing::New => None, }; view! { } })}
} } // --------------------------------------------------------------------------- // 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 |ev: web_sys::SubmitEvent| { ev.prevent_default(); 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(); // The inputs check the name and the password length. 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 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( toast, if is_new { i18n::t(i18n::k::USER_CREATED).to_string() } else { i18n::t(i18n::k::USER_UPDATED).to_string() }, ); on_saved.run(existing_id); close.run(()); } Err(e) => { show_error(toast, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string())); set_busy.set(false); } } }); }; view! {
{move || { let n0 = name.get(); view! { } }} {(!is_new) .then(|| { view! {

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

} })} {(!is_new).then(|| view! { })}
{i18n::tr(i18n::k::FOLDERS)}

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

{move || { 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 || error.get().map(|e| view! {

{e}

})}
} } // --------------------------------------------------------------------------- // Rooms and resources // --------------------------------------------------------------------------- /// Rooms and resources: principals without a sign-in that can be invited to /// meetings and answer on their own. #[component] pub fn RoomsPanel() -> impl IntoView { let toast = use_context::().expect("toast context"); let (rooms, set_rooms) = signal(Option::>::None); // `Some(None)` creates a room, `Some(Some(room))` renames one. let (editing, set_editing) = signal(Option::>::None); let (deleting, set_deleting) = signal(Option::::None); let reload = move || { spawn_local(async move { match api::list_rooms().await { Ok(r) => set_rooms.set(Some(r)), Err(e) => show_error(toast, e.to_string()), } }); }; reload(); let delete = Callback::new(move |_| { let Some(room) = deleting.get() else { return }; set_deleting.set(None); spawn_local(async move { match api::delete_room(room.id).await { Ok(_) => { show(toast, i18n::t(i18n::k::DELETED)); reload(); } Err(e) => show_error(toast, e.to_string()), } }); }); view! {

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

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

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

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

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

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

}.into_any() } Some(list) => view! {
{list .into_iter() .map(|r| { let (edit, del) = (r.clone(), r.clone()); let kind = match r.kind { RoomKind::Room => i18n::t(i18n::k::PIM_KIND_ROOM), RoomKind::Resource => i18n::t(i18n::k::PIM_KIND_RESOURCE), }; view! {
{r.display_name.clone()} {kind}
{r.name.clone()}
} }) .collect::>()}
} .into_any(), }} {move || editing.get().map(|existing| view! { })} {move || deleting.get().map(|r| view! { })}
} } #[component] fn RoomForm( existing: Option, close: Callback<()>, on_saved: Callback<()>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); let is_new = existing.is_none(); let id = existing.as_ref().map(|r| r.id); let (name, set_name) = signal( existing .as_ref() .map(|r| r.name.clone()) .unwrap_or_default(), ); let (display, set_display) = signal( existing .as_ref() .map(|r| r.display_name.clone()) .unwrap_or_default(), ); let (kind, set_kind) = signal(existing.as_ref().map_or(RoomKind::Room, |r| r.kind)); let (busy, set_busy) = signal(false); let save = move |ev: web_sys::SubmitEvent| { ev.prevent_default(); if busy.get() { return; } set_busy.set(true); let (n, d, k) = ( name.get().trim().to_string(), display.get().trim().to_string(), kind.get(), ); spawn_local(async move { let result = match id { None => api::create_room(&n, &d, k).await, Some(id) => api::update_room(id, &d).await, }; match result { Ok(_) => { show(toast, i18n::t(i18n::k::SAVED)); on_saved.run(()); close.run(()); } Err(e) => { show_error(toast, e.to_string()); set_busy.set(false); } } }); }; view! {
{is_new.then(|| view! { })}
} }