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