admin.rs
⎇
Raw
1//! Admin UI (milestone 7): user management and server settings.
2//! Rendered in place of the browser when an admin opens the "Admin" panel.
3
4use leptos::prelude::*;
5use wasm_bindgen::JsCast;
6use wasm_bindgen_futures::spawn_local;
7
8use crate::api::{self, AdminUser, Me};
9use crate::components::toast::{ToastMsg, show};
10
11// ---------------------------------------------------------------------------
12// Top-level admin view with tabs
13// ---------------------------------------------------------------------------
14
15#[component]
16pub fn AdminView(me: ReadSignal<Option<Me>>) -> impl IntoView {
17 let (tab, set_tab) = signal("users".to_string());
18 view! {
19 <div class="admin-view">
20 <div class="admin-tabs">
21 <button
22 class="admin-tab"
23 class:active=move || tab.get() == "users"
24 on:click=move |_| set_tab.set("users".to_string())
25 >
26 "Users"
27 </button>
28 <button
29 class="admin-tab"
30 class:active=move || tab.get() == "settings"
31 on:click=move |_| set_tab.set("settings".to_string())
32 >
33 "Settings"
34 </button>
35 </div>
36 <div class="admin-body">
37 {move || {
38 if tab.get() == "users" {
39 view! { <UsersTab me=me/> }
40 .into_view()
41 .into_any()
42 } else {
43 view! { <SettingsTab/> }
44 .into_view()
45 .into_any()
46 }
47 }}
48 </div>
49 </div>
50 }
51}
52
53// ---------------------------------------------------------------------------
54// Settings tab
55// ---------------------------------------------------------------------------
56
57#[component]
58fn SettingsTab() -> impl IntoView {
59 let toast = use_context::<ToastMsg>().expect("toast context");
60 let (value, set_value) = signal(Option::<bool>::None);
61 let (busy, set_busy) = signal(false);
62 {
63 let set = set_value;
64 let toast2 = toast;
65 spawn_local(async move {
66 match api::get_admin_settings().await {
67 Ok(s) => set.set(Some(s.allow_writable_shares)),
68 Err(e) => show(toast2, e.to_string()),
69 }
70 });
71 }
72 let save = move |_| {
73 if busy.get() {
74 return;
75 }
76 let Some(v) = value.get() else { return };
77 set_busy.set(true);
78 let toast2 = toast;
79 spawn_local(async move {
80 match api::update_admin_settings(v).await {
81 Ok(_) => show(toast2, "Settings saved".to_string()),
82 Err(e) => show(toast2, format!("Could not save settings: {e}")),
83 }
84 set_busy.set(false);
85 });
86 };
87 view! {
88 <div class="settings-tab">
89 <h3 class="admin-section-title">"Server settings"</h3>
90 {move || match value.get() {
91 None => view! {
92 <p class="muted">"Loading…"</p>
93 }
94 .into_view()
95 .into_any(),
96 Some(v) => view! {
97 <label class="setting-row">
98 <input
99 type="checkbox"
100 checked=v
101 on:change=move |ev: web_sys::Event| {
102 if let Some(t) = ev
103 .target()
104 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
105 {
106 set_value.set(Some(t.checked()));
107 }
108 }
109 />
110 <span>
111 <span class="setting-label">"Allow writable shares"</span>
112 <span class="setting-desc">
113 "Let users create read-write share links. Off by default."
114 </span>
115 </span>
116 </label>
117 <div class="modal-actions">
118 <button class="btn btn-primary" disabled=move || busy.get() on:click=save>
119 {move || if busy.get() { "Saving…" } else { "Save settings" }}
120 </button>
121 </div>
122 }
123 .into_view()
124 .into_any(),
125 }}
126 </div>
127 }
128}
129
130// ---------------------------------------------------------------------------
131// Users tab
132// ---------------------------------------------------------------------------
133
134/// What the user form is editing: a brand-new user, or an existing one.
135#[derive(Clone)]
136enum Editing {
137 New,
138 Edit(AdminUser),
139}
140
141#[component]
142fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
143 let toast = use_context::<ToastMsg>().expect("toast context");
144 let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
145 let (editing, set_editing) = signal(Option::<Editing>::None);
146
147 let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
148
149 let reload = move || {
150 let set = set_users;
151 let toast2 = toast;
152 spawn_local(async move {
153 match api::list_admin_users().await {
154 Ok(u) => set.set(Some(u)),
155 Err(e) => show(toast2, e.to_string()),
156 }
157 });
158 };
159 reload();
160
161 let delete = move |u: AdminUser| {
162 let is_self = self_id() == Some(u.id);
163 if is_self {
164 return;
165 }
166 let Some(win) = web_sys::window() else { return };
167 let ok = win
168 .confirm_with_message(&format!("Delete user “{}”? This cannot be undone.", u.name))
169 .unwrap_or(false);
170 if !ok {
171 return;
172 }
173 let id = u.id;
174 let read = users;
175 let write = set_users;
176 let toast2 = toast;
177 spawn_local(async move {
178 if api::delete_admin_user(id).await.is_ok() {
179 if let Some(mut cur) = read.get() {
180 cur.retain(|x| x.id != id);
181 write.set(Some(cur));
182 }
183 } else {
184 show(toast2, "Could not delete user".to_string());
185 }
186 });
187 };
188
189 view! {
190 <div class="users-tab">
191 <div class="users-header">
192 <h3 class="admin-section-title">"Users"</h3>
193 <button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
194 "New user"
195 </button>
196 </div>
197 {move || match users.get() {
198 None => view! {
199 <p class="muted">"Loading…"</p>
200 }
201 .into_view()
202 .into_any(),
203 Some(ref list) if list.is_empty() => view! {
204 <p class="muted">"No users."
205 </p>
206 }
207 .into_view()
208 .into_any(),
209 Some(ref list) => {
210 let sid = self_id();
211 let rows = list
212 .iter()
213 .map(|u| {
214 let is_self = sid == Some(u.id);
215 let roots_desc = if u.roots.is_empty() {
216 "(no folders)".to_string()
217 } else {
218 u.roots
219 .iter()
220 .map(|r| format!("{} ({})", r.path, r.mode))
221 .collect::<Vec<_>>()
222 .join(", ")
223 };
224 let u_edit = u.clone();
225 let u_del = u.clone();
226 let del = delete;
227 let set_edit = set_editing;
228 view! {
229 <div class="user-row">
230 <div class="user-row-main">
231 <div class="user-row-name">
232 {if u.is_admin { "👑 " } else { "" }}
233 {u.name.clone()}
234 {if is_self {
235 view! { <span class="muted">" (you)"</span> }
236 .into_view()
237 .into_any()
238 } else {
239 {
240 view! {}.into_view()
241 }.into_any()
242 }}
243 {if u.is_admin {
244 view! { <span class="badge">"admin"</span> }
245 .into_view()
246 .into_any()
247 } else {
248 {
249 view! {}.into_view()
250 }.into_any()
251 }}
252 {if !u.active {
253 view! { <span class="badge badge-off">"disabled"</span> }
254 .into_view()
255 .into_any()
256 } else {
257 {
258 view! {}.into_view()
259 }.into_any()
260 }}
261 </div>
262 <div class="user-row-meta muted">{roots_desc.clone()}</div>
263 </div>
264 <div class="user-row-actions">
265 <button
266 class="btn btn-sm"
267 on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
268 >
269 "Edit"
270 </button>
271 <button
272 class="btn btn-sm btn-danger"
273 disabled=is_self
274 title=if is_self { "You cannot delete yourself" } else { "Delete user" }
275 on:click=move |_| del(u_del.clone())
276 >
277 "Delete"
278 </button>
279 </div>
280 </div>
281 }
282 })
283 .collect::<Vec<_>>();
284 view! {
285 <div class="user-list">{rows}</div>
286 }
287 }
288 .into_view()
289 .into_any(),
290 }}
291 {move || match editing.get() {
292 Some(ref e) => {
293 let existing = match e {
294 Editing::Edit(u) => Some(u.clone()),
295 Editing::New => None,
296 };
297 let sid = self_id();
298 let set = set_editing;
299 view! {
300 <UserForm
301 existing=existing
302 self_id=sid
303 close=Callback::new(move |_| set.set(None))
304 on_saved=Callback::new(move |_| reload())
305 />
306 }
307 .into_view()
308 .into_any()
309 }
310 None => {
311 view! {}.into_any()
312 },
313 }}
314 </div>
315 }
316}
317
318// ---------------------------------------------------------------------------
319// User create / edit form
320// ---------------------------------------------------------------------------
321
322#[derive(Clone)]
323struct RootDraft {
324 path: String,
325 mode: String,
326}
327
328#[component]
329fn UserForm(
330 existing: Option<AdminUser>,
331 self_id: Option<i64>,
332 close: Callback<()>,
333 on_saved: Callback<()>,
334) -> impl IntoView {
335 let toast = use_context::<ToastMsg>().expect("toast context");
336 let is_new = existing.is_none();
337 let is_self = existing
338 .as_ref()
339 .map(|u| self_id == Some(u.id))
340 .unwrap_or(false);
341 let original_name = existing
342 .as_ref()
343 .map(|u| u.name.clone())
344 .unwrap_or_default();
345
346 let (name, set_name) = signal(
347 existing
348 .as_ref()
349 .map(|u| u.name.clone())
350 .unwrap_or_default(),
351 );
352 let (password, set_password) = signal(String::new());
353 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
354 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
355 let mut roots0: Vec<RootDraft> = existing
356 .as_ref()
357 .map(|u| {
358 u.roots
359 .iter()
360 .map(|r| RootDraft {
361 path: r.path.clone(),
362 mode: r.mode.clone(),
363 })
364 .collect()
365 })
366 .unwrap_or_default();
367 if is_new && roots0.is_empty() {
368 roots0.push(RootDraft {
369 path: ".".to_string(),
370 mode: "rw".to_string(),
371 });
372 }
373 let (roots, set_roots) = signal(roots0);
374 let (new_path, set_new_path) = signal(String::new());
375 let (busy, set_busy) = signal(false);
376 let (error, set_error) = signal(Option::<String>::None);
377 let name_readonly = !is_new;
378
379 let add_root = move |_| {
380 let p = new_path.get().trim().to_string();
381 let path = if p.is_empty() { ".".to_string() } else { p };
382 let mut v = roots.get();
383 // Avoid duplicate paths.
384 if v.iter().any(|r| r.path == path) {
385 set_error.set(Some("That folder is already in the list.".to_string()));
386 return;
387 }
388 v.push(RootDraft {
389 path: path.clone(),
390 mode: "rw".to_string(),
391 });
392 set_roots.set(v);
393 set_new_path.set(String::new());
394 set_error.set(None);
395 };
396
397 let save = move |_| {
398 if busy.get() {
399 return;
400 }
401 let n = name.get().trim().to_string();
402 let pw = password.get();
403 let adm = is_admin.get();
404 let act = active.get();
405 let pairs: Vec<(String, String)> = roots
406 .get()
407 .iter()
408 .map(|r| (r.path.clone(), r.mode.clone()))
409 .collect();
410 // Client-side validation.
411 if n.is_empty() {
412 set_error.set(Some("A name is required.".to_string()));
413 return;
414 }
415 if is_new && pw.len() < 8 {
416 set_error.set(Some("Password must be at least 8 characters.".to_string()));
417 return;
418 }
419 if !is_new && pw.is_empty() {
420 // keep password unchanged
421 } else if pw.len() < 8 {
422 set_error.set(Some("Password must be at least 8 characters.".to_string()));
423 return;
424 }
425 if pairs.is_empty() {
426 set_error.set(Some("Give the user at least one folder.".to_string()));
427 return;
428 }
429
430 set_busy.set(true);
431 set_error.set(None);
432 let toast2 = toast;
433 let on_saved2 = on_saved;
434 let close2 = close;
435 let existing_id = existing.as_ref().map(|u| u.id);
436 spawn_local(async move {
437 let result = if is_new {
438 api::create_admin_user(&n, &pw, adm, &pairs).await
439 } else {
440 let id = existing_id.unwrap_or(0);
441 let pw_opt = if pw.is_empty() {
442 None
443 } else {
444 Some(pw.clone())
445 };
446 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
447 };
448 match result {
449 Ok(_) => {
450 show(
451 toast2,
452 if is_new {
453 "User created".to_string()
454 } else {
455 "User updated".to_string()
456 },
457 );
458 on_saved2.run(());
459 close2.run(());
460 }
461 Err(e) => {
462 show(toast2, format!("Could not save user: {e}"));
463 set_busy.set(false);
464 }
465 }
466 });
467 };
468
469 view! {
470 <div class="modal-overlay" on:click=move |_| close.run(())>
471 <div
472 class="modal user-form"
473 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
474 >
475 <h2 class="modal-title">{if is_new { "New user".to_string() } else { format!("Edit “{}”", original_name) }}</h2>
476 {move || {
477 let n0 = name.get();
478 view! {
479 <label class="field">
480 <span class="field-label">"Name"</span>
481 <input
482 class="field-input"
483 value=n0.clone()
484 disabled=name_readonly
485 placeholder="e.g. alice"
486 on:input=move |ev: web_sys::Event| {
487 if let Some(t) = ev
488 .target()
489 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
490 {
491 set_name.set(t.value());
492 }
493 }
494 />
495 </label>
496 }
497 }}
498 <label class="field">
499 <span class="field-label">
500 {if is_new { "Password" } else { "New password (leave blank to keep)" }}
501 </span>
502 <input
503 class="field-input"
504 type="password"
505 value=move || password.get()
506 placeholder="at least 8 characters"
507 on:input=move |ev: web_sys::Event| {
508 if let Some(t) = ev
509 .target()
510 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
511 {
512 set_password.set(t.value());
513 }
514 }
515 />
516 </label>
517 <label class="check-row">
518 <input
519 type="checkbox"
520 checked=move || is_admin.get()
521 disabled=is_self
522 on:change=move |ev: web_sys::Event| {
523 if let Some(t) = ev
524 .target()
525 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
526 {
527 set_is_admin.set(t.checked());
528 }
529 }
530 />
531 "Administrator"
532 </label>
533 {move || if !is_new {
534 view! {
535 <label class="check-row">
536 <input
537 type="checkbox"
538 checked=move || active.get()
539 disabled=is_self
540 on:change=move |ev: web_sys::Event| {
541 if let Some(t) = ev
542 .target()
543 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
544 {
545 set_active.set(t.checked());
546 }
547 }
548 />
549 "Active (can sign in)"
550 </label>
551 }
552 .into_view()
553 .into_any()
554 } else {
555 view! {}.into_any()
556 }}
557 <div class="field">
558 <span class="field-label">"Folders"</span>
559 <p class="muted field-hint">"Paths are relative to the server root. “.” is the whole root.”"</p>
560 <div class="roots-editor">
561 {move || {
562 let set_roots_c = set_roots;
563 let roots_c = roots;
564 roots
565 .get()
566 .iter()
567 .enumerate()
568 .map(|(i, r)| {
569 let rw_selected = r.mode == "rw";
570 let ro_selected = r.mode == "ro";
571 view! {
572 <div class="root-draft">
573 <span class="root-draft-path">{r.path.clone()}</span>
574 <select
575 class="root-draft-mode"
576 on:change=move |ev: web_sys::Event| {
577 if let Some(t) = ev
578 .target()
579 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
580 {
581 let val = t.value();
582 let mut v = roots_c.get();
583 if i < v.len() {
584 v[i].mode = val;
585 }
586 set_roots_c.set(v);
587 }
588 }
589 >
590 <option value="rw" selected=rw_selected>"read-write"</option>
591 <option value="ro" selected=ro_selected>"read-only"</option>
592 </select>
593 <button
594 class="btn btn-sm"
595 title="Remove folder"
596 on:click=move |_| {
597 let mut v = roots_c.get();
598 if i < v.len() {
599 v.remove(i);
600 }
601 set_roots_c.set(v);
602 }
603 >
604 "✕"
605 </button>
606 </div>
607 }
608 })
609 .collect::<Vec<_>>()
610 }}
611 <div class="root-add">
612 <input
613 class="field-input"
614 value=move || new_path.get()
615 placeholder="e.g. docs or ."
616 on:input=move |ev: web_sys::Event| {
617 if let Some(t) = ev
618 .target()
619 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
620 {
621 set_new_path.set(t.value());
622 }
623 }
624 />
625 <button class="btn" on:click=add_root>"Add"</button>
626 </div>
627 </div>
628 </div>
629 {move || match error.get() {
630 Some(ref e) => view! {
631 <p class="form-error">{e.clone()}</p>
632 }
633 .into_view()
634 .into_any(),
635 None => {
636 view! {}.into_any()
637 },
638 }}
639 <div class="modal-actions">
640 <button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
641 <button
642 class="btn btn-primary"
643 disabled=move || busy.get()
644 on:click=save
645 >
646 {move || if busy.get() { "Saving…" } else { if is_new { "Create user" } else { "Save" } }}
647 </button>
648 </div>
649 </div>
650 </div>
651 }
652}
653