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