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