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::{show, ToastMsg};
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 view! {}.into_view().into_any()
240 }}
241 {if u.is_admin {
242 view! { <span class="badge">"admin"</span> }
243 .into_view()
244 .into_any()
245 } else {
246 view! {}.into_view().into_any()
247 }}
248 {if !u.active {
249 view! { <span class="badge badge-off">"disabled"</span> }
250 .into_view()
251 .into_any()
252 } else {
253 view! {}.into_view().into_any()
254 }}
255 </div>
256 <div class="user-row-meta muted">{roots_desc.clone()}</div>
257 </div>
258 <div class="user-row-actions">
259 <button
260 class="btn btn-sm"
261 on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
262 >
263 "Edit"
264 </button>
265 <button
266 class="btn btn-sm btn-danger"
267 disabled=is_self
268 title=if is_self { "You cannot delete yourself" } else { "Delete user" }
269 on:click=move |_| del(u_del.clone())
270 >
271 "Delete"
272 </button>
273 </div>
274 </div>
275 }
276 })
277 .collect::<Vec<_>>();
278 view! {
279 <div class="user-list">{rows}</div>
280 }
281 }
282 .into_view()
283 .into_any(),
284 }}
285 {move || match editing.get() {
286 Some(ref e) => {
287 let existing = match e {
288 Editing::Edit(u) => Some(u.clone()),
289 Editing::New => None,
290 };
291 let sid = self_id();
292 let set = set_editing;
293 view! {
294 <UserForm
295 existing=existing
296 self_id=sid
297 close=Callback::new(move |_| set.set(None))
298 on_saved=Callback::new(move |_| reload())
299 />
300 }
301 .into_view()
302 .into_any()
303 }
304 None => view! {}.into_any(),
305 }}
306 </div>
307 }
308}
309
310// ---------------------------------------------------------------------------
311// User create / edit form
312// ---------------------------------------------------------------------------
313
314#[derive(Clone)]
315struct RootDraft {
316 path: String,
317 mode: String,
318}
319
320#[component]
321fn UserForm(
322 existing: Option<AdminUser>,
323 self_id: Option<i64>,
324 close: Callback<()>,
325 on_saved: Callback<()>,
326) -> impl IntoView {
327 let toast = use_context::<ToastMsg>().expect("toast context");
328 let is_new = existing.is_none();
329 let is_self = existing.as_ref().map(|u| self_id == Some(u.id)).unwrap_or(false);
330 let original_name = existing.as_ref().map(|u| u.name.clone()).unwrap_or_default();
331
332 let (name, set_name) = signal(existing.as_ref().map(|u| u.name.clone()).unwrap_or_default());
333 let (password, set_password) = signal(String::new());
334 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
335 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
336 let mut roots0: Vec<RootDraft> = existing
337 .as_ref()
338 .map(|u| {
339 u.roots
340 .iter()
341 .map(|r| RootDraft {
342 path: r.path.clone(),
343 mode: r.mode.clone(),
344 })
345 .collect()
346 })
347 .unwrap_or_default();
348 if is_new && roots0.is_empty() {
349 roots0.push(RootDraft {
350 path: ".".to_string(),
351 mode: "rw".to_string(),
352 });
353 }
354 let (roots, set_roots) = signal(roots0);
355 let (new_path, set_new_path) = signal(String::new());
356 let (busy, set_busy) = signal(false);
357 let (error, set_error) = signal(Option::<String>::None);
358 let name_readonly = !is_new;
359
360 let add_root = move |_| {
361 let p = new_path.get().trim().to_string();
362 let path = if p.is_empty() { ".".to_string() } else { p };
363 let mut v = roots.get();
364 // Avoid duplicate paths.
365 if v.iter().any(|r| r.path == path) {
366 set_error.set(Some("That folder is already in the list.".to_string()));
367 return;
368 }
369 v.push(RootDraft {
370 path: path.clone(),
371 mode: "rw".to_string(),
372 });
373 set_roots.set(v);
374 set_new_path.set(String::new());
375 set_error.set(None);
376 };
377
378 let save = move |_| {
379 if busy.get() {
380 return;
381 }
382 let n = name.get().trim().to_string();
383 let pw = password.get();
384 let adm = is_admin.get();
385 let act = active.get();
386 let pairs: Vec<(String, String)> = roots
387 .get()
388 .iter()
389 .map(|r| (r.path.clone(), r.mode.clone()))
390 .collect();
391 // Client-side validation.
392 if n.is_empty() {
393 set_error.set(Some("A name is required.".to_string()));
394 return;
395 }
396 if is_new && pw.len() < 8 {
397 set_error.set(Some("Password must be at least 8 characters.".to_string()));
398 return;
399 }
400 if !is_new && pw.is_empty() {
401 // keep password unchanged
402 } else if pw.len() < 8 {
403 set_error.set(Some("Password must be at least 8 characters.".to_string()));
404 return;
405 }
406 if pairs.is_empty() {
407 set_error.set(Some("Give the user at least one folder.".to_string()));
408 return;
409 }
410
411 set_busy.set(true);
412 set_error.set(None);
413 let toast2 = toast;
414 let on_saved2 = on_saved;
415 let close2 = close;
416 let existing_id = existing.as_ref().map(|u| u.id);
417 spawn_local(async move {
418 let result = if is_new {
419 api::create_admin_user(&n, &pw, adm, &pairs).await
420 } else {
421 let id = existing_id.unwrap_or(0);
422 let pw_opt = if pw.is_empty() { None } else { Some(pw.clone()) };
423 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
424 };
425 match result {
426 Ok(_) => {
427 show(toast2, if is_new { "User created".to_string() } else { "User updated".to_string() });
428 on_saved2.run(());
429 close2.run(());
430 }
431 Err(e) => {
432 show(toast2, format!("Could not save user: {e}"));
433 set_busy.set(false);
434 }
435 }
436 });
437 };
438
439 view! {
440 <div class="modal-overlay" on:click=move |_| close.run(())>
441 <div
442 class="modal user-form"
443 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
444 >
445 <h2 class="modal-title">{if is_new { "New user".to_string() } else { format!("Edit “{}”", original_name) }}</h2>
446 {move || {
447 let n0 = name.get();
448 view! {
449 <label class="field">
450 <span class="field-label">"Name"</span>
451 <input
452 class="field-input"
453 value=n0.clone()
454 disabled=name_readonly
455 placeholder="e.g. alice"
456 on:input=move |ev: web_sys::Event| {
457 if let Some(t) = ev
458 .target()
459 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
460 {
461 set_name.set(t.value());
462 }
463 }
464 />
465 </label>
466 }
467 }}
468 <label class="field">
469 <span class="field-label">
470 {if is_new { "Password" } else { "New password (leave blank to keep)" }}
471 </span>
472 <input
473 class="field-input"
474 type="password"
475 value=move || password.get()
476 placeholder="at least 8 characters"
477 on:input=move |ev: web_sys::Event| {
478 if let Some(t) = ev
479 .target()
480 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
481 {
482 set_password.set(t.value());
483 }
484 }
485 />
486 </label>
487 <label class="check-row">
488 <input
489 type="checkbox"
490 checked=move || is_admin.get()
491 disabled=is_self
492 on:change=move |ev: web_sys::Event| {
493 if let Some(t) = ev
494 .target()
495 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
496 {
497 set_is_admin.set(t.checked());
498 }
499 }
500 />
501 "Administrator"
502 </label>
503 {move || if !is_new {
504 view! {
505 <label class="check-row">
506 <input
507 type="checkbox"
508 checked=move || active.get()
509 disabled=is_self
510 on:change=move |ev: web_sys::Event| {
511 if let Some(t) = ev
512 .target()
513 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
514 {
515 set_active.set(t.checked());
516 }
517 }
518 />
519 "Active (can sign in)"
520 </label>
521 }
522 .into_view()
523 .into_any()
524 } else {
525 view! {}.into_any()
526 }}
527 <div class="field">
528 <span class="field-label">"Folders"</span>
529 <p class="muted field-hint">"Paths are relative to the server root. “.” is the whole root.”"</p>
530 <div class="roots-editor">
531 {move || {
532 let set_roots_c = set_roots;
533 let roots_c = roots;
534 roots
535 .get()
536 .iter()
537 .enumerate()
538 .map(|(i, r)| {
539 let rw_selected = r.mode == "rw";
540 let ro_selected = r.mode == "ro";
541 view! {
542 <div class="root-draft">
543 <span class="root-draft-path">{r.path.clone()}</span>
544 <select
545 class="root-draft-mode"
546 on:change=move |ev: web_sys::Event| {
547 if let Some(t) = ev
548 .target()
549 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
550 {
551 let val = t.value();
552 let mut v = roots_c.get();
553 if i < v.len() {
554 v[i].mode = val;
555 }
556 set_roots_c.set(v);
557 }
558 }
559 >
560 <option value="rw" selected=rw_selected>"read-write"</option>
561 <option value="ro" selected=ro_selected>"read-only"</option>
562 </select>
563 <button
564 class="btn btn-sm"
565 title="Remove folder"
566 on:click=move |_| {
567 let mut v = roots_c.get();
568 if i < v.len() {
569 v.remove(i);
570 }
571 set_roots_c.set(v);
572 }
573 >
574 "✕"
575 </button>
576 </div>
577 }
578 })
579 .collect::<Vec<_>>()
580 }}
581 <div class="root-add">
582 <input
583 class="field-input"
584 value=move || new_path.get()
585 placeholder="e.g. docs or ."
586 on:input=move |ev: web_sys::Event| {
587 if let Some(t) = ev
588 .target()
589 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
590 {
591 set_new_path.set(t.value());
592 }
593 }
594 />
595 <button class="btn" on:click=add_root>"Add"</button>
596 </div>
597 </div>
598 </div>
599 {move || match error.get() {
600 Some(ref e) => view! {
601 <p class="form-error">{e.clone()}</p>
602 }
603 .into_view()
604 .into_any(),
605 None => view! {}.into_any(),
606 }}
607 <div class="modal-actions">
608 <button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
609 <button
610 class="btn btn-primary"
611 disabled=move || busy.get()
612 on:click=save
613 >
614 {move || if busy.get() { "Saving…" } else { if is_new { "Create user" } else { "Save" } }}
615 </button>
616 </div>
617 </div>
618 </div>
619 }
620}
621