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 crate::i18n;
5use leptos::prelude::*;
6use wasm_bindgen_futures::spawn_local;
7
8use crate::api::{self, AdminUser, Me, Mode, RoomInfo, RoomKind};
9use crate::components::icon::icon_svg;
10use crate::components::modal::Modal;
11use crate::components::toast::{ToastMsg, show, show_error};
12use crate::icons::IconName;
13use crate::router::{Location, Section, navigate};
14use crate::views::dialogs::{Dialog, admin_root};
15
16// ---------------------------------------------------------------------------
17// Settings view
18// ---------------------------------------------------------------------------
19
20/// The settings tabs. Every user sees Profile and Security; Server is
21/// admin-only.
22#[derive(Clone, Copy, PartialEq)]
23enum SettingsTab {
24 Profile,
25 Security,
26 Server,
27}
28
29impl SettingsTab {
30 const KEY: &'static str = "dovenest.settings-tab";
31
32 fn parse(s: &str, is_admin: bool) -> Option<Self> {
33 match s {
34 "server" if is_admin => Some(SettingsTab::Server),
35 "profile" => Some(SettingsTab::Profile),
36 "security" => Some(SettingsTab::Security),
37 _ => None,
38 }
39 }
40
41 const fn as_str(self) -> &'static str {
42 match self {
43 SettingsTab::Server => "server",
44 SettingsTab::Security => "security",
45 SettingsTab::Profile => "profile",
46 }
47 }
48
49 /// The tab in the URL, else the last one used.
50 fn current(url_tab: Option<&str>, is_admin: bool) -> Self {
51 url_tab
52 .and_then(|t| Self::parse(t, is_admin))
53 .or_else(|| Self::parse(&crate::util::storage_get(Self::KEY)?, is_admin))
54 // First visit: admins land on the server tab (the pre-existing
55 // default), everyone else on their profile.
56 .unwrap_or(if is_admin {
57 SettingsTab::Server
58 } else {
59 SettingsTab::Profile
60 })
61 }
62
63 fn save(self) {
64 crate::util::storage_set(Self::KEY, self.as_str());
65 }
66}
67
68/// One labelled checkbox row for a setting.
69///
70/// The save button belongs to the tab, not the row. The server tab writes
71/// all of its settings in one PUT, so a per-row button would save the other
72/// rows' unsaved state too.
73#[component]
74fn SettingToggle(
75 label: &'static str,
76 desc: &'static str,
77 value: ReadSignal<Option<bool>>,
78 set_value: WriteSignal<Option<bool>>,
79) -> impl IntoView {
80 // Built once the value loads, then only `checked` follows it: rebuilding
81 // the input on each toggle would drop keyboard focus.
82 view! {
83 <Show
84 when=move || value.get().is_some()
85 fallback=|| view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }
86 >
87 <label class="setting-row">
88 <span>
89 <span class="setting-label">{label}</span>
90 <span class="setting-desc">{desc}</span>
91 </span>
92 <input
93 type="checkbox"
94 prop:checked=move || value.get() == Some(true)
95 on:change=move |ev| set_value.set(Some(event_target_checked(&ev)))
96 />
97 </label>
98 </Show>
99 }
100}
101
102#[component]
103pub fn SettingsView(
104 me: ReadSignal<Option<Me>>,
105 /// Its path holds the tab (`#/settings/security`).
106 loc: ReadSignal<Location>,
107 set_me: WriteSignal<Option<Me>>,
108 /// The folder picker for the search exclusions is rendered at the shell
109 /// level, like every other dialog.
110 set_dialog: WriteSignal<Option<Dialog>>,
111) -> impl IntoView {
112 let toast = use_context::<ToastMsg>().expect("toast context");
113 let is_admin = move || me.get().and_then(|m| m.user).is_some_and(|u| u.is_admin);
114 // The URL names the tab, so a link can point at one and Back switches
115 // back. The plain `#/settings` link opens the last one used.
116 let tab = Memo::new(move |_| {
117 loc.with(|l| SettingsTab::current(l.path.first().map(String::as_str), is_admin()))
118 });
119 let pick_tab = move |t: SettingsTab| {
120 t.save();
121 if t != tab.get_untracked() {
122 navigate(&Location {
123 section: Section::Settings,
124 path: vec![t.as_str().to_string()],
125 ..Location::root()
126 });
127 }
128 };
129
130 // --- profile: single-click-to-open ------------------------------------
131 let (single_click, set_single_click) = signal(Option::<bool>::None);
132 let (profile_busy, set_profile_busy) = signal(false);
133 // Profile language: Some(None) = auto (follow the browser).
134 let (language, set_language) = signal(Option::<Option<String>>::None);
135 let (thumbnails, set_thumbnails) = signal(Option::<bool>::None);
136 // Default root: Some(None) = the folder overview.
137 let (default_root, set_default_root) = signal(Option::<Option<i64>>::None);
138 let (week_start, set_week_start) = signal(Option::<u8>::None);
139 // Without `--cache` the server cannot make thumbnails, so hide the row
140 // rather than offer a setting that does nothing.
141 let thumbs_available = move || me.get().is_some_and(|m| m.thumbnails_available);
142 Effect::new(move |_| {
143 if let Some(u) = me.get().and_then(|m| m.user) {
144 set_single_click.set(Some(u.single_click_open));
145 set_thumbnails.set(Some(u.thumbnails));
146 set_language.set(Some(u.language.clone()));
147 set_default_root.set(Some(u.default_root_id));
148 set_week_start.set(Some(u.week_start));
149 }
150 });
151 // Saves both profile settings in one call.
152 let save_profile = Callback::new(move |_| {
153 if profile_busy.get() {
154 return;
155 }
156 let Some(v) = single_click.get() else { return };
157 let Some(th) = thumbnails.get() else { return };
158 let Some(lang) = language.get() else { return };
159 let Some(root) = default_root.get() else {
160 return;
161 };
162 let Some(ws) = week_start.get() else { return };
163 set_profile_busy.set(true);
164 spawn_local(async move {
165 match api::update_profile(Some(v), Some(th), Some(lang), Some(root), Some(ws)).await {
166 Ok(m) => {
167 show(toast, i18n::t(i18n::k::PROFILE_SAVED).to_string());
168 // The response already carries the fresh /me (which also
169 // flips the UI language via the app-level effect).
170 set_me.set(Some(m));
171 }
172 Err(e) => show(
173 toast,
174 i18n::t_fmt(i18n::k::PROFILE_SAVE_ERR, &e.to_string()),
175 ),
176 }
177 set_profile_busy.set(false);
178 });
179 });
180
181 // --- server settings (admin only) --------------------------------------
182 let (value, set_value) = signal(Option::<bool>::None);
183 let (excludes, set_excludes) = signal(Vec::<String>::new());
184 let (excl_error, set_excl_error) = signal(Option::<String>::None);
185 let (busy, set_busy) = signal(false);
186 spawn_local(async move {
187 // Non-admins don't have a Server tab; don't even probe the
188 // endpoint (it would 403 and toast).
189 if !is_admin() {
190 return;
191 }
192 match api::get_admin_settings().await {
193 Ok(s) => {
194 set_value.set(Some(s.allow_writable_shares));
195 set_excludes.set(s.search_excludes);
196 }
197 Err(e) => show_error(toast, e.to_string()),
198 }
199 });
200 // Opens the same folder picker the user editor uses, over the whole
201 // server root (an exclusion is not tied to any one user's folders).
202 let add_exclude = move |_| {
203 set_excl_error.set(None);
204 set_dialog.set(Some(Dialog::Picker {
205 title: i18n::t(i18n::k::EXCLUDE_FOLDER).to_string(),
206 confirm: i18n::t(i18n::k::EXCLUDE_HERE).to_string(),
207 roots: vec![admin_root()],
208 root: api_types::ADMIN_ROOT,
209 dir: String::new(),
210 for_write: false,
211 on_pick: Callback::new(move |(_, dir): (i64, String)| {
212 // An empty dir is the server root. Excluding it would
213 // switch search off altogether.
214 if dir.is_empty() {
215 set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_ROOT_ERR).to_string()));
216 return;
217 }
218 let mut v = excludes.get();
219 if v.contains(&dir) {
220 set_excl_error.set(Some(i18n::t(i18n::k::EXCLUDE_DUP_ERR).to_string()));
221 return;
222 }
223 v.push(dir);
224 set_excludes.set(v);
225 set_excl_error.set(None);
226 }),
227 }));
228 };
229 let save = move |_| {
230 if busy.get() {
231 return;
232 }
233 let Some(v) = value.get() else { return };
234 let ex = excludes.get();
235 set_busy.set(true);
236 spawn_local(async move {
237 match api::update_admin_settings(v, ex).await {
238 Ok(_) => {
239 show(toast, i18n::t(i18n::k::SETTINGS_SAVED).to_string());
240 // Re-fetch this session's /me so the share dialog (and
241 // anything else) sees the new setting without a reload.
242 crate::views::shell::refresh_me(set_me, toast);
243 }
244 Err(e) => show(
245 toast,
246 i18n::t_fmt(i18n::k::SETTINGS_SAVE_ERR, &e.to_string()),
247 ),
248 }
249 set_busy.set(false);
250 });
251 };
252
253 view! {
254 <div class="admin-view">
255 <div class="settings-tabs">
256 {[
257 (SettingsTab::Profile, i18n::k::PROFILE),
258 (SettingsTab::Security, i18n::k::SECURITY),
259 (SettingsTab::Server, i18n::k::SERVER),
260 ]
261 .map(|(t, label)| view! {
262 <Show when=move || t != SettingsTab::Server || is_admin()>
263 <button
264 class="settings-tab-btn"
265 class:active=move || tab.get() == t
266 on:click=move |_| pick_tab(t)
267 >
268 {i18n::tr(label)}
269 </button>
270 </Show>
271 })}
272 </div>
273 <Show when=move || tab.get() == SettingsTab::Profile>
274 {move || match (language.get(), default_root.get(), week_start.get()) {
275 (Some(lang), Some(root), Some(ws)) => {
276 let lang_val = lang.clone().unwrap_or_default();
277 let roots = me.get().map(|m| m.roots).unwrap_or_default();
278 view! {
279 <SettingToggle
280 label=i18n::t(i18n::k::SINGLE_CLICK_LABEL)
281 desc=i18n::t(i18n::k::SINGLE_CLICK_DESC)
282 value=single_click
283 set_value=set_single_click
284 />
285 <Show when=thumbs_available>
286 <SettingToggle
287 label=i18n::t(i18n::k::THUMBNAILS_LABEL)
288 desc=i18n::t(i18n::k::THUMBNAILS_DESC)
289 value=thumbnails
290 set_value=set_thumbnails
291 />
292 </Show>
293 <div class="setting-row setting-row-select">
294 <span>
295 <span class="setting-label">{i18n::t(i18n::k::LANGUAGE)}</span>
296 <span class="setting-desc">{i18n::t(i18n::k::LANGUAGE_DESC)}</span>
297 </span>
298 <select
299 on:change=move |ev| {
300 let tag = event_target_value(&ev);
301 set_language.set(Some((!tag.is_empty()).then_some(tag)));
302 }
303 >
304 <option value="" selected=lang_val.is_empty()>{i18n::t(i18n::k::LANG_AUTO)}</option>
305 <option value="en" selected=lang_val == "en">{i18n::t(i18n::k::LANG_EN)}</option>
306 <option value="de" selected=lang_val == "de">{i18n::t(i18n::k::LANG_DE)}</option>
307 <option value="fr" selected=lang_val == "fr">{i18n::t(i18n::k::LANG_FR)}</option>
308 </select>
309 </div>
310 <div class="setting-row setting-row-select">
311 <span>
312 <span class="setting-label">{i18n::t(i18n::k::WEEK_START)}</span>
313 <span class="setting-desc">{i18n::t(i18n::k::WEEK_START_DESC)}</span>
314 </span>
315 <select
316 on:change=move |ev| {
317 set_week_start.set(event_target_value(&ev).parse().ok())
318 }
319 >
320 {[(1u8, i18n::k::WEEK_MONDAY), (0, i18n::k::WEEK_SUNDAY), (6, i18n::k::WEEK_SATURDAY)]
321 .map(|(d, label)| view! {
322 <option value=d.to_string() selected=ws == d>{i18n::t(label)}</option>
323 })}
324 </select>
325 </div>
326 <div class="setting-row setting-row-select">
327 <span>
328 <span class="setting-label">{i18n::t(i18n::k::DEFAULT_ROOT_LABEL)}</span>
329 <span class="setting-desc">{i18n::t(i18n::k::DEFAULT_ROOT_DESC)}</span>
330 </span>
331 <select
332 on:change=move |ev| {
333 set_default_root.set(Some(event_target_value(&ev).parse().ok()))
334 }
335 >
336 <option value="" selected=root.is_none()>{i18n::t(i18n::k::DEFAULT_ROOT_NONE)}</option>
337 {roots.iter().map(|r| {
338 let id = r.id;
339 view! {
340 <option value=id.to_string() selected=root == Some(id)>{r.name.clone()}</option>
341 }
342 }).collect::<Vec<_>>()}
343 </select>
344 </div>
345 <div class="modal-actions">
346 <button
347 class="btn btn-primary"
348 disabled=move || profile_busy.get()
349 on:click=move |_| save_profile.run(())
350 >
351 {move || if profile_busy.get() {
352 i18n::t(i18n::k::SAVING).to_string()
353 } else {
354 i18n::t(i18n::k::SAVE_PROFILE).to_string()
355 }}
356 </button>
357 </div>
358 }
359 .into_any()
360 }
361 _ => view! {
362 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
363 }
364 .into_any(),
365 }}
366 </Show>
367 <Show when=move || tab.get() == SettingsTab::Security>
368 <crate::views::security::SecurityView me=me set_me=set_me/>
369 </Show>
370 <Show when=move || tab.get() == SettingsTab::Server && is_admin()>
371 <SettingToggle
372 label=i18n::t(i18n::k::ALLOW_RW_SHARES)
373 desc=i18n::t(i18n::k::ALLOW_RW_SHARES_DESC)
374 value=value
375 set_value=set_value
376 />
377 <div class="excludes-field">
378 <span class="setting-label">{i18n::tr(i18n::k::SEARCH_EXCLUDES)}</span>
379 <div class="excludes-list">
380 {move || {
381 let list = excludes.get();
382 if list.is_empty() {
383 return view! {
384 <p class="muted excludes-empty">
385 {i18n::tr(i18n::k::SEARCH_EXCLUDES_EMPTY)}
386 </p>
387 }
388 .into_any();
389 }
390 list.iter()
391 .enumerate()
392 .map(|(i, path)| {
393 view! {
394 <div class="exclude-row">
395 {icon_svg(IconName::Folder, "ic-row")}
396 <span class="exclude-path">{path.clone()}</span>
397 <button
398 class="icon-btn exclude-remove"
399 title=i18n::t(i18n::k::REMOVE_FOLDER)
400 aria-label=i18n::t(i18n::k::REMOVE_FOLDER)
401 on:click=move |_| {
402 let mut v = excludes.get();
403 if i < v.len() {
404 v.remove(i);
405 }
406 set_excludes.set(v);
407 set_excl_error.set(None);
408 }
409 >
410 {icon_svg(IconName::Close, "ic-btn")}
411 </button>
412 </div>
413 }
414 })
415 .collect::<Vec<_>>()
416 .into_any()
417 }}
418 </div>
419 <button class="btn btn-sm exclude-add" on:click=add_exclude>
420 {icon_svg(IconName::Add, "ic-btn")}
421 {i18n::tr(i18n::k::EXCLUDE_FOLDER)}
422 </button>
423 {move || excl_error.get().map(|e| view! { <p class="dialog-error">{e}</p> })}
424 </div>
425 <div class="modal-actions">
426 <button
427 class="btn btn-primary"
428 disabled=move || busy.get() || value.get().is_none()
429 on:click=move |_| save(())
430 >
431 {move || if busy.get() {
432 i18n::t(i18n::k::SAVING).to_string()
433 } else {
434 i18n::t(i18n::k::SAVE_SETTINGS).to_string()
435 }}
436 </button>
437 </div>
438 </Show>
439 </div>
440 }
441}
442
443// ---------------------------------------------------------------------------
444// Users view
445// ---------------------------------------------------------------------------
446
447/// What the user form is editing: a brand-new user, or an existing one.
448#[derive(Clone)]
449enum Editing {
450 New,
451 Edit(AdminUser),
452}
453
454#[component]
455pub fn UsersView(
456 me: ReadSignal<Option<Me>>,
457 set_me: WriteSignal<Option<Me>>,
458 set_dialog: WriteSignal<Option<Dialog>>,
459) -> impl IntoView {
460 let toast = use_context::<ToastMsg>().expect("toast context");
461 let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
462 let (editing, set_editing) = signal(Option::<Editing>::None);
463
464 let self_id = move || me.get().and_then(|m| m.user).map(|u| u.id);
465
466 let reload = move || {
467 spawn_local(async move {
468 match api::list_admin_users().await {
469 Ok(u) => set_users.set(Some(u)),
470 Err(e) => show_error(toast, e.to_string()),
471 }
472 });
473 };
474 reload();
475
476 // After a save: reload the list, and — if the saved user is the current
477 // one — refresh /me so the sidebar's folders update in place.
478 let on_saved = Callback::new(move |saved: Option<i64>| {
479 reload();
480 if saved.is_some_and(|id| self_id() == Some(id)) {
481 crate::views::shell::refresh_me(set_me, toast);
482 }
483 });
484
485 let delete = move |u: AdminUser| {
486 let is_self = self_id() == Some(u.id);
487 if is_self {
488 return;
489 }
490 let Some(win) = web_sys::window() else { return };
491 let ok = win
492 .confirm_with_message(&i18n::t_fmt(i18n::k::DELETE_USER_CONFIRM, &u.name))
493 .unwrap_or(false);
494 if !ok {
495 return;
496 }
497 let id = u.id;
498 spawn_local(async move {
499 if api::delete_admin_user(id).await.is_ok() {
500 if let Some(mut cur) = users.get() {
501 cur.retain(|x| x.id != id);
502 set_users.set(Some(cur));
503 }
504 } else {
505 show_error(toast, i18n::t(i18n::k::USER_DELETE_ERR).to_string());
506 }
507 });
508 };
509
510 view! {
511 <div class="users-tab">
512 <div class="users-header">
513 <h3 class="admin-section-title">{i18n::tr(i18n::k::USERS)}</h3>
514 <button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
515 {icon_svg(IconName::Add, "ic-btn")}
516 {i18n::tr(i18n::k::NEW_USER)}
517 </button>
518 </div>
519 {move || match users.get() {
520 None => view! {
521 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
522 }
523 .into_any(),
524 Some(ref list) if list.is_empty() => view! {
525 <p class="muted">{i18n::tr(i18n::k::NO_USERS)}</p>
526 }
527 .into_any(),
528 Some(ref list) => {
529 let sid = self_id();
530 let rows = list
531 .iter()
532 .map(|u| {
533 let is_self = sid == Some(u.id);
534 let is_admin = u.is_admin;
535 let u_edit = u.clone();
536 let u_del = u.clone();
537 view! {
538 <div class="user-row">
539 <div class="user-row-main">
540 <div class="user-row-name">
541 {is_admin.then(|| view! {
542 <span class="ic-admin" title=i18n::tr(i18n::k::ADMINISTRATOR)>{icon_svg(IconName::Admin, "")}</span>
543 })}
544 {u.name.clone()}
545 {is_self.then(|| view! { <span class="muted">" " {i18n::tr(i18n::k::USER_YOU)}</span> })}
546 {(!u.active).then(|| view! { <span class="badge badge-off">{i18n::tr(i18n::k::USER_DISABLED)}</span> })}
547 </div>
548 </div>
549 <div class="user-row-actions">
550 <button
551 class="btn btn-sm"
552 on:click=move |_| set_editing.set(Some(Editing::Edit(u_edit.clone())))
553 >
554 {i18n::tr(i18n::k::EDIT)}
555 </button>
556 <button
557 class="btn btn-sm btn-danger"
558 disabled=is_self
559 title=if is_self {
560 i18n::tr(i18n::k::CANT_DELETE_SELF)
561 } else {
562 i18n::tr(i18n::k::DELETE_USER)
563 }
564 on:click=move |_| delete(u_del.clone())
565 >
566 {i18n::tr(i18n::k::DELETE)}
567 </button>
568 </div>
569 </div>
570 }
571 })
572 .collect::<Vec<_>>();
573 view! {
574 <div class="user-list">{rows}</div>
575 }
576 }
577 .into_any(),
578 }}
579 {move || editing.get().map(|e| {
580 let existing = match e {
581 Editing::Edit(u) => Some(u),
582 Editing::New => None,
583 };
584 view! {
585 <UserForm
586 existing=existing
587 self_id=self_id()
588 close=Callback::new(move |_| set_editing.set(None))
589 on_saved=on_saved
590 set_dialog=set_dialog
591 />
592 }
593 })}
594 </div>
595 }
596}
597
598// ---------------------------------------------------------------------------
599// User create / edit form
600// ---------------------------------------------------------------------------
601
602#[derive(Clone)]
603struct RootDraft {
604 path: String,
605 mode: Mode,
606}
607
608#[component]
609fn UserForm(
610 existing: Option<AdminUser>,
611 self_id: Option<i64>,
612 close: Callback<()>,
613 on_saved: Callback<Option<i64>>,
614 set_dialog: WriteSignal<Option<Dialog>>,
615) -> impl IntoView {
616 let toast = use_context::<ToastMsg>().expect("toast context");
617 let is_new = existing.is_none();
618 let is_self = existing
619 .as_ref()
620 .map(|u| self_id == Some(u.id))
621 .unwrap_or(false);
622 let original_name = existing
623 .as_ref()
624 .map(|u| u.name.clone())
625 .unwrap_or_default();
626
627 let (name, set_name) = signal(
628 existing
629 .as_ref()
630 .map(|u| u.name.clone())
631 .unwrap_or_default(),
632 );
633 let (password, set_password) = signal(String::new());
634 let (is_admin, set_is_admin) = signal(existing.as_ref().map(|u| u.is_admin).unwrap_or(false));
635 let (active, set_active) = signal(existing.as_ref().map(|u| u.active).unwrap_or(true));
636 let mut roots0: Vec<RootDraft> = existing
637 .as_ref()
638 .map(|u| {
639 u.roots
640 .iter()
641 .map(|r| RootDraft {
642 path: r.path.clone(),
643 mode: r.mode,
644 })
645 .collect()
646 })
647 .unwrap_or_default();
648 if is_new && roots0.is_empty() {
649 roots0.push(RootDraft {
650 path: ".".to_string(),
651 mode: Mode::Rw,
652 });
653 }
654 let (roots, set_roots) = signal(roots0);
655 let (busy, set_busy) = signal(false);
656 let (error, set_error) = signal(Option::<String>::None);
657 let name_readonly = !is_new;
658
659 // Pick a folder under the server root; "" (the root itself) is stored
660 // as ".".
661 let add_root = move |_| {
662 set_dialog.set(Some(Dialog::Picker {
663 title: i18n::t(i18n::k::ADD_FOLDER).to_string(),
664 confirm: i18n::t(i18n::k::ADD_HERE).to_string(),
665 roots: vec![admin_root()],
666 root: api_types::ADMIN_ROOT,
667 dir: String::new(),
668 for_write: false,
669 on_pick: Callback::new(move |(_, dir): (i64, String)| {
670 let path = if dir.is_empty() { ".".to_string() } else { dir };
671 let mut v = roots.get();
672 if v.iter().any(|r| r.path == path) {
673 set_error.set(Some(i18n::t(i18n::k::ROOT_DUP_ERR).to_string()));
674 return;
675 }
676 v.push(RootDraft {
677 path,
678 mode: Mode::Rw,
679 });
680 set_roots.set(v);
681 set_error.set(None);
682 }),
683 }));
684 };
685
686 let save = move |ev: web_sys::SubmitEvent| {
687 ev.prevent_default();
688 if busy.get() {
689 return;
690 }
691 let n = name.get().trim().to_string();
692 let pw = password.get();
693 let adm = is_admin.get();
694 let act = active.get();
695 let pairs: Vec<(String, Mode)> = roots
696 .get()
697 .iter()
698 .map(|r| (r.path.clone(), r.mode))
699 .collect();
700 // The inputs check the name and the password length.
701 if pairs.is_empty() {
702 set_error.set(Some(i18n::t(i18n::k::NEED_FOLDER).to_string()));
703 return;
704 }
705
706 set_busy.set(true);
707 set_error.set(None);
708 let existing_id = existing.as_ref().map(|u| u.id);
709 spawn_local(async move {
710 let result = if is_new {
711 api::create_admin_user(&n, &pw, adm, &pairs).await
712 } else {
713 let id = existing_id.unwrap_or(0);
714 let pw_opt = if pw.is_empty() {
715 None
716 } else {
717 Some(pw.clone())
718 };
719 api::update_admin_user(id, pw_opt, Some(adm), Some(act), Some(pairs)).await
720 };
721 match result {
722 Ok(_) => {
723 show(
724 toast,
725 if is_new {
726 i18n::t(i18n::k::USER_CREATED).to_string()
727 } else {
728 i18n::t(i18n::k::USER_UPDATED).to_string()
729 },
730 );
731 on_saved.run(existing_id);
732 close.run(());
733 }
734 Err(e) => {
735 show_error(toast, i18n::t_fmt(i18n::k::USER_SAVE_ERR, &e.to_string()));
736 set_busy.set(false);
737 }
738 }
739 });
740 };
741
742 view! {
743 <Modal class="user-form" on_close=close>
744 <form on:submit=save>
745 <h2 class="modal-title">{move || {
746 if is_new {
747 i18n::t(i18n::k::NEW_USER).to_string()
748 } else {
749 i18n::t_fmt(i18n::k::EDIT_USER_TITLE, &original_name)
750 }
751 }}</h2>
752 {move || {
753 let n0 = name.get();
754 view! {
755 <label class="field">
756 <span class="field-label">{i18n::tr(i18n::k::NAME)}</span>
757 <input
758 class="field-input"
759 value=n0.clone()
760 disabled=name_readonly
761 required=true
762 placeholder=i18n::tr(i18n::k::USER_NAME_EXAMPLE)
763 on:input=move |ev| set_name.set(event_target_value(&ev))
764 />
765 </label>
766 }
767 }}
768 <label class="field">
769 <span class="field-label">
770 {if is_new {
771 i18n::t(i18n::k::PASSWORD)
772 } else {
773 i18n::t(i18n::k::NEW_PASSWORD_KEEP)
774 }}
775 </span>
776 <input
777 class="field-input"
778 type="password"
779 value=move || password.get()
780 required=is_new
781 minlength="8"
782 placeholder=i18n::tr(i18n::k::PASSWORD_MIN_HINT)
783 on:input=move |ev| set_password.set(event_target_value(&ev))
784 />
785 </label>
786 {(!is_new)
787 .then(|| {
788 view! {
789 <p class="setting-desc">{i18n::tr(i18n::k::PASSWORD_RESETS_SIGNIN)}</p>
790 }
791 })}
792 <label class="check-row">
793 <input
794 type="checkbox"
795 checked=move || is_admin.get()
796 disabled=is_self
797 on:change=move |ev| set_is_admin.set(event_target_checked(&ev))
798 />
799 {i18n::tr(i18n::k::ADMINISTRATOR)}
800 </label>
801 {(!is_new).then(|| view! {
802 <label class="check-row">
803 <input
804 type="checkbox"
805 checked=move || active.get()
806 disabled=is_self
807 on:change=move |ev| set_active.set(event_target_checked(&ev))
808 />
809 {i18n::tr(i18n::k::ACTIVE_CAN_SIGNIN)}
810 </label>
811 })}
812 <div class="field">
813 <span class="field-label">{i18n::tr(i18n::k::FOLDERS)}</span>
814 <p class="muted field-hint">{i18n::tr(i18n::k::FOLDERS_PATHS_HINT)}</p>
815 <div class="roots-editor">
816 {move || {
817 roots
818 .get()
819 .iter()
820 .enumerate()
821 .map(|(i, r)| {
822 let rw_selected = r.mode == Mode::Rw;
823 let ro_selected = r.mode == Mode::Ro;
824 view! {
825 <div class="root-draft">
826 <span class="root-draft-path">{r.path.clone()}</span>
827 <select
828 class="root-draft-mode"
829 on:change=move |ev| {
830 let mut v = roots.get();
831 if i < v.len()
832 && let Some(m) = Mode::from_wire(&event_target_value(&ev))
833 {
834 v[i].mode = m;
835 }
836 set_roots.set(v);
837 }
838 >
839 <option value=Mode::Rw.as_str() selected=rw_selected>{i18n::t(i18n::k::MODE_RW)}</option>
840 <option value=Mode::Ro.as_str() selected=ro_selected>{i18n::t(i18n::k::MODE_RO)}</option>
841 </select>
842 <button
843 type="button"
844 class="icon-btn icon-btn-sm"
845 title={i18n::tr(i18n::k::REMOVE_FOLDER)}
846 on:click=move |_| {
847 let mut v = roots.get();
848 if i < v.len() {
849 v.remove(i);
850 }
851 set_roots.set(v);
852 }
853 >
854 {icon_svg(IconName::Close, "ic-btn")}
855 </button>
856 </div>
857 }
858 })
859 .collect::<Vec<_>>()
860 }}
861 <button type="button" class="btn root-add" on:click=add_root>
862 {icon_svg(IconName::Folder, "ic-btn")}
863 {i18n::tr(i18n::k::ADD_FOLDER)}
864 </button>
865 </div>
866 </div>
867 {move || error.get().map(|e| view! { <p class="form-error">{e}</p> })}
868 <div class="modal-actions">
869 <button type="button" class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
870 <button
871 type="submit"
872 class="btn btn-primary"
873 disabled=move || busy.get()
874 >
875 {move || {
876 if busy.get() {
877 i18n::t(i18n::k::SAVING).to_string()
878 } else if is_new {
879 i18n::t(i18n::k::CREATE_USER).to_string()
880 } else {
881 i18n::t(i18n::k::SAVE).to_string()
882 }
883 }}
884 </button>
885 </div>
886 </form>
887 </Modal>
888 }
889}
890
891// ---------------------------------------------------------------------------
892// Rooms and resources
893// ---------------------------------------------------------------------------
894
895/// Rooms and resources: principals without a sign-in that can be invited to
896/// meetings and answer on their own.
897#[component]
898pub fn RoomsPanel() -> impl IntoView {
899 let toast = use_context::<ToastMsg>().expect("toast context");
900 let (rooms, set_rooms) = signal(Option::<Vec<RoomInfo>>::None);
901 // `Some(None)` creates a room, `Some(Some(room))` renames one.
902 let (editing, set_editing) = signal(Option::<Option<RoomInfo>>::None);
903 let (deleting, set_deleting) = signal(Option::<RoomInfo>::None);
904 let reload = move || {
905 spawn_local(async move {
906 match api::list_rooms().await {
907 Ok(r) => set_rooms.set(Some(r)),
908 Err(e) => show_error(toast, e.to_string()),
909 }
910 });
911 };
912 reload();
913 let delete = Callback::new(move |_| {
914 let Some(room) = deleting.get() else { return };
915 set_deleting.set(None);
916 spawn_local(async move {
917 match api::delete_room(room.id).await {
918 Ok(_) => {
919 show(toast, i18n::t(i18n::k::DELETED));
920 reload();
921 }
922 Err(e) => show_error(toast, e.to_string()),
923 }
924 });
925 });
926 view! {
927 <div class="users-tab">
928 <div class="users-header">
929 <h3 class="admin-section-title">{i18n::tr(i18n::k::PIM_ROOMS)}</h3>
930 <button class="btn btn-primary" on:click=move |_| set_editing.set(Some(None))>
931 {icon_svg(IconName::Add, "ic-btn")}
932 {i18n::tr(i18n::k::PIM_NEW_ROOM)}
933 </button>
934 </div>
935 <p class="muted view-hint rooms-hint">{i18n::tr(i18n::k::PIM_ROOMS_HINT)}</p>
936 {move || match rooms.get() {
937 None => view! { <p class="muted">{i18n::tr(i18n::k::LOADING)}</p> }.into_any(),
938 Some(list) if list.is_empty() => {
939 view! { <p class="muted">{i18n::tr(i18n::k::PIM_NO_ROOMS)}</p> }.into_any()
940 }
941 Some(list) => view! {
942 <div class="user-list">
943 {list
944 .into_iter()
945 .map(|r| {
946 let (edit, del) = (r.clone(), r.clone());
947 let kind = match r.kind {
948 RoomKind::Room => i18n::t(i18n::k::PIM_KIND_ROOM),
949 RoomKind::Resource => i18n::t(i18n::k::PIM_KIND_RESOURCE),
950 };
951 view! {
952 <div class="user-row">
953 <div class="user-row-main">
954 <div class="user-row-name">
955 <span class="ic-admin">{icon_svg(IconName::Room, "")}</span>
956 {r.display_name.clone()}
957 <span class="badge">{kind}</span>
958 </div>
959 <div class="muted room-address">{r.name.clone()}</div>
960 </div>
961 <div class="user-row-actions">
962 <button
963 class="btn btn-sm"
964 on:click=move |_| set_editing.set(Some(Some(edit.clone())))
965 >
966 {i18n::tr(i18n::k::RENAME)}
967 </button>
968 <button
969 class="btn btn-sm btn-danger"
970 on:click=move |_| set_deleting.set(Some(del.clone()))
971 >
972 {i18n::tr(i18n::k::DELETE)}
973 </button>
974 </div>
975 </div>
976 }
977 })
978 .collect::<Vec<_>>()}
979 </div>
980 }
981 .into_any(),
982 }}
983 {move || editing.get().map(|existing| view! {
984 <RoomForm
985 existing=existing
986 close=Callback::new(move |_| set_editing.set(None))
987 on_saved=Callback::new(move |_| reload())
988 />
989 })}
990 {move || deleting.get().map(|r| view! {
991 <crate::views::dialogs::ConfirmDialog
992 title=i18n::t(i18n::k::DELETE).to_string()
993 message=i18n::t_fmt(i18n::k::PIM_ROOM_DELETE_CONFIRM, &r.display_name)
994 submit=i18n::t(i18n::k::DELETE).to_string()
995 danger=true
996 on_submit=delete
997 on_close=Callback::new(move |_| set_deleting.set(None))
998 />
999 })}
1000 </div>
1001 }
1002}
1003
1004#[component]
1005fn RoomForm(
1006 existing: Option<RoomInfo>,
1007 close: Callback<()>,
1008 on_saved: Callback<()>,
1009) -> impl IntoView {
1010 let toast = use_context::<ToastMsg>().expect("toast context");
1011 let is_new = existing.is_none();
1012 let id = existing.as_ref().map(|r| r.id);
1013 let (name, set_name) = signal(
1014 existing
1015 .as_ref()
1016 .map(|r| r.name.clone())
1017 .unwrap_or_default(),
1018 );
1019 let (display, set_display) = signal(
1020 existing
1021 .as_ref()
1022 .map(|r| r.display_name.clone())
1023 .unwrap_or_default(),
1024 );
1025 let (kind, set_kind) = signal(existing.as_ref().map_or(RoomKind::Room, |r| r.kind));
1026 let (busy, set_busy) = signal(false);
1027 let save = move |ev: web_sys::SubmitEvent| {
1028 ev.prevent_default();
1029 if busy.get() {
1030 return;
1031 }
1032 set_busy.set(true);
1033 let (n, d, k) = (
1034 name.get().trim().to_string(),
1035 display.get().trim().to_string(),
1036 kind.get(),
1037 );
1038 spawn_local(async move {
1039 let result = match id {
1040 None => api::create_room(&n, &d, k).await,
1041 Some(id) => api::update_room(id, &d).await,
1042 };
1043 match result {
1044 Ok(_) => {
1045 show(toast, i18n::t(i18n::k::SAVED));
1046 let _ = on_saved.try_run(());
1047 let _ = close.try_run(());
1048 }
1049 Err(e) => {
1050 show_error(toast, e.to_string());
1051 set_busy.set(false);
1052 }
1053 }
1054 });
1055 };
1056 view! {
1057 <Modal class="user-form" on_close=close>
1058 <form on:submit=save>
1059 <h2 class="modal-title">
1060 {if is_new { i18n::t(i18n::k::PIM_NEW_ROOM) } else { i18n::t(i18n::k::RENAME) }}
1061 </h2>
1062 <label class="field">
1063 <span class="field-label">{i18n::tr(i18n::k::PIM_ROOM_NAME)}</span>
1064 <input
1065 class="field-input"
1066 required=true
1067 disabled=!is_new
1068 prop:value=move || name.get()
1069 on:input=move |ev| set_name.set(event_target_value(&ev))
1070 />
1071 </label>
1072 <label class="field">
1073 <span class="field-label">{i18n::tr(i18n::k::PIM_ROOM_DISPLAY)}</span>
1074 <input
1075 class="field-input"
1076 required=!is_new
1077 prop:value=move || display.get()
1078 on:input=move |ev| set_display.set(event_target_value(&ev))
1079 />
1080 </label>
1081 {is_new.then(|| view! {
1082 <label class="field">
1083 <span class="field-label">{i18n::tr(i18n::k::TYPE)}</span>
1084 <select
1085 class="field-input"
1086 on:change=move |ev| {
1087 set_kind.set(match event_target_value(&ev).as_str() {
1088 "resource" => RoomKind::Resource,
1089 _ => RoomKind::Room,
1090 })
1091 }
1092 >
1093 <option value="room">{i18n::t(i18n::k::PIM_KIND_ROOM)}</option>
1094 <option value="resource">{i18n::t(i18n::k::PIM_KIND_RESOURCE)}</option>
1095 </select>
1096 </label>
1097 })}
1098 <div class="modal-actions">
1099 <button type="button" class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
1100 <button type="submit" class="btn btn-primary" disabled=move || busy.get()>
1101 {move || if busy.get() { i18n::t(i18n::k::SAVING) } else { i18n::t(i18n::k::SAVE) }}
1102 </button>
1103 </div>
1104 </form>
1105 </Modal>
1106 }
1107}
1108