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