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