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