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