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#[component]
18pub fn SettingsView(set_me: WriteSignal<Option<Me>>) -> impl IntoView {
19 let toast = use_context::<ToastMsg>().expect("toast context");
20 let (value, set_value) = signal(Option::<bool>::None);
21 let (busy, set_busy) = signal(false);
22 {
23 let set = set_value;
24 let toast2 = toast;
25 spawn_local(async move {
26 match api::get_admin_settings().await {
27 Ok(s) => set.set(Some(s.allow_writable_shares)),
28 Err(e) => show(toast2, e.to_string()),
29 }
30 });
31 }
32 let save = move |_| {
33 if busy.get() {
34 return;
35 }
36 let Some(v) = value.get() else { return };
37 set_busy.set(true);
38 let toast2 = toast;
39 let set_me2 = set_me;
40 spawn_local(async move {
41 match api::update_admin_settings(v).await {
42 Ok(_) => {
43 show(toast2, "Settings saved".to_string());
44 // Re-fetch this session's /me so the share dialog (and
45 // anything else) sees the new setting without a reload.
46 if let Ok(m) = api::me().await {
47 set_me2.set(Some(m));
48 }
49 }
50 Err(e) => show(toast2, format!("Could not save settings: {e}")),
51 }
52 set_busy.set(false);
53 });
54 };
55 view! {
56 <div class="admin-view">
57 <div class="settings-tab">
58 <h3 class="admin-section-title">"Server settings"</h3>
59 {move || match value.get() {
60 None => view! {
61 <p class="muted">"Loading…"</p>
62 }
63 .into_view()
64 .into_any(),
65 Some(v) => view! {
66 <label class="setting-row">
67 <input
68 type="checkbox"
69 checked=v
70 on:change=move |ev: web_sys::Event| {
71 if let Some(t) = ev
72 .target()
73 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
74 {
75 set_value.set(Some(t.checked()));
76 }
77 }
78 />
79 <span>
80 <span class="setting-label">"Allow writable shares"</span>
81 <span class="setting-desc">
82 "Let users create read-write share links. Off by default."
83 </span>
84 </span>
85 </label>
86 <div class="modal-actions">
87 <button class="btn btn-primary" disabled=move || busy.get() on:click=save>
88 {move || if busy.get() { "Saving…" } else { "Save settings" }}
89 </button>
90 </div>
91 }
92 .into_view()
93 .into_any(),
94 }}
95 </div>
96 </div>
97 }
98}
99
100// ---------------------------------------------------------------------------
101// Users view
102// ---------------------------------------------------------------------------
103
104/// What the user form is editing: a brand-new user, or an existing one.
105#[derive(Clone)]
106enum Editing {
107 New,
108 Edit(AdminUser),
109}
110
111#[component]
112pub fn UsersView(me: ReadSignal<Option<Me>>, set_me: WriteSignal<Option<Me>>) -> impl IntoView {
113 let toast = use_context::<ToastMsg>().expect("toast context");
114 let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
115 let (editing, set_editing) = signal(Option::<Editing>::None);
116
117 let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
118
119 let reload = move || {
120 let set = set_users;
121 let toast2 = toast;
122 spawn_local(async move {
123 match api::list_admin_users().await {
124 Ok(u) => set.set(Some(u)),
125 Err(e) => show(toast2, e.to_string()),
126 }
127 });
128 };
129 reload();
130
131 // After a save: reload the list, and — if the saved user is the current
132 // one — refresh /me so the sidebar's folders update in place.
133 let on_saved = Callback::new(move |saved: Option<i64>| {
134 reload();
135 if saved.is_some_and(|id| self_id() == Some(id)) {
136 crate::views::shell::refresh_me(set_me, toast);
137 }
138 });
139
140 let delete = move |u: AdminUser| {
141 let is_self = self_id() == Some(u.id);
142 if is_self {
143 return;
144 }
145 let Some(win) = web_sys::window() else { return };
146 let ok = win
147 .confirm_with_message(&format!("Delete user “{}”? This cannot be undone.", u.name))
148 .unwrap_or(false);
149 if !ok {
150 return;
151 }
152 let id = u.id;
153 let read = users;
154 let write = set_users;
155 let toast2 = toast;
156 spawn_local(async move {
157 if api::delete_admin_user(id).await.is_ok() {
158 if let Some(mut cur) = read.get() {
159 cur.retain(|x| x.id != id);
160 write.set(Some(cur));
161 }
162 } else {
163 show(toast2, "Could not delete user".to_string());
164 }
165 });
166 };
167
168 view! {
169 <div class="users-tab">
170 <div class="users-header">
171 <h3 class="admin-section-title">"Users"</h3>
172 <button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
173 <Icon name=IconName::Add class="ic-btn".to_string()/>
174 "New user"
175 </button>
176 </div>
177 {move || match users.get() {
178 None => view! {
179 <p class="muted">"Loading…"</p>
180 }
181 .into_view()
182 .into_any(),
183 Some(ref list) if list.is_empty() => view! {
184 <p class="muted">"No users."
185 </p>
186 }
187 .into_view()
188 .into_any(),
189 Some(ref list) => {
190 let sid = self_id();
191 let rows = list
192 .iter()
193 .map(|u| {
194 let is_self = sid == Some(u.id);
195 let is_admin = u.is_admin;
196 let u_edit = u.clone();
197 let u_del = u.clone();
198 let del = delete;
199 let set_edit = set_editing;
200 view! {
201 <div class="user-row">
202 <div class="user-row-main">
203 <div class="user-row-name">
204 {move || {
205 if is_admin {
206 view! { <span class="ic-admin" title="Administrator"><Icon name=IconName::Admin/></span> }
207 .into_view()
208 .into_any()
209 } else {
210 view! {}.into_any()
211 }
212 }}
213 {u.name.clone()}
214 {if is_self {
215 view! { <span class="muted">" (you)"</span> }
216 .into_view()
217 .into_any()
218 } else {
219 {
220 view! {}.into_view()
221 }.into_any()
222 }}
223 {if !u.active {
224 view! { <span class="badge badge-off">"disabled"</span> }
225 .into_view()
226 .into_any()
227 } else {
228 {
229 view! {}.into_view()
230 }.into_any()
231 }}
232 </div>
233 </div>
234 <div class="user-row-actions">
235 <button
236 class="btn btn-sm"
237 on:click=move |_| set_edit.set(Some(Editing::Edit(u_edit.clone())))
238 >
239 "Edit"
240 </button>
241 <button
242 class="btn btn-sm btn-danger"
243 disabled=is_self
244 title=if is_self { "You cannot delete yourself" } else { "Delete user" }
245 on:click=move |_| del(u_del.clone())
246 >
247 "Delete"
248 </button>
249 </div>
250 </div>
251 }
252 })
253 .collect::<Vec<_>>();
254 view! {
255 <div class="user-list">{rows}</div>
256 }
257 }
258 .into_view()
259 .into_any(),
260 }}
261 {move || match editing.get() {
262 Some(ref e) => {
263 let existing = match e {
264 Editing::Edit(u) => Some(u.clone()),
265 Editing::New => None,
266 };
267 let sid = self_id();
268 let set = set_editing;
269 view! {
270 <UserForm
271 existing=existing
272 self_id=sid
273 close=Callback::new(move |_| set.set(None))
274 on_saved=on_saved
275 />
276 }
277 .into_view()
278 .into_any()
279 }
280 None => {
281 view! {}.into_any()
282 },
283 }}
284 </div>
285 }
286}
287
288// ---------------------------------------------------------------------------
289// User create / edit form
290// ---------------------------------------------------------------------------
291
292#[derive(Clone)]
293struct RootDraft {
294 path: String,
295 mode: Mode,
296}
297
298#[component]
299fn UserForm(
300 existing: Option<AdminUser>,
301 self_id: Option<i64>,
302 close: Callback<()>,
303 on_saved: Callback<Option<i64>>,
304) -> impl IntoView {
305 let toast = use_context::<ToastMsg>().expect("toast context");
306 let is_new = existing.is_none();
307 let is_self = existing
308 .as_ref()
309 .map(|u| self_id == Some(u.id))
310 .unwrap_or(false);
311 let original_name = existing
312 .as_ref()
313 .map(|u| u.name.clone())
314 .unwrap_or_default();
315
316 let (name, set_name) = signal(
317 existing
318 .as_ref()
319 .map(|u| u.name.clone())
320 .unwrap_or_default(),
321 );
322 let (password, set_password) = signal(String::new());
323 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
324 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
325 let mut roots0: Vec<RootDraft> = existing
326 .as_ref()
327 .map(|u| {
328 u.roots
329 .iter()
330 .map(|r| RootDraft {
331 path: r.path.clone(),
332 mode: r.mode,
333 })
334 .collect()
335 })
336 .unwrap_or_default();
337 if is_new && roots0.is_empty() {
338 roots0.push(RootDraft {
339 path: ".".to_string(),
340 mode: Mode::Rw,
341 });
342 }
343 let (roots, set_roots) = signal(roots0);
344 let (new_path, set_new_path) = signal(String::new());
345 let (busy, set_busy) = signal(false);
346 let (error, set_error) = signal(Option::<String>::None);
347 let name_readonly = !is_new;
348
349 let add_root = move |_| {
350 let p = new_path.get().trim().to_string();
351 let path = if p.is_empty() { ".".to_string() } else { p };
352 let mut v = roots.get();
353 // Avoid duplicate paths.
354 if v.iter().any(|r| r.path == path) {
355 set_error.set(Some("That folder is already in the list.".to_string()));
356 return;
357 }
358 v.push(RootDraft {
359 path: path.clone(),
360 mode: Mode::Rw,
361 });
362 set_roots.set(v);
363 set_new_path.set(String::new());
364 set_error.set(None);
365 };
366
367 let save = move |_| {
368 if busy.get() {
369 return;
370 }
371 let n = name.get().trim().to_string();
372 let pw = password.get();
373 let adm = is_admin.get();
374 let act = active.get();
375 let pairs: Vec<(String, Mode)> = roots
376 .get()
377 .iter()
378 .map(|r| (r.path.clone(), r.mode))
379 .collect();
380 // Client-side validation.
381 if n.is_empty() {
382 set_error.set(Some("A name is required.".to_string()));
383 return;
384 }
385 if is_new && pw.len() < 8 {
386 set_error.set(Some("Password must be at least 8 characters.".to_string()));
387 return;
388 }
389 if !is_new && pw.is_empty() {
390 // keep password unchanged
391 } else if pw.len() < 8 {
392 set_error.set(Some("Password must be at least 8 characters.".to_string()));
393 return;
394 }
395 if pairs.is_empty() {
396 set_error.set(Some("Give the user at least one folder.".to_string()));
397 return;
398 }
399
400 set_busy.set(true);
401 set_error.set(None);
402 let toast2 = toast;
403 let on_saved2 = on_saved;
404 let close2 = close;
405 let existing_id = existing.as_ref().map(|u| u.id);
406 spawn_local(async move {
407 let result = if is_new {
408 api::create_admin_user(&n, &pw, adm, &pairs).await
409 } else {
410 let id = existing_id.unwrap_or(0);
411 let pw_opt = if pw.is_empty() {
412 None
413 } else {
414 Some(pw.clone())
415 };
416 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
417 };
418 match result {
419 Ok(_) => {
420 show(
421 toast2,
422 if is_new {
423 "User created".to_string()
424 } else {
425 "User updated".to_string()
426 },
427 );
428 on_saved2.run(existing_id);
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 == Mode::Rw;
540 let ro_selected = r.mode == 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 mut v = roots_c.get();
552 if i < v.len()
553 && let Some(m) = Mode::from_wire(
554 &t.value(),
555 )
556 {
557 v[i].mode = m;
558 }
559 set_roots_c.set(v);
560 }
561 }
562 >
563 <option value=Mode::Rw.as_str() selected=rw_selected>"read-write"</option>
564 <option value=Mode::Ro.as_str() selected=ro_selected>"read-only"</option>
565 </select>
566 <button
567 class="icon-btn icon-btn-sm"
568 title="Remove folder"
569 on:click=move |_| {
570 let mut v = roots_c.get();
571 if i < v.len() {
572 v.remove(i);
573 }
574 set_roots_c.set(v);
575 }
576 >
577 <Icon name=IconName::Close class="ic-btn".to_string()/>
578 </button>
579 </div>
580 }
581 })
582 .collect::<Vec<_>>()
583 }}
584 <div class="root-add">
585 <input
586 class="field-input"
587 value=move || new_path.get()
588 placeholder="e.g. docs or ."
589 on:input=move |ev: web_sys::Event| {
590 if let Some(t) = ev
591 .target()
592 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
593 {
594 set_new_path.set(t.value());
595 }
596 }
597 />
598 <button class="btn" on:click=add_root>"Add"</button>
599 </div>
600 </div>
601 </div>
602 {move || match error.get() {
603 Some(ref e) => view! {
604 <p class="form-error">{e.clone()}</p>
605 }
606 .into_view()
607 .into_any(),
608 None => {
609 view! {}.into_any()
610 },
611 }}
612 <div class="modal-actions">
613 <button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
614 <button
615 class="btn btn-primary"
616 disabled=move || busy.get()
617 on:click=save
618 >
619 {move || if busy.get() { "Saving…" } else { if is_new { "Create user" } else { "Save" } }}
620 </button>
621 </div>
622 </div>
623 </div>
624 }
625}
626