1pub mod form;
14mod icons;
15pub mod list;
16mod roles;
17pub mod settings;
18mod sql;
19mod users;
20
21use std::collections::HashMap;
22use std::sync::Arc;
23
24use askama::Template;
25use axum::extract::{Path, Query, Request, State};
26use axum::http::{header, StatusCode};
27use axum::middleware::{self, Next};
28use axum::response::{Html, IntoResponse, Redirect, Response};
29use axum::routing::{get, post};
30use axum::{Extension, Form, Router};
31use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
32use chrono_tz::{Tz, TZ_VARIANTS};
33use laterite_auth::{AuthService, AuthenticatedUser, NewOperator, PermissionSet, RequestContext};
34use laterite_core::Db;
35use serde::Deserialize;
36
37const SESSION_COOKIE: &str = "laterite_session";
38
39#[derive(Clone)]
41pub(crate) struct AdminState {
42 auth: AuthService,
43 db: Db,
44 nav: Arc<Vec<NavLink>>,
45 settings: Arc<Vec<settings::SettingsItem>>,
46 permissions: Arc<Vec<Permission>>,
47 secure_cookie: bool,
48 timezone: Tz,
49}
50
51impl AdminState {
52 #[cfg(test)]
53 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
54 Self {
55 auth,
56 db,
57 nav: Arc::new(Vec::new()),
58 settings: Arc::new(Vec::new()),
59 permissions: Arc::new(builtin_permissions()),
60 secure_cookie: false,
61 timezone: Tz::UTC,
62 }
63 }
64}
65
66#[derive(Clone)]
69pub struct AdminConfig {
70 pub secure_cookie: bool,
73 pub timezone: String,
77}
78
79impl Default for AdminConfig {
80 fn default() -> Self {
81 Self {
82 secure_cookie: false,
83 timezone: "UTC".to_string(),
84 }
85 }
86}
87
88#[derive(Clone)]
89struct NavLink {
90 label: String,
91 path: String,
92 icon: Option<&'static str>,
95}
96
97#[derive(Clone)]
102pub(crate) struct Shell {
103 nav: Vec<NavView>,
104 full_name: String,
105 initial: String,
106 tz: Tz,
110 sidebar: Vec<settings::CategoryView>,
114}
115
116impl Shell {
117 fn new(
118 nav: &[NavLink],
119 user: &AuthenticatedUser,
120 default_tz: Tz,
121 sidebar: Vec<settings::CategoryView>,
122 active_nav: Option<&str>,
123 ) -> Self {
124 let full_name = user.user.full_name();
125 let initial = full_name
126 .chars()
127 .next()
128 .map(|c| c.to_uppercase().to_string())
129 .unwrap_or_else(|| "?".to_string());
130 Shell {
131 nav: nav
132 .iter()
133 .map(|n| NavView {
134 label: n.label.clone(),
135 path: n.path.clone(),
136 active: active_nav == Some(n.path.as_str()),
137 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
138 })
139 .collect(),
140 full_name,
141 initial,
142 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
143 sidebar,
144 }
145 }
146
147 #[cfg(test)]
148 pub(crate) fn test() -> Self {
149 Shell {
150 nav: Vec::new(),
151 full_name: "Test Operator".to_string(),
152 initial: "T".to_string(),
153 tz: Tz::UTC,
154 sidebar: Vec::new(),
155 }
156 }
157}
158
159fn settings_context(visible: &[settings::SettingsItem], path: &str) -> (bool, Option<String>) {
166 if path == "/admin/settings" {
167 (true, None)
168 } else if let Some(code) = path.strip_prefix("/admin/settings/") {
169 (true, Some(code.to_string()))
170 } else {
171 let active = visible
174 .iter()
175 .filter_map(|i| i.link.as_deref().map(|link| (link, &i.code)))
176 .filter(|(link, _)| path == *link || path.starts_with(&format!("{link}/")))
177 .max_by_key(|(link, _)| link.len())
178 .map(|(_, code)| code.clone());
179 (active.is_some(), active)
180 }
181}
182
183fn active_nav_path(nav: &[NavLink], in_settings_context: bool, path: &str) -> Option<String> {
191 if in_settings_context {
192 return Some("/admin/settings".to_string());
193 }
194 nav.iter()
195 .filter(|n| {
196 path == n.path || (n.path != "/admin" && path.starts_with(&format!("{}/", n.path)))
197 })
198 .max_by_key(|n| n.path.len())
199 .map(|n| n.path.clone())
200}
201
202fn resolve_nav_context(
207 nav: &[NavLink],
208 items: &[settings::SettingsItem],
209 perms: &PermissionSet,
210 path: &str,
211) -> (Vec<settings::CategoryView>, Option<String>) {
212 let visible = visible_settings(items, perms);
213 let (in_context, active) = settings_context(&visible, path);
214 let sidebar = if in_context {
215 settings::sidebar_groups(&visible, active.as_deref())
216 } else {
217 Vec::new()
218 };
219 let active_nav = active_nav_path(nav, in_context, path);
220 (sidebar, active_nav)
221}
222
223fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
227 preference
228 .and_then(|name| name.parse::<Tz>().ok())
229 .unwrap_or(default_tz)
230}
231
232pub struct Resource {
235 pub base_path: String,
236 pub nav_label: String,
237 pub list: list::ListConfig,
238 pub form: Option<form::FormConfig>,
239 pub permission: Option<String>,
244}
245
246#[derive(Clone)]
251pub struct Permission {
252 pub code: String,
253 pub label: String,
254 pub group: String,
255}
256
257fn builtin_permissions() -> Vec<Permission> {
260 vec![
261 Permission {
262 code: "backend.manage_users".to_string(),
263 label: "Manage backend users".to_string(),
264 group: "Backend".to_string(),
265 },
266 Permission {
267 code: "backend.manage_roles".to_string(),
268 label: "Manage roles".to_string(),
269 group: "Backend".to_string(),
270 },
271 ]
272}
273
274pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
289 vec![laterite_auth::migrations(), settings::migrations()]
290}
291
292pub fn router(
297 auth: AuthService,
298 db: Db,
299 app_resources: Vec<Resource>,
300 app_settings: Vec<settings::SettingsItem>,
301 app_permissions: Vec<Permission>,
302 config: AdminConfig,
303) -> Router {
304 let mut resources = builtin_resources();
305 let mut settings = builtin_settings();
306 settings.extend(app_settings);
307 let mut permissions = builtin_permissions();
308 permissions.extend(app_permissions);
309
310 let mut nav = vec![NavLink {
314 label: "Dashboard".to_string(),
315 path: "/admin".to_string(),
316 icon: Some("layout-dashboard"),
317 }];
318 for resource in &app_resources {
319 nav.push(NavLink {
320 label: resource.nav_label.clone(),
321 path: resource.base_path.clone(),
322 icon: None,
323 });
324 }
325 nav.push(NavLink {
326 label: "Settings".to_string(),
327 path: "/admin/settings".to_string(),
328 icon: Some("settings"),
329 });
330 resources.extend(app_resources);
331
332 let state = AdminState {
333 auth,
334 db,
335 nav: Arc::new(nav),
336 settings: Arc::new(settings),
337 permissions: Arc::new(permissions),
338 secure_cookie: config.secure_cookie,
339 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
340 };
341
342 let mut protected = Router::new().route("/admin", get(dashboard));
343 for resource in &resources {
344 protected = protected.merge(mount_resource(resource));
345 }
346 protected = protected.merge(guard_with_permission(
349 Router::new()
350 .route("/admin/roles/new", get(roles::new_form).post(roles::create))
351 .route(
352 "/admin/roles/{id}/edit",
353 get(roles::edit_form).post(roles::update),
354 ),
355 "backend.manage_roles",
356 ));
357 protected = protected.merge(guard_with_permission(
360 Router::new().route(
361 "/admin/users/{id}/edit",
362 get(users::edit_form).post(users::update),
363 ),
364 "backend.manage_users",
365 ));
366 protected = protected
367 .route("/admin/settings", get(settings_index))
368 .route(
369 "/admin/settings/{code}",
370 get(settings_edit).post(settings_update),
371 )
372 .route(
373 "/admin/preferences",
374 get(preferences_form).post(preferences_update),
375 )
376 .route("/admin/logout", post(logout));
377
378 protected
379 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
380 .route("/admin/login", get(login_form).post(login_submit))
384 .route("/admin/setup", get(setup_form).post(setup_submit))
385 .route("/admin/assets/laterite.css", get(asset_css))
386 .route("/admin/assets/mark.svg", get(asset_mark))
387 .route("/admin/assets/mark.png", get(asset_mark_png))
388 .route("/admin/assets/fonts/{file}", get(asset_font))
389 .with_state(state)
390}
391
392async fn asset_mark() -> Response {
394 (
395 [(header::CONTENT_TYPE, "image/svg+xml")],
396 include_str!("../assets/mark.svg"),
397 )
398 .into_response()
399}
400
401async fn asset_mark_png() -> Response {
403 (
404 [
405 (header::CONTENT_TYPE, "image/png"),
406 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
407 ],
408 &include_bytes!("../assets/mark.png")[..],
409 )
410 .into_response()
411}
412
413async fn asset_css() -> Response {
415 (
416 [
417 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
418 (header::CACHE_CONTROL, "no-cache"),
422 ],
423 include_str!("../assets/laterite.css"),
424 )
425 .into_response()
426}
427
428async fn asset_font(Path(file): Path<String>) -> Response {
430 let bytes: &[u8] = match file.as_str() {
431 "space-grotesk-500.woff2" => &include_bytes!("../assets/fonts/space-grotesk-500.woff2")[..],
432 "space-grotesk-600.woff2" => &include_bytes!("../assets/fonts/space-grotesk-600.woff2")[..],
433 "space-grotesk-700.woff2" => &include_bytes!("../assets/fonts/space-grotesk-700.woff2")[..],
434 "ibm-plex-sans-400.woff2" => &include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")[..],
435 "ibm-plex-sans-600.woff2" => &include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")[..],
436 "ibm-plex-mono-400.woff2" => &include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")[..],
437 "ibm-plex-mono-600.woff2" => &include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")[..],
438 _ => return not_found(),
439 };
440 (
441 [
442 (header::CONTENT_TYPE, "font/woff2"),
443 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
444 ],
445 bytes,
446 )
447 .into_response()
448}
449
450fn mount_resource(resource: &Resource) -> Router<AdminState> {
455 let base = resource.base_path.clone();
456 let list_cfg = resource.list.clone();
457 let mut router = Router::new().route(
458 &base,
459 get(
460 move |State(state): State<AdminState>,
461 Extension(shell): Extension<Shell>,
462 Query(params): Query<list::ListParams>| {
463 let cfg = list_cfg.clone();
464 async move { list::handle(&state, &cfg, params, shell).await }
465 },
466 ),
467 );
468
469 if let Some(form_cfg) = resource.form.clone() {
470 let (new_cfg, create_cfg) = (form_cfg.clone(), form_cfg.clone());
471 router = router.route(
472 &format!("{base}/new"),
473 get(move |Extension(shell): Extension<Shell>| {
474 let cfg = new_cfg.clone();
475 async move { form::new_form(&cfg, shell) }
476 })
477 .post(
478 move |State(state): State<AdminState>,
479 Extension(shell): Extension<Shell>,
480 Form(data): Form<HashMap<String, String>>| {
481 let cfg = create_cfg.clone();
482 async move { form::create(&state, &cfg, data, shell).await }
483 },
484 ),
485 );
486
487 let (edit_cfg, update_cfg) = (form_cfg.clone(), form_cfg.clone());
488 router = router.route(
489 &format!("{base}/{{id}}/edit"),
490 get(
491 move |State(state): State<AdminState>,
492 Extension(shell): Extension<Shell>,
493 Path(id): Path<String>| {
494 let cfg = edit_cfg.clone();
495 async move { form::edit_form(&state, &cfg, id, shell).await }
496 },
497 )
498 .post(
499 move |State(state): State<AdminState>,
500 Extension(shell): Extension<Shell>,
501 Path(id): Path<String>,
502 Form(data): Form<HashMap<String, String>>| {
503 let cfg = update_cfg.clone();
504 async move { form::update(&state, &cfg, id, data, shell).await }
505 },
506 ),
507 );
508 }
509
510 if let Some(permission) = &resource.permission {
512 router = guard_with_permission(router, permission);
513 }
514 router
515}
516
517fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
522 let needed: Arc<str> = Arc::from(permission);
523 router.route_layer(middleware::from_fn(
524 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
525 let needed = needed.clone();
526 async move {
527 if user.allows(&needed) {
528 next.run(req).await
529 } else {
530 forbidden()
531 }
532 }
533 },
534 ))
535}
536
537async fn require_auth(
540 State(state): State<AdminState>,
541 jar: CookieJar,
542 mut request: Request,
543 next: Next,
544) -> Response {
545 let identity = match jar.get(SESSION_COOKIE) {
546 Some(cookie) => state.auth.verify_session(cookie.value()).await.ok(),
547 None => None,
548 };
549 match identity {
550 Some(user) => {
551 let path = request.uri().path().to_string();
552 let (sidebar, active_nav) =
553 resolve_nav_context(&state.nav, &state.settings, &user.permissions, &path);
554 let shell = Shell::new(
555 &state.nav,
556 &user,
557 state.timezone,
558 sidebar,
559 active_nav.as_deref(),
560 );
561 request.extensions_mut().insert(user);
562 request.extensions_mut().insert(shell);
563 next.run(request).await
564 }
565 None => Redirect::to("/admin/login").into_response(),
566 }
567}
568
569async fn login_form(State(state): State<AdminState>) -> Response {
570 match state.auth.has_any_operator().await {
572 Ok(false) => Redirect::to("/admin/setup").into_response(),
573 Ok(true) => render(LoginTemplate { error: None }),
574 Err(_) => render_error(),
575 }
576}
577
578#[derive(Deserialize)]
579struct LoginForm {
580 username: String,
581 password: String,
582}
583
584async fn login_submit(
585 State(state): State<AdminState>,
586 jar: CookieJar,
587 Form(form): Form<LoginForm>,
588) -> Response {
589 match state
590 .auth
591 .authenticate(&form.username, &form.password, &RequestContext::default())
592 .await
593 {
594 Ok(session) => {
595 let cookie = session_cookie(session.token, state.secure_cookie);
596 (jar.add(cookie), Redirect::to("/admin")).into_response()
597 }
598 Err(_) => render(LoginTemplate {
599 error: Some("Invalid username or password.".to_string()),
600 }),
601 }
602}
603
604fn session_cookie(token: String, secure: bool) -> Cookie<'static> {
607 Cookie::build((SESSION_COOKIE, token))
608 .path("/admin")
609 .http_only(true)
610 .secure(secure)
611 .same_site(SameSite::Lax)
612 .build()
613}
614
615#[derive(Deserialize)]
616struct SetupForm {
617 username: String,
618 first_name: String,
619 last_name: String,
620 email: String,
621 password: String,
622 timezone: String,
623}
624
625async fn setup_form(State(state): State<AdminState>) -> Response {
628 match state.auth.has_any_operator().await {
629 Ok(true) => Redirect::to("/admin/login").into_response(),
630 Ok(false) => render(setup_view(state.timezone, None)),
631 Err(_) => render_error(),
632 }
633}
634
635async fn setup_submit(
636 State(state): State<AdminState>,
637 jar: CookieJar,
638 Form(form): Form<SetupForm>,
639) -> Response {
640 match state.auth.has_any_operator().await {
642 Ok(true) => return Redirect::to("/admin/login").into_response(),
643 Ok(false) => {}
644 Err(_) => return render_error(),
645 }
646
647 let username = form.username.trim();
648 let email = form.email.trim();
649 let first_name = form.first_name.trim();
650 let last_name = form.last_name.trim();
651 let tz = form.timezone.trim();
652 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
653 {
654 return render(setup_view(
655 state.timezone,
656 Some("Username, first name, email, and password are all required."),
657 ));
658 }
659 if tz.parse::<Tz>().is_err() {
661 return render(setup_view(
662 state.timezone,
663 Some("That is not a recognised timezone."),
664 ));
665 }
666
667 let new = NewOperator {
668 username,
669 email,
670 first_name,
671 last_name: (!last_name.is_empty()).then_some(last_name),
672 password: &form.password,
673 timezone: Some(tz),
674 };
675 if state.auth.create_superuser(new).await.is_err() {
676 return render(setup_view(
677 state.timezone,
678 Some("Could not create the account. The username or email may already be taken."),
679 ));
680 }
681
682 match state
684 .auth
685 .authenticate(username, &form.password, &RequestContext::default())
686 .await
687 {
688 Ok(session) => {
689 let cookie = session_cookie(session.token, state.secure_cookie);
690 (jar.add(cookie), Redirect::to("/admin")).into_response()
691 }
692 Err(_) => Redirect::to("/admin/login").into_response(),
693 }
694}
695
696fn setup_view(default_tz: Tz, error: Option<&str>) -> SetupTemplate {
699 let default_name = default_tz.name();
700 let zones = TZ_VARIANTS
701 .iter()
702 .map(|tz| TzOption {
703 name: tz.name().to_string(),
704 selected: tz.name() == default_name,
705 })
706 .collect();
707 SetupTemplate {
708 zones,
709 error: error.map(|e| e.to_string()),
710 }
711}
712
713async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
714 if let Some(cookie) = jar.get(SESSION_COOKIE) {
715 let _ = state.auth.logout(cookie.value()).await;
716 }
717 let removal = Cookie::build((SESSION_COOKIE, "")).path("/admin").build();
718 (jar.remove(removal), Redirect::to("/admin/login")).into_response()
719}
720
721async fn dashboard(
722 Extension(shell): Extension<Shell>,
723 Extension(user): Extension<AuthenticatedUser>,
724) -> Response {
725 render(DashboardTemplate {
726 username: user.user.username,
727 shell,
728 })
729}
730
731fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
734 match &item.permission {
735 None => true,
736 Some(p) => perms.allows(p),
737 }
738}
739
740fn visible_settings(
744 items: &[settings::SettingsItem],
745 perms: &PermissionSet,
746) -> Vec<settings::SettingsItem> {
747 items
748 .iter()
749 .filter(|item| operator_can_see(item, perms))
750 .cloned()
751 .collect()
752}
753
754async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
755 settings::index(shell)
756}
757
758async fn settings_edit(
759 State(state): State<AdminState>,
760 Extension(shell): Extension<Shell>,
761 Extension(user): Extension<AuthenticatedUser>,
762 Path(code): Path<String>,
763) -> Response {
764 let items = visible_settings(&state.settings, &user.permissions);
767 match items
768 .iter()
769 .find(|item| item.code == code && item.link.is_none())
770 {
771 Some(item) => settings::edit_form(&state, item, shell).await,
772 None => not_found(),
773 }
774}
775
776async fn settings_update(
777 State(state): State<AdminState>,
778 Extension(shell): Extension<Shell>,
779 Extension(user): Extension<AuthenticatedUser>,
780 Path(code): Path<String>,
781 Form(data): Form<HashMap<String, String>>,
782) -> Response {
783 let items = visible_settings(&state.settings, &user.permissions);
784 match items
785 .iter()
786 .find(|item| item.code == code && item.link.is_none())
787 {
788 Some(item) => settings::update(&state, item, data, shell).await,
789 None => not_found(),
790 }
791}
792
793#[derive(Deserialize)]
794struct PreferencesQuery {
795 saved: Option<String>,
796}
797
798async fn preferences_form(
800 State(state): State<AdminState>,
801 Extension(shell): Extension<Shell>,
802 Extension(user): Extension<AuthenticatedUser>,
803 Query(query): Query<PreferencesQuery>,
804) -> Response {
805 render(preferences_view(
806 &shell,
807 &user,
808 state.timezone,
809 query.saved.is_some(),
810 None,
811 ))
812}
813
814#[derive(Deserialize)]
815struct PreferencesForm {
816 timezone: String,
817}
818
819async fn preferences_update(
820 State(state): State<AdminState>,
821 Extension(shell): Extension<Shell>,
822 Extension(user): Extension<AuthenticatedUser>,
823 Form(form): Form<PreferencesForm>,
824) -> Response {
825 let trimmed = form.timezone.trim();
826 let stored = if trimmed.is_empty() {
828 None
829 } else if trimmed.parse::<Tz>().is_ok() {
830 Some(trimmed)
831 } else {
832 return render(preferences_view(
833 &shell,
834 &user,
835 state.timezone,
836 false,
837 Some("That is not a recognised timezone."),
838 ));
839 };
840 match state.auth.set_user_timezone(user.user.id, stored).await {
841 Ok(()) => Redirect::to("/admin/preferences?saved=1").into_response(),
842 Err(_) => render_error(),
843 }
844}
845
846fn preferences_view(
850 shell: &Shell,
851 user: &AuthenticatedUser,
852 default_tz: Tz,
853 saved: bool,
854 error: Option<&str>,
855) -> PreferencesTemplate {
856 let current = user.user.timezone.as_deref();
857 let zones = TZ_VARIANTS
858 .iter()
859 .map(|tz| TzOption {
860 name: tz.name().to_string(),
861 selected: current == Some(tz.name()),
862 })
863 .collect();
864 PreferencesTemplate {
865 shell: shell.clone(),
866 zones,
867 effective_tz: shell.tz.name().to_string(),
868 default_tz: default_tz.name().to_string(),
869 inherits: current.is_none(),
870 saved,
871 error: error.map(|e| e.to_string()),
872 }
873}
874
875fn builtin_resources() -> Vec<Resource> {
877 vec![
878 Resource {
879 base_path: "/admin/users".to_string(),
880 nav_label: "Backend Users".to_string(),
881 list: backend_users_list_config(),
882 form: None,
883 permission: Some("backend.manage_users".to_string()),
884 },
885 Resource {
886 base_path: "/admin/roles".to_string(),
887 nav_label: "Roles".to_string(),
888 list: roles_list_config(),
889 form: None,
892 permission: Some("backend.manage_roles".to_string()),
893 },
894 ]
895}
896
897fn builtin_settings() -> Vec<settings::SettingsItem> {
901 vec![
902 settings::SettingsItem {
903 code: "backend.administrators".to_string(),
904 label: "Administrators".to_string(),
905 description: "Manage backend administrator accounts.".to_string(),
906 category: "Users".to_string(),
907 order: 10,
908 icon: Some("users".to_string()),
909 permission: Some("backend.manage_users".to_string()),
910 link: Some("/admin/users".to_string()),
911 fields: Vec::new(),
912 },
913 settings::SettingsItem {
914 code: "backend.roles".to_string(),
915 label: "Roles".to_string(),
916 description: "Manage roles and their permissions.".to_string(),
917 category: "Users".to_string(),
918 order: 20,
919 icon: Some("shield".to_string()),
920 permission: Some("backend.manage_roles".to_string()),
921 link: Some("/admin/roles".to_string()),
922 fields: Vec::new(),
923 },
924 ]
925}
926
927fn backend_users_list_config() -> list::ListConfig {
928 list::ListConfig {
929 entity: "backend_users".to_string(),
930 title: "Backend Users".to_string(),
931 columns: vec![
932 list::ListColumn::new("username", "Username"),
933 list::ListColumn::new("email", "Email"),
934 list::ListColumn::new("first_name", "First name"),
935 list::ListColumn::new("last_name", "Last name"),
936 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
937 list::ListColumn::new("is_active", "Active").yes_no(),
938 list::ListColumn::new("created_at", "Created").datetime(),
939 ],
940 order_by: "created_at".to_string(),
941 order_dir: list::SortDir::Desc,
942 per_page: 25,
943 id_field: "id".to_string(),
944 edit_base: Some("/admin/users".to_string()),
947 creatable: false,
948 }
949}
950
951fn roles_list_config() -> list::ListConfig {
952 list::ListConfig {
953 entity: "backend_roles".to_string(),
954 title: "Roles".to_string(),
955 columns: vec![
956 list::ListColumn::new("code", "Code"),
957 list::ListColumn::new("name", "Name"),
958 list::ListColumn::new("created_at", "Created").datetime(),
959 ],
960 order_by: "created_at".to_string(),
961 order_dir: list::SortDir::Desc,
962 per_page: 25,
963 id_field: "id".to_string(),
964 edit_base: Some("/admin/roles".to_string()),
965 creatable: true,
966 }
967}
968
969#[derive(Template)]
970#[template(path = "login.html")]
971struct LoginTemplate {
972 error: Option<String>,
973}
974
975#[derive(Template)]
976#[template(path = "dashboard.html")]
977struct DashboardTemplate {
978 shell: Shell,
979 username: String,
980}
981
982#[derive(Template)]
983#[template(path = "setup.html")]
984struct SetupTemplate {
985 zones: Vec<TzOption>,
986 error: Option<String>,
987}
988
989#[derive(Template)]
990#[template(path = "preferences.html")]
991struct PreferencesTemplate {
992 shell: Shell,
993 zones: Vec<TzOption>,
994 effective_tz: String,
996 default_tz: String,
998 inherits: bool,
1000 saved: bool,
1001 error: Option<String>,
1002}
1003
1004struct TzOption {
1005 name: String,
1006 selected: bool,
1007}
1008
1009#[derive(Clone)]
1010struct NavView {
1011 label: String,
1012 path: String,
1013 active: bool,
1014 icon: &'static str,
1017}
1018
1019pub(crate) fn render<T: Template>(template: T) -> Response {
1021 match template.render() {
1022 Ok(html) => Html(html).into_response(),
1023 Err(_) => render_error(),
1024 }
1025}
1026
1027pub(crate) fn render_error() -> Response {
1028 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
1029}
1030
1031pub(crate) fn not_found() -> Response {
1032 (StatusCode::NOT_FOUND, "Not found").into_response()
1033}
1034
1035pub(crate) fn forbidden() -> Response {
1036 (StatusCode::FORBIDDEN, "Forbidden").into_response()
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 #[test]
1044 fn display_tz_prefers_a_valid_operator_preference() {
1045 assert_eq!(
1046 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
1047 Tz::Asia__Kolkata
1048 );
1049 }
1050
1051 #[test]
1052 fn display_tz_falls_back_when_unset_or_invalid() {
1053 let default = Tz::Europe__London;
1054 assert_eq!(resolve_display_tz(None, default), default);
1056 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
1058 assert_eq!(resolve_display_tz(Some(""), default), default);
1059 }
1060
1061 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
1065 laterite_core::testing::connect_test(&[]).await
1066 }
1067
1068 #[tokio::test]
1069 async fn builtin_migrations_create_the_admin_tables() {
1070 let (db, _guard) = empty_db().await;
1071 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
1072 .await
1073 .unwrap();
1074 for table in ["backend_users", "settings"] {
1078 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
1079 .fetch_optional(&db.pool)
1080 .await;
1081 assert!(
1082 probe.is_ok(),
1083 "{table} should exist after builtin_migrations"
1084 );
1085 }
1086 }
1087
1088 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
1089 settings::SettingsItem {
1090 code: code.to_string(),
1091 label: code.to_string(),
1092 description: String::new(),
1093 category: "General".to_string(),
1094 order: 1,
1095 icon: None,
1096 permission: permission.map(str::to_string),
1097 link: None,
1098 fields: Vec::new(),
1099 }
1100 }
1101
1102 #[test]
1103 fn settings_visibility_respects_permissions() {
1104 let items = vec![
1105 settings_item("public", None),
1106 settings_item("gated", Some("backend.manage_users")),
1107 ];
1108
1109 let none = PermissionSet::new(false, Vec::<String>::new());
1111 let codes: Vec<String> = visible_settings(&items, &none)
1112 .into_iter()
1113 .map(|i| i.code)
1114 .collect();
1115 assert_eq!(codes, ["public"]);
1116
1117 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
1119 assert_eq!(visible_settings(&items, &granted).len(), 2);
1120
1121 let superuser = PermissionSet::new(true, Vec::<String>::new());
1123 assert_eq!(visible_settings(&items, &superuser).len(), 2);
1124 }
1125
1126 #[test]
1127 fn context_sidebar_follows_settings_links() {
1128 let items = builtin_settings();
1129 let superuser = PermissionSet::new(true, Vec::<String>::new());
1130 let sidebar = |path: &str| resolve_nav_context(&[], &items, &superuser, path).0;
1131 let active_path = |path: &str| -> Option<String> {
1132 sidebar(path)
1133 .into_iter()
1134 .flat_map(|g| g.items)
1135 .find(|i| i.active)
1136 .map(|i| i.path)
1137 };
1138
1139 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
1141 assert_eq!(
1142 active_path("/admin/roles/42/edit").as_deref(),
1143 Some("/admin/roles")
1144 );
1145 assert!(!sidebar("/admin/settings").is_empty());
1147 assert_eq!(active_path("/admin/settings"), None);
1148 assert_eq!(
1150 active_path("/admin/settings/backend.roles").as_deref(),
1151 Some("/admin/roles")
1152 );
1153 assert!(sidebar("/admin").is_empty());
1155 }
1156
1157 #[test]
1158 fn active_nav_lights_the_right_tab() {
1159 let nav = vec![
1160 NavLink {
1161 label: "Dashboard".to_string(),
1162 path: "/admin".to_string(),
1163 icon: Some("layout-dashboard"),
1164 },
1165 NavLink {
1166 label: "Pages".to_string(),
1167 path: "/admin/pages".to_string(),
1168 icon: None,
1169 },
1170 NavLink {
1171 label: "Settings".to_string(),
1172 path: "/admin/settings".to_string(),
1173 icon: Some("settings"),
1174 },
1175 ];
1176
1177 assert_eq!(
1179 active_nav_path(&nav, false, "/admin").as_deref(),
1180 Some("/admin")
1181 );
1182 assert_eq!(
1184 active_nav_path(&nav, false, "/admin/pages/7/edit").as_deref(),
1185 Some("/admin/pages")
1186 );
1187 assert_eq!(active_nav_path(&nav, false, "/admin/pages-archive"), None);
1189 assert_eq!(active_nav_path(&nav, false, "/admin/preferences"), None);
1192 assert_eq!(
1195 active_nav_path(&nav, true, "/admin/users").as_deref(),
1196 Some("/admin/settings")
1197 );
1198 assert_eq!(
1199 active_nav_path(&nav, true, "/admin/settings").as_deref(),
1200 Some("/admin/settings")
1201 );
1202 }
1203}