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