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