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 leptos::prelude::*;
5use wasm_bindgen::JsCast;
6use wasm_bindgen_futures::spawn_local;
7
8use crate::api::{self, AdminUser, Me, Mode};
9use crate::components::icon::Icon;
10use crate::components::toast::{ToastMsg, show};
11use crate::icons::IconName;
12
13// ---------------------------------------------------------------------------
14// Settings view
15// ---------------------------------------------------------------------------
16
17/// The two settings tabs. Every user sees Profile; Server is admin-only.
18#[derive(Clone, Copy, PartialEq)]
19enum SettingsTab {
20 Profile,
21 Server,
22}
23
24impl SettingsTab {
25 const KEY: &'static str = "fbng.settings-tab";
26
27 fn load(is_admin: bool) -> Self {
28 match crate::util::storage_get(Self::KEY).as_deref() {
29 Some("server") if is_admin => SettingsTab::Server,
30 Some("profile") => SettingsTab::Profile,
31 // First visit: admins land on the server tab (the pre-existing
32 // default), everyone else on their profile.
33 _ if is_admin => SettingsTab::Server,
34 _ => SettingsTab::Profile,
35 }
36 }
37
38 fn save(self) {
39 crate::util::storage_set(
40 Self::KEY,
41 match self {
42 SettingsTab::Server => "server",
43 SettingsTab::Profile => "profile",
44 },
45 );
46 }
47}
48
49/// One "setting" tab body: a checkbox with description and a busy save
50/// button. Both tabs (profile, server) share exactly this shape, so the
51/// markup lives here once.
52#[component]
53fn SettingToggle(
54 label: &'static str,
55 desc: &'static str,
56 value: ReadSignal<Option<bool>>,
57 set_value: WriteSignal<Option<bool>>,
58 busy: ReadSignal<bool>,
59 save: Callback<()>,
60 save_label: &'static str,
61) -> impl IntoView {
62 view! {
63 {move || match value.get() {
64 None => view! {
65 <p class="muted">"Loading…"</p>
66 }
67 .into_view()
68 .into_any(),
69 Some(v) => view! {
70 <label class="setting-row">
71 <input
72 type="checkbox"
73 checked=v
74 on:change=move |ev: web_sys::Event| {
75 if let Some(t) = ev
76 .target()
77 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
78 {
79 set_value.set(Some(t.checked()));
80 }
81 }
82 />
83 <span>
84 <span class="setting-label">{label}</span>
85 <span class="setting-desc">{desc}</span>
86 </span>
87 </label>
88 <div class="modal-actions">
89 <button
90 class="btn btn-primary"
91 disabled=move || busy.get()
92 on:click=move |_| save.run(())
93 >
94 {move || if busy.get() { "Saving…" } else { save_label }}
95 </button>
96 </div>
97 }
98 .into_view()
99 .into_any(),
100 }}
101 }
102}
103
104#[component]
105pub fn SettingsView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> 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 Effect::new(move |_| {
120 if let Some(v) = me.get().and_then(|m| m.user).map(|u| u.single_click_open) {
121 set_single_click.set(Some(v));
122 }
123 });
124 let save_profile = move |_| {
125 if profile_busy.get() {
126 return;
127 }
128 let Some(v) = single_click.get() else { return };
129 set_profile_busy.set(true);
130 let toast2 = toast;
131 let set_me2 = set_me;
132 let set_val = set_single_click;
133 spawn_local(async move {
134 match api::update_profile(v).await {
135 Ok(m) => {
136 show(toast2, "Profile saved".to_string());
137 // The response already carries the fresh /me.
138 set_me2.set(Some(m));
139 set_val.set(Some(v));
140 }
141 Err(e) => show(toast2, format!("Could not save profile: {e}")),
142 }
143 set_profile_busy.set(false);
144 });
145 };
146
147 // --- server: allow writable shares (admin only) ------------------------
148 let (value, set_value) = signal(Option::<bool>::None);
149 let (busy, set_busy) = signal(false);
150 {
151 let set = set_value;
152 let toast2 = toast;
153 spawn_local(async move {
154 match api::get_admin_settings().await {
155 Ok(s) => set.set(Some(s.allow_writable_shares)),
156 Err(e) => show(toast2, e.to_string()),
157 }
158 });
159 }
160 let save = move |_| {
161 if busy.get() {
162 return;
163 }
164 let Some(v) = value.get() else { return };
165 set_busy.set(true);
166 let toast2 = toast;
167 let set_me2 = set_me;
168 spawn_local(async move {
169 match api::update_admin_settings(v).await {
170 Ok(_) => {
171 show(toast2, "Settings saved".to_string());
172 // Re-fetch this session's /me so the share dialog (and
173 // anything else) sees the new setting without a reload.
174 if let Ok(m) = api::me().await {
175 set_me2.set(Some(m));
176 }
177 }
178 Err(e) => show(toast2, format!("Could not save settings: {e}")),
179 }
180 set_busy.set(false);
181 });
182 };
183
184 view! {
185 <div class="admin-view">
186 <div class="settings-tabs">
187 <button
188 class="settings-tab-btn"
189 class:active=move || tab.get() == SettingsTab::Profile
190 on:click=move |_| pick_tab(SettingsTab::Profile)
191 >
192 "Profile"
193 </button>
194 <Show when=move || is_admin()>
195 <button
196 class="settings-tab-btn"
197 class:active=move || tab.get() == SettingsTab::Server
198 on:click=move |_| pick_tab(SettingsTab::Server)
199 >
200 "Server"
201 </button>
202 </Show>
203 </div>
204 <Show when=move || tab.get() == SettingsTab::Profile>
205 <SettingToggle
206 label="Use single click to open"
207 desc="When on, a single click opens an entry and Ctrl+click selects it. \
208 When off, a single click selects and a double click opens."
209 value=single_click
210 set_value=set_single_click
211 busy=profile_busy
212 save=Callback::new(save_profile)
213 save_label="Save profile"
214 />
215 </Show>
216 <Show when=move || tab.get() == SettingsTab::Server && is_admin()>
217 <SettingToggle
218 label="Allow writable shares"
219 desc="Let users create read-write share links. Off by default."
220 value=value
221 set_value=set_value
222 busy=busy
223 save=Callback::new(save)
224 save_label="Save settings"
225 />
226 </Show>
227 </div>
228 }
229}
230
231// ---------------------------------------------------------------------------
232// Users view
233// ---------------------------------------------------------------------------
234
235/// What the user form is editing: a brand-new user, or an existing one.
236#[derive(Clone)]
237enum Editing {
238 New,
239 Edit(AdminUser),
240}
241
242#[component]
243pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
244 let toast = use_context::<ToastMsg>().expect("toast context");
245 let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
246 let (editing, set_editing) = signal(Option::<Editing>::None);
247
248 let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
249
250 let reload = move || {
251 let set = set_users;
252 let toast2 = toast;
253 spawn_local(async move {
254 match api::list_admin_users().await {
255 Ok(u) => set.set(Some(u)),
256 Err(e) => show(toast2, e.to_string()),
257 }
258 });
259 };
260 reload();
261
262 // After a save: reload the list, and — if the saved user is the current
263 // one — refresh /me so the sidebar's folders update in place.
264 let on_saved = Callback::new(move |saved: Option<i64>| {
265 reload();
266 if saved.is_some_and(|id| self_id() == Some(id)) {
267 crate::views::shell::refresh_me(set_me, toast);
268 }
269 });
270
271 let delete = move |u: AdminUser| {
272 let is_self = self_id() == Some(u.id);
273 if is_self {
274 return;
275 }
276 let Some(win) = web_sys::window() else { return };
277 let ok = win
278 .confirm_with_message(&format!("Delete user “{}”? This cannot be undone.", u.name))
279 .unwrap_or(false);
280 if !ok {
281 return;
282 }
283 let id = u.id;
284 let read = users;
285 let write = set_users;
286 let toast2 = toast;
287 spawn_local(async move {
288 if api::delete_admin_user(id).await.is_ok() {
289 if let Some(mut cur) = read.get() {
290 cur.retain(|x| x.id != id);
291 write.set(Some(cur));
292 }
293 } else {
294 show(toast2, "Could not delete user".to_string());
295 }
296 });
297 };
298
299 view! {
300 <div class="users-tab">
301 <div class="users-header">
302 <h3 class="admin-section-title">"Users"</h3>
303 <button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
304 <Icon name=IconName::Add class="ic-btn".to_string()/>
305 "New user"
306 </button>
307 </div>
308 {move || match users.get() {
309 None => view! {
310 <p class="muted">"Loading…"</p>
311 }
312 .into_view()
313 .into_any(),
314 Some(ref list) if list.is_empty() => view! {
315 <p class="muted">"No users."
316 </p>
317 }
318 .into_view()
319 .into_any(),
320 Some(ref list) => {
321 let sid = self_id();
322 let rows = list
323 .iter()
324 .map(|u| {
325 let is_self = sid == Some(u.id);
326 let is_admin = u.is_admin;
327 let u_edit = u.clone();
328 let u_del = u.clone();
329 let del = delete;
330 let set_edit = set_editing;
331 view! {
332 <div class="user-row">
333 <div class="user-row-main">
334 <div class="user-row-name">
335 {move || {
336 if is_admin {
337 view! { <span class="ic-admin" title="Administrator"><Icon name=IconName::Admin/></span> }
338 .into_view()
339 .into_any()
340 } else {
341 view! {}.into_any()
342 }
343 }}
344 {u.name.clone()}
345 {if is_self {
346 view! { <span class="muted">" (you)"</span> }
347 .into_view()
348 .into_any()
349 } else {
350 {
351 view! {}.into_view()
352 }.into_any()
353 }}
354 {if !u.active {
355 view! { <span class="badge badge-off">"disabled"</span> }
356 .into_view()
357 .into_any()
358 } else {
359 {
360 view! {}.into_view()
361 }.into_any()
362 }}
363 </div>
364 </div>
365 <div class="user-row-actions">
366 <button
367 class="btn btn-sm"
368 on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
369 >
370 "Edit"
371 </button>
372 <button
373 class="btn btn-sm btn-danger"
374 disabled=is_self
375 title=if is_self { "You cannot delete yourself" } else { "Delete user" }
376 on:click=move |_| del(u_del.clone())
377 >
378 "Delete"
379 </button>
380 </div>
381 </div>
382 }
383 })
384 .collect::<Vec<_>>();
385 view! {
386 <div class="user-list">{rows}</div>
387 }
388 }
389 .into_view()
390 .into_any(),
391 }}
392 {move || match editing.get() {
393 Some(ref e) => {
394 let existing = match e {
395 Editing::Edit(u) => Some(u.clone()),
396 Editing::New => None,
397 };
398 let sid = self_id();
399 let set = set_editing;
400 view! {
401 <UserForm
402 existing=existing
403 self_id=sid
404 close=Callback::new(move |_| set.set(None))
405 on_saved=on_saved
406 />
407 }
408 .into_view()
409 .into_any()
410 }
411 None => {
412 view! {}.into_any()
413 },
414 }}
415 </div>
416 }
417}
418
419// ---------------------------------------------------------------------------
420// User create / edit form
421// ---------------------------------------------------------------------------
422
423#[derive(Clone)]
424struct RootDraft {
425 path: String,
426 mode: Mode,
427}
428
429#[component]
430fn UserForm(
431 existing: Option<AdminUser>,
432 self_id: Option<i64>,
433 close: Callback<()>,
434 on_saved: Callback<Option<i64>>,
435) -> impl IntoView {
436 let toast = use_context::<ToastMsg>().expect("toast context");
437 let is_new = existing.is_none();
438 let is_self = existing
439 .as_ref()
440 .map(|u| self_id == Some(u.id))
441 .unwrap_or(false);
442 let original_name = existing
443 .as_ref()
444 .map(|u| u.name.clone())
445 .unwrap_or_default();
446
447 let (name, set_name) = signal(
448 existing
449 .as_ref()
450 .map(|u| u.name.clone())
451 .unwrap_or_default(),
452 );
453 let (password, set_password) = signal(String::new());
454 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
455 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
456 let mut roots0: Vec<RootDraft> = existing
457 .as_ref()
458 .map(|u| {
459 u.roots
460 .iter()
461 .map(|r| RootDraft {
462 path: r.path.clone(),
463 mode: r.mode,
464 })
465 .collect()
466 })
467 .unwrap_or_default();
468 if is_new && roots0.is_empty() {
469 roots0.push(RootDraft {
470 path: ".".to_string(),
471 mode: Mode::Rw,
472 });
473 }
474 let (roots, set_roots) = signal(roots0);
475 let (new_path, set_new_path) = signal(String::new());
476 let (busy, set_busy) = signal(false);
477 let (error, set_error) = signal(Option::<String>::None);
478 let name_readonly = !is_new;
479
480 let add_root = move |_| {
481 let p = new_path.get().trim().to_string();
482 let path = if p.is_empty() { ".".to_string() } else { p };
483 let mut v = roots.get();
484 // Avoid duplicate paths.
485 if v.iter().any(|r| r.path == path) {
486 set_error.set(Some("That folder is already in the list.".to_string()));
487 return;
488 }
489 v.push(RootDraft {
490 path: path.clone(),
491 mode: Mode::Rw,
492 });
493 set_roots.set(v);
494 set_new_path.set(String::new());
495 set_error.set(None);
496 };
497
498 let save = move |_| {
499 if busy.get() {
500 return;
501 }
502 let n = name.get().trim().to_string();
503 let pw = password.get();
504 let adm = is_admin.get();
505 let act = active.get();
506 let pairs: Vec<(String, Mode)> = roots
507 .get()
508 .iter()
509 .map(|r| (r.path.clone(), r.mode))
510 .collect();
511 // Client-side validation.
512 if n.is_empty() {
513 set_error.set(Some("A name is required.".to_string()));
514 return;
515 }
516 if is_new && pw.len() < 8 {
517 set_error.set(Some("Password must be at least 8 characters.".to_string()));
518 return;
519 }
520 if !is_new && pw.is_empty() {
521 // keep password unchanged
522 } else if pw.len() < 8 {
523 set_error.set(Some("Password must be at least 8 characters.".to_string()));
524 return;
525 }
526 if pairs.is_empty() {
527 set_error.set(Some("Give the user at least one folder.".to_string()));
528 return;
529 }
530
531 set_busy.set(true);
532 set_error.set(None);
533 let toast2 = toast;
534 let on_saved2 = on_saved;
535 let close2 = close;
536 let existing_id = existing.as_ref().map(|u| u.id);
537 spawn_local(async move {
538 let result = if is_new {
539 api::create_admin_user(&n, &pw, adm, &pairs).await
540 } else {
541 let id = existing_id.unwrap_or(0);
542 let pw_opt = if pw.is_empty() {
543 None
544 } else {
545 Some(pw.clone())
546 };
547 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
548 };
549 match result {
550 Ok(_) => {
551 show(
552 toast2,
553 if is_new {
554 "User created".to_string()
555 } else {
556 "User updated".to_string()
557 },
558 );
559 on_saved2.run(existing_id);
560 close2.run(());
561 }
562 Err(e) => {
563 show(toast2, format!("Could not save user: {e}"));
564 set_busy.set(false);
565 }
566 }
567 });
568 };
569
570 view! {
571 <div class="modal-overlay" on:click=move |_| close.run(())>
572 <div
573 class="modal user-form"
574 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
575 >
576 <h2 class="modal-title">{if is_new { "New user".to_string() } else { format!("Edit “{}”", original_name) }}</h2>
577 {move || {
578 let n0 = name.get();
579 view! {
580 <label class="field">
581 <span class="field-label">"Name"</span>
582 <input
583 class="field-input"
584 value=n0.clone()
585 disabled=name_readonly
586 placeholder="e.g. alice"
587 on:input=move |ev: web_sys::Event| {
588 if let Some(t) = ev
589 .target()
590 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
591 {
592 set_name.set(t.value());
593 }
594 }
595 />
596 </label>
597 }
598 }}
599 <label class="field">
600 <span class="field-label">
601 {if is_new { "Password" } else { "New password (leave blank to keep)" }}
602 </span>
603 <input
604 class="field-input"
605 type="password"
606 value=move || password.get()
607 placeholder="at least 8 characters"
608 on:input=move |ev: web_sys::Event| {
609 if let Some(t) = ev
610 .target()
611 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
612 {
613 set_password.set(t.value());
614 }
615 }
616 />
617 </label>
618 <label class="check-row">
619 <input
620 type="checkbox"
621 checked=move || is_admin.get()
622 disabled=is_self
623 on:change=move |ev: web_sys::Event| {
624 if let Some(t) = ev
625 .target()
626 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
627 {
628 set_is_admin.set(t.checked());
629 }
630 }
631 />
632 "Administrator"
633 </label>
634 {move || if !is_new {
635 view! {
636 <label class="check-row">
637 <input
638 type="checkbox"
639 checked=move || active.get()
640 disabled=is_self
641 on:change=move |ev: web_sys::Event| {
642 if let Some(t) = ev
643 .target()
644 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
645 {
646 set_active.set(t.checked());
647 }
648 }
649 />
650 "Active (can sign in)"
651 </label>
652 }
653 .into_view()
654 .into_any()
655 } else {
656 view! {}.into_any()
657 }}
658 <div class="field">
659 <span class="field-label">"Folders"</span>
660 <p class="muted field-hint">"Paths are relative to the server root. “.” is the whole root.”"</p>
661 <div class="roots-editor">
662 {move || {
663 let set_roots_c = set_roots;
664 let roots_c = roots;
665 roots
666 .get()
667 .iter()
668 .enumerate()
669 .map(|(i, r)| {
670 let rw_selected = r.mode == Mode::Rw;
671 let ro_selected = r.mode == Mode::Ro;
672 view! {
673 <div class="root-draft">
674 <span class="root-draft-path">{r.path.clone()}</span>
675 <select
676 class="root-draft-mode"
677 on:change=move |ev: web_sys::Event| {
678 if let Some(t) = ev
679 .target()
680 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
681 {
682 let mut v = roots_c.get();
683 if i < v.len()
684 && let Some(m) = Mode::from_wire(
685 &t.value(),
686 )
687 {
688 v[i].mode = m;
689 }
690 set_roots_c.set(v);
691 }
692 }
693 >
694 <option value=Mode::Rw.as_str() selected=rw_selected>"read-write"</option>
695 <option value=Mode::Ro.as_str() selected=ro_selected>"read-only"</option>
696 </select>
697 <button
698 class="icon-btn icon-btn-sm"
699 title="Remove folder"
700 on:click=move |_| {
701 let mut v = roots_c.get();
702 if i < v.len() {
703 v.remove(i);
704 }
705 set_roots_c.set(v);
706 }
707 >
708 <Icon name=IconName::Close class="ic-btn".to_string()/>
709 </button>
710 </div>
711 }
712 })
713 .collect::<Vec<_>>()
714 }}
715 <div class="root-add">
716 <input
717 class="field-input"
718 value=move || new_path.get()
719 placeholder="e.g. docs or ."
720 on:input=move |ev: web_sys::Event| {
721 if let Some(t) = ev
722 .target()
723 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
724 {
725 set_new_path.set(t.value());
726 }
727 }
728 />
729 <button class="btn" on:click=add_root>"Add"</button>
730 </div>
731 </div>
732 </div>
733 {move || match error.get() {
734 Some(ref e) => view! {
735 <p class="form-error">{e.clone()}</p>
736 }
737 .into_view()
738 .into_any(),
739 None => {
740 view! {}.into_any()
741 },
742 }}
743 <div class="modal-actions">
744 <button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
745 <button
746 class="btn btn-primary"
747 disabled=move || busy.get()
748 on:click=save
749 >
750 {move || if busy.get() { "Saving…" } else { if is_new { "Create user" } else { "Save" } }}
751 </button>
752 </div>
753 </div>
754 </div>
755 }
756}
757