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