admin.rs
⎇
Raw
1//! Admin UI (milestone 7): user management and server settings.
2//! Rendered as full views from the shell's "manage" section.
3
4use crate::i18n;
5use crate::util::select_value;
6use leptos::prelude::*;
7use wasm_bindgen::JsCast;
8use wasm_bindgen_futures::spawn_local;
9
10use crate::api::{self, AdminUser, Me, Mode};
11use crate::components::icon::Icon;
12use crate::components::modal::Modal;
13use crate::components::toast::{ToastMsg, show, show_error};
14use crate::icons::IconName;
15use crate::views::dialogs::{Dialog, admin_root};
16
17// ---------------------------------------------------------------------------
18// Settings view
19// ---------------------------------------------------------------------------
20
21/// The settings tabs. Every user sees Profile and Security; Server is
22/// admin-only.
23#[derive(Clone, Copy, PartialEq)]
24enum SettingsTab {
25 Profile,
26 Security,
27 Server,
28}
29
30impl SettingsTab {
31 const KEY: &'static str = "fbng.settings-tab";
32
33 fn load(is_admin: bool) -> Self {
34 match crate::util::storage_get(Self::KEY).as_deref() {
35 Some("server") if is_admin => SettingsTab::Server,
36 Some("profile") => SettingsTab::Profile,
37 Some("security") => SettingsTab::Security,
38 // First visit: admins land on the server tab (the pre-existing
39 // default), everyone else on their profile.
40 _ if is_admin => SettingsTab::Server,
41 _ => SettingsTab::Profile,
42 }
43 }
44
45 fn save(self) {
46 crate::util::storage_set(
47 Self::KEY,
48 match self {
49 SettingsTab::Server => "server",
50 SettingsTab::Security => "security",
51 SettingsTab::Profile => "profile",
52 },
53 );
54 }
55}
56
57/// One labelled checkbox row for a setting.
58///
59/// The save button belongs to the tab, not the row. The server tab writes
60/// all of its settings in one PUT, so a per-row button would save the other
61/// rows' unsaved state too.
62#[component]
63fn SettingToggle(
64 label: &'static str,
65 desc: &'static str,
66 value: ReadSignal<Option<bool>>,
67 set_value: WriteSignal<Option<bool>>,
68) -> impl IntoView {
69 view! {
70 {move || match value.get() {
71 None => view! {
72 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
73 }
74 .into_view()
75 .into_any(),
76 Some(v) => view! {
77 <label class="setting-row">
78 <span>
79 <span class="setting-label">{label}</span>
80 <span class="setting-desc">{desc}</span>
81 </span>
82 <input
83 type="checkbox"
84 checked=v
85 on:change=move |ev: web_sys::Event| {
86 if let Some(t) = ev
87 .target()
88 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
89 {
90 set_value.set(Some(t.checked()));
91 }
92 }
93 />
94 </label>
95 }
96 .into_view()
97 .into_any(),
98 }}
99 }
100}
101
102#[component]
103pub fn SettingsView(
104 me: ReadSignal<Option<Me>>,
105 set_me: WriteSignal<Option<Me>>,
106 /// The folder picker for the search exclusions is rendered at the shell
107 /// level, like every other dialog.
108 set_dialog: WriteSignal<Option<Dialog>>,
109) -> impl IntoView {
110 let toast = use_context::<ToastMsg>().expect("toast context");
111 let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin);
112 // The shell re-creates this view when /me changes (saving resets it), so
113 // the active tab is remembered in localStorage like the view mode.
114 let (tab, set_tab) = signal(SettingsTab::load(is_admin()));
115 let pick_tab = move |t: SettingsTab| {
116 t.save();
117 set_tab.set(t);
118 };
119
120 // --- profile: single-click-to-open ------------------------------------
121 let (single_click, set_single_click) = signal(Option::<bool>::None);
122 let (profile_busy, set_profile_busy) = signal(false);
123 // Profile language: Some(None) = auto (follow the browser).
124 let (language, set_language) = signal(Option::<Option<String>>::None);
125 let (thumbnails, set_thumbnails) = signal(Option::<bool>::None);
126 // Default root: Some(None) = the folder overview.
127 let (default_root, set_default_root) = signal(Option::<Option<i64>>::None);
128 // Without `--cache` the server cannot make thumbnails, so hide the row
129 // rather than offer a setting that does nothing.
130 let thumbs_available = move || me.get().is_some_and(|m| m.thumbnails_available);
131 Effect::new(move |_| {
132 if let Some(u) = me.get().and_then(|m| m.user) {
133 set_single_click.set(Some(u.single_click_open));
134 set_thumbnails.set(Some(u.thumbnails));
135 set_language.set(Some(u.language.clone()));
136 set_default_root.set(Some(u.default_root_id));
137 }
138 });
139 // Saves both profile settings in one call.
140 let save_profile = Callback::new(move |_| {
141 if profile_busy.get() {
142 return;
143 }
144 let Some(v) = single_click.get() else { return };
145 let Some(th) = thumbnails.get() else { return };
146 let Some(lang) = language.get() else { return };
147 let Some(root) = default_root.get() else {
148 return;
149 };
150 set_profile_busy.set(true);
151 let toast2 = toast;
152 let set_me2 = set_me;
153 let set_val = set_single_click;
154 let set_th = set_thumbnails;
155 let set_lang = set_language;
156 let set_root = set_default_root;
157 spawn_local(async move {
158 match api::update_profile(Some(v), Some(th), Some(lang.clone()), Some(root)).await {
159 Ok(m) => {
160 show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string());
161 // The response already carries the fresh /me (which also
162 // flips the UI language via the app-level effect).
163 set_me2.set(Some(m));
164 set_val.set(Some(v));
165 set_th.set(Some(th));
166 set_lang.set(Some(lang));
167 set_root.set(Some(root));
168 }
169 Err(e) => show(
170 toast2,
171 i18n::t_fmt(i18n::k::PROFILE_SAVE_ERR, &e.to_string()),
172 ),
173 }
174 set_profile_busy.set(false);
175 });
176 });
177
178 // --- server settings (admin only) --------------------------------------
179 let (value, set_value) = signal(Option::<bool>::None);
180 let (excludes, set_excludes) = signal(Vec::<String>::new());
181 let (excl_error, set_excl_error) = signal(Option::<String>::None);
182 let (busy, set_busy) = signal(false);
183 {
184 let set = set_value;
185 let set_ex = set_excludes;
186 let toast2 = toast;
187 spawn_local(async move {
188 // Non-admins don't have a Server tab; don't even probe the
189 // endpoint (it would 403 and toast).
190 if !is_admin() {
191 return;
192 }
193 match api::get_admin_settings().await {
194 Ok(s) => {
195 set.set(Some(s.allow_writable_shares));
196 set_ex.set(s.search_excludes);
197 }
198 Err(e) => show_error(toast2, e.to_string()),
199 }
200 });
201 }
202 // Opens the same folder picker the user editor uses, over the whole
203 // server root (an exclusion is not tied to any one user's folders).
204 let add_exclude = move |_| {
205 set_excl_error.set(None);
206 set_dialog.set(Some(Dialog::Picker {
207 title: i18n::t(i18n::k::EXCLUDE_FOLDER).to_string(),
208 confirm: i18n::t(i18n::k::EXCLUDE_HERE).to_string(),
209 roots: vec![admin_root()],
210 root: api_types::ADMIN_ROOT,
211 dir: String::new(),
212 for_write: false,
213 on_pick: Callback::new(move |(_, dir): (i64, String)| {
214 // An empty dir is the server root. Excluding it would
215 // switch search off altogether.
216 if dir.is_empty() {
217 set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_ROOT_ERR).to_string()));
218 return;
219 }
220 let mut v = excludes.get();
221 if v.contains(&dir) {
222 set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_DUP_ERR).to_string()));
223 return;
224 }
225 v.push(dir);
226 set_excludes.set(v);
227 set_excl_error.set(None);
228 }),
229 }));
230 };
231 let save = move |_| {
232 if busy.get() {
233 return;
234 }
235 let Some(v) = value.get() else { return };
236 let ex = excludes.get();
237 set_busy.set(true);
238 let toast2 = toast;
239 let set_me2 = set_me;
240 spawn_local(async move {
241 match api::update_admin_settings(v, ex).await {
242 Ok(_) => {
243 show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
244 // Re-fetch this session's /me so the share dialog (and
245 // anything else) sees the new setting without a reload.
246 if let Ok(m) = api::me().await {
247 set_me2.set(Some(m));
248 }
249 }
250 Err(e) => show(
251 toast2,
252 i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()),
253 ),
254 }
255 set_busy.set(false);
256 });
257 };
258
259 view! {
260 <div class="admin-view">
261 <div class="settings-tabs">
262 <button
263 class="settings-tab-btn"
264 class:active=move || tab.get() == SettingsTab::Profile
265 on:click=move |_| pick_tab(SettingsTab::Profile)
266 >
267 {i18n::tr(i18n::k::PROFILE)}
268 </button>
269 <button
270 class="settings-tab-btn"
271 class:active=move || tab.get() == SettingsTab::Security
272 on:click=move |_| pick_tab(SettingsTab::Security)
273 >
274 {i18n::tr(i18n::k::SECURITY)}
275 </button>
276 <Show when=move || is_admin()>
277 <button
278 class="settings-tab-btn"
279 class:active=move || tab.get() == SettingsTab::Server
280 on:click=move |_| pick_tab(SettingsTab::Server)
281 >
282 {i18n::tr(i18n::k::SERVER)}
283 </button>
284 </Show>
285 </div>
286 <Show when=move || tab.get() == SettingsTab::Profile>
287 {move || match (single_click.get(), language.get(), default_root.get()) {
288 (Some(sc), Some(lang), Some(root)) => {
289 let lang_val = lang.clone().unwrap_or_default();
290 let roots = me.get().map(|m| m.roots).unwrap_or_default();
291 view! {
292 <label class="setting-row">
293 <span>
294 <span class="setting-label">{i18n::t(i18n::k::SINGLE_CLICK_LABEL)}</span>
295 <span class="setting-desc">{i18n::t(i18n::k::SINGLE_CLICK_DESC)}</span>
296 </span>
297 <input
298 type="checkbox"
299 checked=sc
300 on:change=move |ev: web_sys::Event| {
301 if let Some(t) = ev
302 .target()
303 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
304 {
305 set_single_click.set(Some(t.checked()));
306 }
307 }
308 />
309 </label>
310 <Show when=thumbs_available>
311 <label class="setting-row">
312 <span>
313 <span class="setting-label">{i18n::t(i18n::k::THUMBNAILS_LABEL)}</span>
314 <span class="setting-desc">{i18n::t(i18n::k::THUMBNAILS_DESC)}</span>
315 </span>
316 <input
317 type="checkbox"
318 checked=move || thumbnails.get().unwrap_or(false)
319 on:change=move |ev: web_sys::Event| {
320 if let Some(t) = ev
321 .target()
322 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
323 {
324 set_thumbnails.set(Some(t.checked()));
325 }
326 }
327 />
328 </label>
329 </Show>
330 <div class="setting-row setting-row-select">
331 <span>
332 <span class="setting-label">{i18n::t(i18n::k::LANGUAGE)}</span>
333 <span class="setting-desc">{i18n::t(i18n::k::LANGUAGE_DESC)}</span>
334 </span>
335 <select
336 on:change=move |ev: web_sys::Event| {
337 let Some(tag) = select_value(&ev) else {
338 return;
339 };
340 set_language.set(Some(if tag.is_empty() {
341 None
342 } else {
343 Some(tag)
344 }));
345 }
346 >
347 <option value="" selected=lang_val.is_empty()>{i18n::t(i18n::k::LANG_AUTO)}</option>
348 <option value="en" selected=lang_val == "en">{i18n::t(i18n::k::LANG_EN)}</option>
349 <option value="de" selected=lang_val == "de">{i18n::t(i18n::k::LANG_DE)}</option>
350 <option value="fr" selected=lang_val == "fr">{i18n::t(i18n::k::LANG_FR)}</option>
351 </select>
352 </div>
353 <div class="setting-row setting-row-select">
354 <span>
355 <span class="setting-label">{i18n::t(i18n::k::DEFAULT_ROOT_LABEL)}</span>
356 <span class="setting-desc">{i18n::t(i18n::k::DEFAULT_ROOT_DESC)}</span>
357 </span>
358 <select
359 on:change=move |ev: web_sys::Event| {
360 let Some(v) = select_value(&ev) else {
361 return;
362 };
363 set_default_root.set(Some(v.parse().ok()));
364 }
365 >
366 <option value="" selected=root.is_none()>{i18n::t(i18n::k::DEFAULT_ROOT_NONE)}</option>
367 {roots.iter().map(|r| {
368 let id = r.id;
369 view! {
370 <option value=id.to_string() selected=root == Some(id)>{r.name.clone()}</option>
371 }
372 }).collect::<Vec<_>>()}
373 </select>
374 </div>
375 <div class="modal-actions">
376 <button
377 class="btn btn-primary"
378 disabled=move || profile_busy.get()
379 on:click=move |_| save_profile.run(())
380 >
381 {move || if profile_busy.get() {
382 i18n::t(i18n::k::SAVING).to_string()
383 } else {
384 i18n::t(i18n::k::SAVE_PROFILE).to_string()
385 }}
386 </button>
387 </div>
388 }
389 .into_view()
390 .into_any()
391 }
392 _ => view! {
393 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
394 }
395 .into_view()
396 .into_any(),
397 }}
398 </Show>
399 <Show when=move || tab.get() == SettingsTab::Security>
400 <crate::views::security::SecurityView me=me set_me=set_me/>
401 </Show>
402 <Show when=move || tab.get() == SettingsTab::Server && is_admin()>
403 <SettingToggle
404 label=i18n::t(i18n::k::ALLOW_RW_SHARES)
405 desc=i18n::t(i18n::k::ALLOW_RW_SHARES_DESC)
406 value=value
407 set_value=set_value
408 />
409 <div class="excludes-field">
410 <span class="setting-label">{i18n::tr(i18n::k::SEARCH_EXCLUDES)}</span>
411 <div class="excludes-list">
412 {move || {
413 let list = excludes.get();
414 if list.is_empty() {
415 return view! {
416 <p class="muted excludes-empty">
417 {i18n::tr(i18n::k::SEARCH_EXCLUDES_EMPTY)}
418 </p>
419 }
420 .into_view()
421 .into_any();
422 }
423 list.iter()
424 .enumerate()
425 .map(|(i, path)| {
426 view! {
427 <div class="exclude-row">
428 <Icon name=IconName::Folder class="ic-row".to_string()/>
429 <span class="exclude-path">{path.clone()}</span>
430 <button
431 class="icon-btn exclude-remove"
432 title=i18n::t(i18n::k::REMOVE_FOLDER)
433 aria-label=i18n::t(i18n::k::REMOVE_FOLDER)
434 on:click=move |_| {
435 let mut v = excludes.get();
436 if i < v.len() {
437 v.remove(i);
438 }
439 set_excludes.set(v);
440 set_excl_error.set(None);
441 }
442 >
443 <Icon name=IconName::Close class="ic-btn".to_string()/>
444 </button>
445 </div>
446 }
447 })
448 .collect::<Vec<_>>()
449 .into_view()
450 .into_any()
451 }}
452 </div>
453 <button class="btn btn-sm exclude-add" on:click=add_exclude>
454 <Icon name=IconName::Add class="ic-btn".to_string()/>
455 {i18n::tr(i18n::k::EXCLUDE_FOLDER)}
456 </button>
457 {move || {
458 let Some(e) = excl_error.get() else {
459 return view! {}.into_any();
460 };
461 view! { <p class="dialog-error">{e}</p> }.into_view().into_any()
462 }}
463 </div>
464 <div class="modal-actions">
465 <button
466 class="btn btn-primary"
467 disabled=move || busy.get() || value.get().is_none()
468 on:click=move |_| save(())
469 >
470 {move || if busy.get() {
471 i18n::t(i18n::k::SAVING).to_string()
472 } else {
473 i18n::t(i18n::k::SAVE_SETTINGS).to_string()
474 }}
475 </button>
476 </div>
477 </Show>
478 </div>
479 }
480}
481
482// ---------------------------------------------------------------------------
483// Users view
484// ---------------------------------------------------------------------------
485
486/// What the user form is editing: a brand-new user, or an existing one.
487#[derive(Clone)]
488enum Editing {
489 New,
490 Edit(AdminUser),
491}
492
493#[component]
494pub fn UsersView(
495 me: ReadSignal<Option<Me>>,
496 set_me: WriteSignal<Option<Me>>,
497 set_dialog: WriteSignal<Option<Dialog>>,
498) -> impl IntoView {
499 let toast = use_context::<ToastMsg>().expect("toast context");
500 let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
501 let (editing, set_editing) = signal(Option::<Editing>::None);
502
503 let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
504
505 let reload = move || {
506 let set = set_users;
507 let toast2 = toast;
508 spawn_local(async move {
509 match api::list_admin_users().await {
510 Ok(u) => set.set(Some(u)),
511 Err(e) => show_error(toast2, e.to_string()),
512 }
513 });
514 };
515 reload();
516
517 // After a save: reload the list, and — if the saved user is the current
518 // one — refresh /me so the sidebar's folders update in place.
519 let on_saved = Callback::new(move |saved: Option<i64>| {
520 reload();
521 if saved.is_some_and(|id| self_id() == Some(id)) {
522 crate::views::shell::refresh_me(set_me, toast);
523 }
524 });
525
526 let delete = move |u: AdminUser| {
527 let is_self = self_id() == Some(u.id);
528 if is_self {
529 return;
530 }
531 let Some(win) = web_sys::window() else { return };
532 let ok = win
533 .confirm_with_message(&i18n::t_fmt(i18n::k::DELETE_USER_CONFIRM, &u.name))
534 .unwrap_or(false);
535 if !ok {
536 return;
537 }
538 let id = u.id;
539 let read = users;
540 let write = set_users;
541 let toast2 = toast;
542 spawn_local(async move {
543 if api::delete_admin_user(id).await.is_ok() {
544 if let Some(mut cur) = read.get() {
545 cur.retain(|x| x.id != id);
546 write.set(Some(cur));
547 }
548 } else {
549 show_error(toast2, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
550 }
551 });
552 };
553
554 view! {
555 <div class="users-tab">
556 <div class="users-header">
557 <h3 class="admin-section-title">{i18n::tr(i18n::k::USERS)}</h3>
558 <button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
559 <Icon name=IconName::Add class="ic-btn".to_string()/>
560 {i18n::tr(i18n::k::NEW_USER)}
561 </button>
562 </div>
563 {move || match users.get() {
564 None => view! {
565 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
566 }
567 .into_view()
568 .into_any(),
569 Some(ref list) if list.is_empty() => view! {
570 <p class="muted">{i18n::tr(i18n::k::NO_USERS)}</p>
571 }
572 .into_view()
573 .into_any(),
574 Some(ref list) => {
575 let sid = self_id();
576 let rows = list
577 .iter()
578 .map(|u| {
579 let is_self = sid == Some(u.id);
580 let is_admin = u.is_admin;
581 let u_edit = u.clone();
582 let u_del = u.clone();
583 let del = delete;
584 let set_edit = set_editing;
585 view! {
586 <div class="user-row">
587 <div class="user-row-main">
588 <div class="user-row-name">
589 {move || {
590 if is_admin {
591 view! { <span class="ic-admin" title="Administrator"><Icon name=IconName::Admin/></span> }
592 .into_view()
593 .into_any()
594 } else {
595 view! {}.into_any()
596 }
597 }}
598 {u.name.clone()}
599 {if is_self {
600 view! { <span class="muted">" (you)"</span> }
601 .into_view()
602 .into_any()
603 } else {
604 {
605 view! {}.into_view()
606 }.into_any()
607 }}
608 {if !u.active {
609 view! { <span class="badge badge-off">"disabled"</span> }
610 .into_view()
611 .into_any()
612 } else {
613 {
614 view! {}.into_view()
615 }.into_any()
616 }}
617 </div>
618 </div>
619 <div class="user-row-actions">
620 <button
621 class="btn btn-sm"
622 on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
623 >
624 {i18n::tr(i18n::k::EDIT)}
625 </button>
626 <button
627 class="btn btn-sm btn-danger"
628 disabled=is_self
629 title=if is_self {
630 i18n::tr(i18n::k::CANT_DELETE_SELF)
631 } else {
632 i18n::tr(i18n::k::DELETE_USER)
633 }
634 on:click=move |_| del(u_del.clone())
635 >
636 {i18n::tr(i18n::k::DELETE)}
637 </button>
638 </div>
639 </div>
640 }
641 })
642 .collect::<Vec<_>>();
643 view! {
644 <div class="user-list">{rows}</div>
645 }
646 }
647 .into_view()
648 .into_any(),
649 }}
650 {move || match editing.get() {
651 Some(ref e) => {
652 let existing = match e {
653 Editing::Edit(u) => Some(u.clone()),
654 Editing::New => None,
655 };
656 let sid = self_id();
657 let set = set_editing;
658 view! {
659 <UserForm
660 existing=existing
661 self_id=sid
662 close=Callback::new(move |_| set.set(None))
663 on_saved=on_saved
664 set_dialog=set_dialog
665 />
666 }
667 .into_view()
668 .into_any()
669 }
670 None => {
671 view! {}.into_any()
672 },
673 }}
674 </div>
675 }
676}
677
678// ---------------------------------------------------------------------------
679// User create / edit form
680// ---------------------------------------------------------------------------
681
682#[derive(Clone)]
683struct RootDraft {
684 path: String,
685 mode: Mode,
686}
687
688#[component]
689fn UserForm(
690 existing: Option<AdminUser>,
691 self_id: Option<i64>,
692 close: Callback<()>,
693 on_saved: Callback<Option<i64>>,
694 set_dialog: WriteSignal<Option<Dialog>>,
695) -> impl IntoView {
696 let toast = use_context::<ToastMsg>().expect("toast context");
697 let is_new = existing.is_none();
698 let is_self = existing
699 .as_ref()
700 .map(|u| self_id == Some(u.id))
701 .unwrap_or(false);
702 let original_name = existing
703 .as_ref()
704 .map(|u| u.name.clone())
705 .unwrap_or_default();
706
707 let (name, set_name) = signal(
708 existing
709 .as_ref()
710 .map(|u| u.name.clone())
711 .unwrap_or_default(),
712 );
713 let (password, set_password) = signal(String::new());
714 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
715 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
716 let mut roots0: Vec<RootDraft> = existing
717 .as_ref()
718 .map(|u| {
719 u.roots
720 .iter()
721 .map(|r| RootDraft {
722 path: r.path.clone(),
723 mode: r.mode,
724 })
725 .collect()
726 })
727 .unwrap_or_default();
728 if is_new && roots0.is_empty() {
729 roots0.push(RootDraft {
730 path: ".".to_string(),
731 mode: Mode::Rw,
732 });
733 }
734 let (roots, set_roots) = signal(roots0);
735 let (busy, set_busy) = signal(false);
736 let (error, set_error) = signal(Option::<String>::None);
737 let name_readonly = !is_new;
738
739 // Pick a folder under the server root; "" (the root itself) is stored
740 // as ".".
741 let add_root = move |_| {
742 set_dialog.set(Some(Dialog::Picker {
743 title: i18n::t(i18n::k::ADD_FOLDER).to_string(),
744 confirm: i18n::t(i18n::k::ADD_HERE).to_string(),
745 roots: vec![admin_root()],
746 root: api_types::ADMIN_ROOT,
747 dir: String::new(),
748 for_write: false,
749 on_pick: Callback::new(move |(_, dir): (i64, String)| {
750 let path = if dir.is_empty() { ".".to_string() } else { dir };
751 let mut v = roots.get();
752 if v.iter().any(|r| r.path == path) {
753 set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string()));
754 return;
755 }
756 v.push(RootDraft {
757 path,
758 mode: Mode::Rw,
759 });
760 set_roots.set(v);
761 set_error.set(None);
762 }),
763 }));
764 };
765
766 let save = move |_| {
767 if busy.get() {
768 return;
769 }
770 let n = name.get().trim().to_string();
771 let pw = password.get();
772 let adm = is_admin.get();
773 let act = active.get();
774 let pairs: Vec<(String, Mode)> = roots
775 .get()
776 .iter()
777 .map(|r| (r.path.clone(), r.mode))
778 .collect();
779 // Client-side validation.
780 if n.is_empty() {
781 set_error.set(Some(i18n::t(i18n::k::NAME_REQUIRED).to_string()));
782 return;
783 }
784 if is_new && pw.len() < 8 {
785 set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
786 return;
787 }
788 if !is_new && pw.is_empty() {
789 // keep password unchanged
790 } else if pw.len() < 8 {
791 set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
792 return;
793 }
794 if pairs.is_empty() {
795 set_error.set(Some(i18n::t(i18n::k::NEED_FOLDER).to_string()));
796 return;
797 }
798
799 set_busy.set(true);
800 set_error.set(None);
801 let toast2 = toast;
802 let on_saved2 = on_saved;
803 let close2 = close;
804 let existing_id = existing.as_ref().map(|u| u.id);
805 spawn_local(async move {
806 let result = if is_new {
807 api::create_admin_user(&n, &pw, adm, &pairs).await
808 } else {
809 let id = existing_id.unwrap_or(0);
810 let pw_opt = if pw.is_empty() {
811 None
812 } else {
813 Some(pw.clone())
814 };
815 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
816 };
817 match result {
818 Ok(_) => {
819 show(
820 toast2,
821 if is_new {
822 i18n::t(i18n::k::USER_CREATED).to_string()
823 } else {
824 i18n::t(i18n::k::USER_UPDATED).to_string()
825 },
826 );
827 on_saved2.run(existing_id);
828 close2.run(());
829 }
830 Err(e) => {
831 show_error(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
832 set_busy.set(false);
833 }
834 }
835 });
836 };
837
838 view! {
839 <Modal class="user-form" on_close=close>
840 <h2 class="modal-title">{move || {
841 if is_new {
842 i18n::t(i18n::k::NEW_USER).to_string()
843 } else {
844 i18n::t_fmt(i18n::k::EDIT_USER_TITLE, &original_name)
845 }
846 }}</h2>
847 {move || {
848 let n0 = name.get();
849 view! {
850 <label class="field">
851 <span class="field-label">{i18n::tr(i18n::k::NAME)}</span>
852 <input
853 class="field-input"
854 value=n0.clone()
855 disabled=name_readonly
856 placeholder="e.g. alice"
857 on:input=move |ev: web_sys::Event| {
858 if let Some(t) = ev
859 .target()
860 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
861 {
862 set_name.set(t.value());
863 }
864 }
865 />
866 </label>
867 }
868 }}
869 <label class="field">
870 <span class="field-label">
871 {if is_new {
872 i18n::t(i18n::k::PASSWORD)
873 } else {
874 i18n::t(i18n::k::NEW_PASSWORD_KEEP)
875 }}
876 </span>
877 <input
878 class="field-input"
879 type="password"
880 value=move || password.get()
881 placeholder="at least 8 characters"
882 on:input=move |ev: web_sys::Event| {
883 if let Some(t) = ev
884 .target()
885 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
886 {
887 set_password.set(t.value());
888 }
889 }
890 />
891 </label>
892 {(!is_new)
893 .then(|| {
894 view! {
895 <p class="setting-desc">{i18n::tr(i18n::k::PASSWORD_RESETS_SIGNIN)}</p>
896 }
897 })}
898 <label class="check-row">
899 <input
900 type="checkbox"
901 checked=move || is_admin.get()
902 disabled=is_self
903 on:change=move |ev: web_sys::Event| {
904 if let Some(t) = ev
905 .target()
906 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
907 {
908 set_is_admin.set(t.checked());
909 }
910 }
911 />
912 {i18n::tr(i18n::k::ADMINISTRATOR)}
913 </label>
914 {move || if !is_new {
915 view! {
916 <label class="check-row">
917 <input
918 type="checkbox"
919 checked=move || active.get()
920 disabled=is_self
921 on:change=move |ev: web_sys::Event| {
922 if let Some(t) = ev
923 .target()
924 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
925 {
926 set_active.set(t.checked());
927 }
928 }
929 />
930 {i18n::tr(i18n::k::ACTIVE_CAN_SIGNIN)}
931 </label>
932 }
933 .into_view()
934 .into_any()
935 } else {
936 view! {}.into_any()
937 }}
938 <div class="field">
939 <span class="field-label">{i18n::tr(i18n::k::FOLDERS)}</span>
940 <p class="muted field-hint">{i18n::tr(i18n::k::FOLDERS_PATHS_HINT)}</p>
941 <div class="roots-editor">
942 {move || {
943 let set_roots_c = set_roots;
944 let roots_c = roots;
945 roots
946 .get()
947 .iter()
948 .enumerate()
949 .map(|(i, r)| {
950 let rw_selected = r.mode == Mode::Rw;
951 let ro_selected = r.mode == Mode::Ro;
952 view! {
953 <div class="root-draft">
954 <span class="root-draft-path">{r.path.clone()}</span>
955 <select
956 class="root-draft-mode"
957 on:change=move |ev: web_sys::Event| {
958 if let Some(t) = ev
959 .target()
960 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
961 {
962 let mut v = roots_c.get();
963 if i < v.len()
964 && let Some(m) = Mode::from_wire(
965 &t.value(),
966 )
967 {
968 v[i].mode = m;
969 }
970 set_roots_c.set(v);
971 }
972 }
973 >
974 <option value=Mode::Rw.as_str() selected=rw_selected>{i18n::t(i18n::k::MODE_RW)}</option>
975 <option value=Mode::Ro.as_str() selected=ro_selected>{i18n::t(i18n::k::MODE_RO)}</option>
976 </select>
977 <button
978 class="icon-btn icon-btn-sm"
979 title={i18n::tr(i18n::k::REMOVE_FOLDER)}
980 on:click=move |_| {
981 let mut v = roots_c.get();
982 if i < v.len() {
983 v.remove(i);
984 }
985 set_roots_c.set(v);
986 }
987 >
988 <Icon name=IconName::Close class="ic-btn".to_string()/>
989 </button>
990 </div>
991 }
992 })
993 .collect::<Vec<_>>()
994 }}
995 <button class="btn root-add" on:click=add_root>
996 <Icon name=IconName::Folder class="ic-btn".to_string()/>
997 {i18n::tr(i18n::k::ADD_FOLDER)}
998 </button>
999 </div>
1000 </div>
1001 {move || match error.get() {
1002 Some(ref e) => view! {
1003 <p class="form-error">{e.clone()}</p>
1004 }
1005 .into_view()
1006 .into_any(),
1007 None => {
1008 view! {}.into_any()
1009 },
1010 }}
1011 <div class="modal-actions">
1012 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
1013 <button
1014 class="btn btn-primary"
1015 disabled=move || busy.get()
1016 on:click=save
1017 >
1018 {move || {
1019 if busy.get() {
1020 i18n::t(i18n::k::SAVING).to_string()
1021 } else if is_new {
1022 i18n::t(i18n::k::CREATE_USER).to_string()
1023 } else {
1024 i18n::t(i18n::k::SAVE).to_string()
1025 }
1026 }}
1027 </button>
1028 </div>
1029 </Modal>
1030 }
1031}
1032