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 leptos::prelude::*;
6use wasm_bindgen::JsCast;
7
8/// The value of a `<select>` behind an event, when the target is one.
9fn select_value(ev: &web_sys::Event) -> Option<String> {
10 ev.target()
11 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
12 .map(|s| s.value())
13}
14use wasm_bindgen_futures::spawn_local;
15
16use crate::api::{self, AdminUser, Me, Mode};
17use crate::components::icon::Icon;
18use crate::components::toast::{ToastMsg, show};
19use crate::icons::IconName;
20
21// ---------------------------------------------------------------------------
22// Settings view
23// ---------------------------------------------------------------------------
24
25/// The two settings tabs. Every user sees Profile; Server is admin-only.
26#[derive(Clone, Copy, PartialEq)]
27enum SettingsTab {
28 Profile,
29 Server,
30}
31
32impl SettingsTab {
33 const KEY: &'static str = "fbng.settings-tab";
34
35 fn load(is_admin: bool) -> Self {
36 match crate::util::storage_get(Self::KEY).as_deref() {
37 Some("server") if is_admin => SettingsTab::Server,
38 Some("profile") => SettingsTab::Profile,
39 // First visit: admins land on the server tab (the pre-existing
40 // default), everyone else on their profile.
41 _ if is_admin => SettingsTab::Server,
42 _ => SettingsTab::Profile,
43 }
44 }
45
46 fn save(self) {
47 crate::util::storage_set(
48 Self::KEY,
49 match self {
50 SettingsTab::Server => "server",
51 SettingsTab::Profile => "profile",
52 },
53 );
54 }
55}
56
57/// One "setting" tab body: a checkbox with description and a busy save
58/// button. Both tabs (profile, server) share exactly this shape, so the
59/// markup lives here once.
60#[component]
61fn SettingToggle(
62 label: &'static str,
63 desc: &'static str,
64 value: ReadSignal<Option<bool>>,
65 set_value: WriteSignal<Option<bool>>,
66 busy: ReadSignal<bool>,
67 save: Callback<()>,
68 save_label: &'static str,
69) -> impl IntoView {
70 view! {
71 {move || match value.get() {
72 None => view! {
73 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
74 }
75 .into_view()
76 .into_any(),
77 Some(v) => view! {
78 <label class="setting-row">
79 <span>
80 <span class="setting-label">{label}</span>
81 <span class="setting-desc">{desc}</span>
82 </span>
83 <input
84 type="checkbox"
85 checked=v
86 on:change=move |ev: web_sys::Event| {
87 if let Some(t) = ev
88 .target()
89 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
90 {
91 set_value.set(Some(t.checked()));
92 }
93 }
94 />
95 </label>
96 <div class="modal-actions">
97 <button
98 class="btn btn-primary"
99 disabled=move || busy.get()
100 on:click=move |_| save.run(())
101 >
102 {move || if busy.get() { i18n::t(i18n::k::SAVING).to_string() } else { save_label.to_string() }}
103 </button>
104 </div>
105 }
106 .into_view()
107 .into_any(),
108 }}
109 }
110}
111
112#[component]
113pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
114 let toast = use_context::<ToastMsg>().expect("toast context");
115 let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin);
116 // The shell re-creates this view when /me changes (saving resets it), so
117 // the active tab is remembered in localStorage like the view mode.
118 let (tab, set_tab) = signal(SettingsTab::load(is_admin()));
119 let pick_tab = move |t: SettingsTab| {
120 t.save();
121 set_tab.set(t);
122 };
123
124 // --- profile: single-click-to-open ------------------------------------
125 let (single_click, set_single_click) = signal(Option::<bool>::None);
126 let (profile_busy, set_profile_busy) = signal(false);
127 // Profile language: Some(None) = auto (follow the browser).
128 let (language, set_language) = signal(Option::<Option<String>>::None);
129 Effect::new(move |_| {
130 if let Some(u) = me.get().and_then(|m| m.user) {
131 set_single_click.set(Some(u.single_click_open));
132 set_language.set(Some(u.language.clone()));
133 }
134 });
135 // Saves both profile settings in one call.
136 let save_profile = Callback::new(move |_| {
137 if profile_busy.get() {
138 return;
139 }
140 let Some(v) = single_click.get() else { return };
141 let Some(lang) = language.get() else { return };
142 set_profile_busy.set(true);
143 let toast2 = toast;
144 let set_me2 = set_me;
145 let set_val = set_single_click;
146 let set_lang = set_language;
147 spawn_local(async move {
148 match api::update_profile(Some(v), Some(lang.clone())).await {
149 Ok(m) => {
150 show(toast2, i18n::t(i18n::k::PROFILE_SAVED).to_string());
151 // The response already carries the fresh /me (which also
152 // flips the UI language via the app-level effect).
153 set_me2.set(Some(m));
154 set_val.set(Some(v));
155 set_lang.set(Some(lang));
156 }
157 Err(e) => show(
158 toast2,
159 i18n::t_fmt(i18n::k::PROFILE_SAVE_ERR, &e.to_string()),
160 ),
161 }
162 set_profile_busy.set(false);
163 });
164 });
165
166 // --- server: allow writable shares (admin only) ------------------------
167 let (value, set_value) = signal(Option::<bool>::None);
168 let (busy, set_busy) = signal(false);
169 {
170 let set = set_value;
171 let toast2 = toast;
172 spawn_local(async move {
173 // Non-admins don't have a Server tab; don't even probe the
174 // endpoint (it would 403 and toast).
175 if !is_admin() {
176 return;
177 }
178 match api::get_admin_settings().await {
179 Ok(s) => set.set(Some(s.allow_writable_shares)),
180 Err(e) => show(toast2, e.to_string()),
181 }
182 });
183 }
184 let save = move |_| {
185 if busy.get() {
186 return;
187 }
188 let Some(v) = value.get() else { return };
189 set_busy.set(true);
190 let toast2 = toast;
191 let set_me2 = set_me;
192 spawn_local(async move {
193 match api::update_admin_settings(v).await {
194 Ok(_) => {
195 show(toast2, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
196 // Re-fetch this session's /me so the share dialog (and
197 // anything else) sees the new setting without a reload.
198 if let Ok(m) = api::me().await {
199 set_me2.set(Some(m));
200 }
201 }
202 Err(e) => show(
203 toast2,
204 i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()),
205 ),
206 }
207 set_busy.set(false);
208 });
209 };
210
211 view! {
212 <div class="admin-view">
213 <div class="settings-tabs">
214 <button
215 class="settings-tab-btn"
216 class:active=move || tab.get() == SettingsTab::Profile
217 on:click=move |_| pick_tab(SettingsTab::Profile)
218 >
219 {i18n::tr(i18n::k::PROFILE)}
220 </button>
221 <Show when=move || is_admin()>
222 <button
223 class="settings-tab-btn"
224 class:active=move || tab.get() == SettingsTab::Server
225 on:click=move |_| pick_tab(SettingsTab::Server)
226 >
227 {i18n::tr(i18n::k::SERVER)}
228 </button>
229 </Show>
230 </div>
231 <Show when=move || tab.get() == SettingsTab::Profile>
232 {move || match (single_click.get(), language.get()) {
233 (Some(sc), Some(lang)) => {
234 let lang_val = lang.clone().unwrap_or_default();
235 view! {
236 <label class="setting-row">
237 <span>
238 <span class="setting-label">{i18n::t(i18n::k::SINGLE_CLICK_LABEL)}</span>
239 <span class="setting-desc">{i18n::t(i18n::k::SINGLE_CLICK_DESC)}</span>
240 </span>
241 <input
242 type="checkbox"
243 checked=sc
244 on:change=move |ev: web_sys::Event| {
245 if let Some(t) = ev
246 .target()
247 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
248 {
249 set_single_click.set(Some(t.checked()));
250 }
251 }
252 />
253 </label>
254 <div class="setting-row setting-row-select">
255 <span>
256 <span class="setting-label">{i18n::t(i18n::k::LANGUAGE)}</span>
257 <span class="setting-desc">{i18n::t(i18n::k::LANGUAGE_DESC)}</span>
258 </span>
259 <select
260 on:change=move |ev: web_sys::Event| {
261 let Some(tag) = select_value(&ev) else {
262 return;
263 };
264 set_language.set(Some(if tag.is_empty() {
265 None
266 } else {
267 Some(tag)
268 }));
269 }
270 >
271 <option value="" selected=lang_val.is_empty()>{i18n::t(i18n::k::LANG_AUTO)}</option>
272 <option value="en" selected=lang_val == "en">{i18n::t(i18n::k::LANG_EN)}</option>
273 <option value="de" selected=lang_val == "de">{i18n::t(i18n::k::LANG_DE)}</option>
274 <option value="fr" selected=lang_val == "fr">{i18n::t(i18n::k::LANG_FR)}</option>
275 </select>
276 </div>
277 <div class="modal-actions">
278 <button
279 class="btn btn-primary"
280 disabled=move || profile_busy.get()
281 on:click=move |_| save_profile.run(())
282 >
283 {move || if profile_busy.get() {
284 i18n::t(i18n::k::SAVING).to_string()
285 } else {
286 i18n::t(i18n::k::SAVE_PROFILE).to_string()
287 }}
288 </button>
289 </div>
290 }
291 .into_view()
292 .into_any()
293 }
294 _ => view! {
295 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
296 }
297 .into_view()
298 .into_any(),
299 }}
300 </Show>
301 <Show when=move || tab.get() == SettingsTab::Server && is_admin()>
302 <SettingToggle
303 label=i18n::t(i18n::k::ALLOW_RW_SHARES)
304 desc=i18n::t(i18n::k::ALLOW_RW_SHARES_DESC)
305 value=value
306 set_value=set_value
307 busy=busy
308 save=Callback::new(save)
309 save_label=i18n::t(i18n::k::SAVE_SETTINGS)
310 />
311 </Show>
312 </div>
313 }
314}
315
316// ---------------------------------------------------------------------------
317// Users view
318// ---------------------------------------------------------------------------
319
320/// What the user form is editing: a brand-new user, or an existing one.
321#[derive(Clone)]
322enum Editing {
323 New,
324 Edit(AdminUser),
325}
326
327#[component]
328pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> 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(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(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 />
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) -> impl IntoView {
524 let toast = use_context::<ToastMsg>().expect("toast context");
525 let is_new = existing.is_none();
526 let is_self = existing
527 .as_ref()
528 .map(|u| self_id == Some(u.id))
529 .unwrap_or(false);
530 let original_name = existing
531 .as_ref()
532 .map(|u| u.name.clone())
533 .unwrap_or_default();
534
535 let (name, set_name) = signal(
536 existing
537 .as_ref()
538 .map(|u| u.name.clone())
539 .unwrap_or_default(),
540 );
541 let (password, set_password) = signal(String::new());
542 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
543 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
544 let mut roots0: Vec<RootDraft> = existing
545 .as_ref()
546 .map(|u| {
547 u.roots
548 .iter()
549 .map(|r| RootDraft {
550 path: r.path.clone(),
551 mode: r.mode,
552 })
553 .collect()
554 })
555 .unwrap_or_default();
556 if is_new && roots0.is_empty() {
557 roots0.push(RootDraft {
558 path: ".".to_string(),
559 mode: Mode::Rw,
560 });
561 }
562 let (roots, set_roots) = signal(roots0);
563 let (new_path, set_new_path) = signal(String::new());
564 let (busy, set_busy) = signal(false);
565 let (error, set_error) = signal(Option::<String>::None);
566 let name_readonly = !is_new;
567
568 let add_root = move |_| {
569 let p = new_path.get().trim().to_string();
570 let path = if p.is_empty() { ".".to_string() } else { p };
571 let mut v = roots.get();
572 // Avoid duplicate paths.
573 if v.iter().any(|r| r.path == path) {
574 set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string()));
575 return;
576 }
577 v.push(RootDraft {
578 path: path.clone(),
579 mode: Mode::Rw,
580 });
581 set_roots.set(v);
582 set_new_path.set(String::new());
583 set_error.set(None);
584 };
585
586 let save = move |_| {
587 if busy.get() {
588 return;
589 }
590 let n = name.get().trim().to_string();
591 let pw = password.get();
592 let adm = is_admin.get();
593 let act = active.get();
594 let pairs: Vec<(String, Mode)> = roots
595 .get()
596 .iter()
597 .map(|r| (r.path.clone(), r.mode))
598 .collect();
599 // Client-side validation.
600 if n.is_empty() {
601 set_error.set(Some(i18n::t(i18n::k::NAME_REQUIRED).to_string()));
602 return;
603 }
604 if is_new && pw.len() < 8 {
605 set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
606 return;
607 }
608 if !is_new && pw.is_empty() {
609 // keep password unchanged
610 } else if pw.len() < 8 {
611 set_error.set(Some(i18n::t(i18n::k::PW_SHORT).to_string()));
612 return;
613 }
614 if pairs.is_empty() {
615 set_error.set(Some(i18n::t(i18n::k::NEED_FOLDER).to_string()));
616 return;
617 }
618
619 set_busy.set(true);
620 set_error.set(None);
621 let toast2 = toast;
622 let on_saved2 = on_saved;
623 let close2 = close;
624 let existing_id = existing.as_ref().map(|u| u.id);
625 spawn_local(async move {
626 let result = if is_new {
627 api::create_admin_user(&n, &pw, adm, &pairs).await
628 } else {
629 let id = existing_id.unwrap_or(0);
630 let pw_opt = if pw.is_empty() {
631 None
632 } else {
633 Some(pw.clone())
634 };
635 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
636 };
637 match result {
638 Ok(_) => {
639 show(
640 toast2,
641 if is_new {
642 i18n::t(i18n::k::USER_CREATED).to_string()
643 } else {
644 i18n::t(i18n::k::USER_UPDATED).to_string()
645 },
646 );
647 on_saved2.run(existing_id);
648 close2.run(());
649 }
650 Err(e) => {
651 show(toast2, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
652 set_busy.set(false);
653 }
654 }
655 });
656 };
657
658 view! {
659 <div class="modal-overlay" on:click=move |_| close.run(())>
660 <div
661 class="modal user-form"
662 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
663 >
664 <h2 class="modal-title">{move || {
665 if is_new {
666 i18n::t(i18n::k::NEW_USER).to_string()
667 } else {
668 i18n::t_fmt(i18n::k::EDIT_USER_TITLE, &original_name)
669 }
670 }}</h2>
671 {move || {
672 let n0 = name.get();
673 view! {
674 <label class="field">
675 <span class="field-label">{i18n::tr(i18n::k::NAME)}</span>
676 <input
677 class="field-input"
678 value=n0.clone()
679 disabled=name_readonly
680 placeholder="e.g. alice"
681 on:input=move |ev: web_sys::Event| {
682 if let Some(t) = ev
683 .target()
684 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
685 {
686 set_name.set(t.value());
687 }
688 }
689 />
690 </label>
691 }
692 }}
693 <label class="field">
694 <span class="field-label">
695 {if is_new {
696 i18n::t(i18n::k::PASSWORD)
697 } else {
698 i18n::t(i18n::k::NEW_PASSWORD_KEEP)
699 }}
700 </span>
701 <input
702 class="field-input"
703 type="password"
704 value=move || password.get()
705 placeholder="at least 8 characters"
706 on:input=move |ev: web_sys::Event| {
707 if let Some(t) = ev
708 .target()
709 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
710 {
711 set_password.set(t.value());
712 }
713 }
714 />
715 </label>
716 <label class="check-row">
717 <input
718 type="checkbox"
719 checked=move || is_admin.get()
720 disabled=is_self
721 on:change=move |ev: web_sys::Event| {
722 if let Some(t) = ev
723 .target()
724 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
725 {
726 set_is_admin.set(t.checked());
727 }
728 }
729 />
730 {i18n::tr(i18n::k::ADMINISTRATOR)}
731 </label>
732 {move || if !is_new {
733 view! {
734 <label class="check-row">
735 <input
736 type="checkbox"
737 checked=move || active.get()
738 disabled=is_self
739 on:change=move |ev: web_sys::Event| {
740 if let Some(t) = ev
741 .target()
742 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
743 {
744 set_active.set(t.checked());
745 }
746 }
747 />
748 {i18n::tr(i18n::k::ACTIVE_CAN_SIGNIN)}
749 </label>
750 }
751 .into_view()
752 .into_any()
753 } else {
754 view! {}.into_any()
755 }}
756 <div class="field">
757 <span class="field-label">{i18n::tr(i18n::k::FOLDERS)}</span>
758 <p class="muted field-hint">{i18n::tr(i18n::k::FOLDERS_PATHS_HINT)}</p>
759 <div class="roots-editor">
760 {move || {
761 let set_roots_c = set_roots;
762 let roots_c = roots;
763 roots
764 .get()
765 .iter()
766 .enumerate()
767 .map(|(i, r)| {
768 let rw_selected = r.mode == Mode::Rw;
769 let ro_selected = r.mode == Mode::Ro;
770 view! {
771 <div class="root-draft">
772 <span class="root-draft-path">{r.path.clone()}</span>
773 <select
774 class="root-draft-mode"
775 on:change=move |ev: web_sys::Event| {
776 if let Some(t) = ev
777 .target()
778 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
779 {
780 let mut v = roots_c.get();
781 if i < v.len()
782 && let Some(m) = Mode::from_wire(
783 &t.value(),
784 )
785 {
786 v[i].mode = m;
787 }
788 set_roots_c.set(v);
789 }
790 }
791 >
792 <option value=Mode::Rw.as_str() selected=rw_selected>{i18n::t(i18n::k::MODE_RW)}</option>
793 <option value=Mode::Ro.as_str() selected=ro_selected>{i18n::t(i18n::k::MODE_RO)}</option>
794 </select>
795 <button
796 class="icon-btn icon-btn-sm"
797 title={i18n::tr(i18n::k::REMOVE_FOLDER)}
798 on:click=move |_| {
799 let mut v = roots_c.get();
800 if i < v.len() {
801 v.remove(i);
802 }
803 set_roots_c.set(v);
804 }
805 >
806 <Icon name=IconName::Close class="ic-btn".to_string()/>
807 </button>
808 </div>
809 }
810 })
811 .collect::<Vec<_>>()
812 }}
813 <div class="root-add">
814 <input
815 class="field-input"
816 value=move || new_path.get()
817 placeholder="e.g. docs or ."
818 on:input=move |ev: web_sys::Event| {
819 if let Some(t) = ev
820 .target()
821 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
822 {
823 set_new_path.set(t.value());
824 }
825 }
826 />
827 <button class="btn" on:click=add_root>{i18n::tr(i18n::k::ADD)}</button>
828 </div>
829 </div>
830 </div>
831 {move || match error.get() {
832 Some(ref e) => view! {
833 <p class="form-error">{e.clone()}</p>
834 }
835 .into_view()
836 .into_any(),
837 None => {
838 view! {}.into_any()
839 },
840 }}
841 <div class="modal-actions">
842 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
843 <button
844 class="btn btn-primary"
845 disabled=move || busy.get()
846 on:click=save
847 >
848 {move || {
849 if busy.get() {
850 i18n::t(i18n::k::SAVING).to_string()
851 } else if is_new {
852 i18n::t(i18n::k::CREATE_USER).to_string()
853 } else {
854 i18n::t(i18n::k::SAVE).to_string()
855 }
856 }}
857 </button>
858 </div>
859 </div>
860 </div>
861 }
862}
863