//! 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)}
}
>
{label}
{desc}
}
}
#[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! {
{i18n::tr(label)}
})}
{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::LANG_AUTO)}
{i18n::t(i18n::k::LANG_EN)}
{i18n::t(i18n::k::LANG_DE)}
{i18n::t(i18n::k::LANG_FR)}
{i18n::t(i18n::k::DEFAULT_ROOT_LABEL)}
{i18n::t(i18n::k::DEFAULT_ROOT_DESC)}
{i18n::t(i18n::k::DEFAULT_ROOT_NONE)}
{roots.iter().map(|r| {
let id = r.id;
view! {
{r.name.clone()}
}
}).collect::>()}
{move || if profile_busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE_PROFILE).to_string()
}}
}
.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()
}}
{i18n::tr(i18n::k::EXCLUDE_FOLDER)}
{move || excl_error.get().map(|e| view! {
{e}
})}
{move || if busy.get() {
i18n::t(i18n::k::SAVING).to_string()
} else {
i18n::t(i18n::k::SAVE_SETTINGS).to_string()
}}
}
}
// ---------------------------------------------------------------------------
// 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! {
{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)} })}
{i18n::tr(i18n::k::EDIT)}
{i18n::tr(i18n::k::DELETE)}
}
})
.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! {
}
}
// ---------------------------------------------------------------------------
// 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_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()}
{i18n::tr(i18n::k::RENAME)}
{i18n::tr(i18n::k::DELETE)}
}
})
.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! {
}
}