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