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, RwLock};
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 app_name: String,
52 brand_cache: Arc<RwLock<Option<String>>>,
56}
57
58impl AdminState {
59 #[cfg(test)]
60 pub(crate) fn new(auth: AuthService, db: Db) -> Self {
61 Self {
62 auth,
63 db,
64 nav: Arc::new(Vec::new()),
65 settings: Arc::new(Vec::new()),
66 permissions: Arc::new(builtin_permissions()),
67 secure_cookie: false,
68 timezone: Tz::UTC,
69 app_name: "Laterite".to_string(),
70 brand_cache: Arc::new(RwLock::new(None)),
71 }
72 }
73
74 async fn brand(&self) -> String {
78 {
79 let cached = self.brand_cache.read().unwrap().clone();
80 if let Some(name) = cached {
81 return name;
82 }
83 }
84 let resolved = match settings::store::load::<settings::BrandSetting>(&self.db).await {
85 Ok(brand) if !brand.app_name.trim().is_empty() => brand.app_name,
86 _ => self.app_name.clone(),
87 };
88 *self.brand_cache.write().unwrap() = Some(resolved.clone());
89 resolved
90 }
91
92 fn invalidate_brand(&self) {
94 *self.brand_cache.write().unwrap() = None;
95 }
96}
97
98#[derive(Clone)]
101pub struct AdminConfig {
102 pub secure_cookie: bool,
105 pub timezone: String,
109 pub app_name: String,
113}
114
115impl Default for AdminConfig {
116 fn default() -> Self {
117 Self {
118 secure_cookie: false,
119 timezone: "UTC".to_string(),
120 app_name: "Laterite".to_string(),
121 }
122 }
123}
124
125#[derive(Clone)]
126struct NavLink {
127 label: String,
128 path: String,
129 icon: Option<&'static str>,
132}
133
134#[derive(Clone)]
139pub(crate) struct Shell {
140 brand: String,
144 nav: Vec<NavView>,
145 full_name: String,
146 initial: String,
147 tz: Tz,
151 sidebar: Vec<settings::CategoryView>,
155}
156
157impl Shell {
158 fn new(
159 brand: String,
160 nav: &[NavLink],
161 user: &AuthenticatedUser,
162 default_tz: Tz,
163 sidebar: Vec<settings::CategoryView>,
164 active_nav: Option<&str>,
165 ) -> Self {
166 let full_name = user.user.full_name();
167 let initial = full_name
168 .chars()
169 .next()
170 .map(|c| c.to_uppercase().to_string())
171 .unwrap_or_else(|| "?".to_string());
172 Shell {
173 brand,
174 nav: nav
175 .iter()
176 .map(|n| NavView {
177 label: n.label.clone(),
178 path: n.path.clone(),
179 active: active_nav == Some(n.path.as_str()),
180 icon: n.icon.map(|name| icons::svg(Some(name))).unwrap_or(""),
181 })
182 .collect(),
183 full_name,
184 initial,
185 tz: resolve_display_tz(user.user.timezone.as_deref(), default_tz),
186 sidebar,
187 }
188 }
189
190 #[cfg(test)]
191 pub(crate) fn test() -> Self {
192 Shell {
193 brand: "Laterite".to_string(),
194 nav: Vec::new(),
195 full_name: "Test Operator".to_string(),
196 initial: "T".to_string(),
197 tz: Tz::UTC,
198 sidebar: Vec::new(),
199 }
200 }
201}
202
203fn settings_context(visible: &[settings::SettingsItem], path: &str) -> (bool, Option<String>) {
210 if path == "/admin/settings" {
211 (true, None)
212 } else if let Some(code) = path.strip_prefix("/admin/settings/") {
213 (true, Some(code.to_string()))
214 } else {
215 let active = visible
218 .iter()
219 .filter_map(|i| i.link.as_deref().map(|link| (link, &i.code)))
220 .filter(|(link, _)| path == *link || path.starts_with(&format!("{link}/")))
221 .max_by_key(|(link, _)| link.len())
222 .map(|(_, code)| code.clone());
223 (active.is_some(), active)
224 }
225}
226
227fn active_nav_path(nav: &[NavLink], in_settings_context: bool, path: &str) -> Option<String> {
235 if in_settings_context {
236 return Some("/admin/settings".to_string());
237 }
238 nav.iter()
239 .filter(|n| {
240 path == n.path || (n.path != "/admin" && path.starts_with(&format!("{}/", n.path)))
241 })
242 .max_by_key(|n| n.path.len())
243 .map(|n| n.path.clone())
244}
245
246fn resolve_nav_context(
251 nav: &[NavLink],
252 items: &[settings::SettingsItem],
253 perms: &PermissionSet,
254 path: &str,
255) -> (Vec<settings::CategoryView>, Option<String>) {
256 let visible = visible_settings(items, perms);
257 let (in_context, active) = settings_context(&visible, path);
258 let sidebar = if in_context {
259 settings::sidebar_groups(&visible, active.as_deref())
260 } else {
261 Vec::new()
262 };
263 let active_nav = active_nav_path(nav, in_context, path);
264 (sidebar, active_nav)
265}
266
267fn resolve_display_tz(preference: Option<&str>, default_tz: Tz) -> Tz {
271 preference
272 .and_then(|name| name.parse::<Tz>().ok())
273 .unwrap_or(default_tz)
274}
275
276pub struct Resource {
279 pub base_path: String,
280 pub nav_label: String,
281 pub list: list::ListConfig,
282 pub form: Option<form::FormConfig>,
283 pub permission: Option<String>,
288}
289
290#[derive(Clone)]
295pub struct Permission {
296 pub code: String,
297 pub label: String,
298 pub group: String,
299}
300
301fn builtin_permissions() -> Vec<Permission> {
304 vec![
305 Permission {
306 code: "backend.manage_users".to_string(),
307 label: "Manage backend users".to_string(),
308 group: "Backend".to_string(),
309 },
310 Permission {
311 code: "backend.manage_roles".to_string(),
312 label: "Manage roles".to_string(),
313 group: "Backend".to_string(),
314 },
315 Permission {
316 code: "backend.manage_branding".to_string(),
317 label: "Manage branding".to_string(),
318 group: "Backend".to_string(),
319 },
320 ]
321}
322
323pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
338 vec![laterite_auth::migrations(), settings::migrations()]
339}
340
341pub fn router(
346 auth: AuthService,
347 db: Db,
348 app_resources: Vec<Resource>,
349 app_settings: Vec<settings::SettingsItem>,
350 app_permissions: Vec<Permission>,
351 config: AdminConfig,
352) -> Router {
353 let mut resources = builtin_resources();
354 let mut settings = builtin_settings();
355 settings.extend(app_settings);
356 let mut permissions = builtin_permissions();
357 permissions.extend(app_permissions);
358
359 let mut nav = vec![NavLink {
363 label: "Dashboard".to_string(),
364 path: "/admin".to_string(),
365 icon: Some("layout-dashboard"),
366 }];
367 for resource in &app_resources {
368 nav.push(NavLink {
369 label: resource.nav_label.clone(),
370 path: resource.base_path.clone(),
371 icon: None,
372 });
373 }
374 nav.push(NavLink {
375 label: "Settings".to_string(),
376 path: "/admin/settings".to_string(),
377 icon: Some("settings"),
378 });
379 resources.extend(app_resources);
380
381 let app_name = if config.app_name.trim().is_empty() {
382 "Laterite".to_string()
383 } else {
384 config.app_name.clone()
385 };
386 let state = AdminState {
387 auth,
388 db,
389 nav: Arc::new(nav),
390 settings: Arc::new(settings),
391 permissions: Arc::new(permissions),
392 secure_cookie: config.secure_cookie,
393 timezone: config.timezone.parse().unwrap_or(Tz::UTC),
394 app_name,
395 brand_cache: Arc::new(RwLock::new(None)),
396 };
397
398 let mut protected = Router::new().route("/admin", get(dashboard));
399 for resource in &resources {
400 protected = protected.merge(mount_resource(resource));
401 }
402 protected = protected.merge(guard_with_permission(
405 Router::new()
406 .route("/admin/roles/new", get(roles::new_form).post(roles::create))
407 .route(
408 "/admin/roles/{id}/edit",
409 get(roles::edit_form).post(roles::update),
410 ),
411 "backend.manage_roles",
412 ));
413 protected = protected.merge(guard_with_permission(
416 Router::new().route(
417 "/admin/users/{id}/edit",
418 get(users::edit_form).post(users::update),
419 ),
420 "backend.manage_users",
421 ));
422 protected = protected
423 .route("/admin/settings", get(settings_index))
424 .route(
425 "/admin/settings/{code}",
426 get(settings_edit).post(settings_update),
427 )
428 .route(
429 "/admin/preferences",
430 get(preferences_form).post(preferences_update),
431 )
432 .route("/admin/logout", post(logout));
433
434 protected
435 .route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
436 .route("/admin/login", get(login_form).post(login_submit))
440 .route("/admin/setup", get(setup_form).post(setup_submit))
441 .route("/admin/assets/laterite.css", get(asset_css))
442 .route("/admin/assets/mark.svg", get(asset_mark))
443 .route("/admin/assets/mark.png", get(asset_mark_png))
444 .route("/admin/assets/fonts/{file}", get(asset_font))
445 .with_state(state)
446}
447
448async fn asset_mark() -> Response {
450 (
451 [(header::CONTENT_TYPE, "image/svg+xml")],
452 include_str!("../assets/mark.svg"),
453 )
454 .into_response()
455}
456
457async fn asset_mark_png() -> Response {
459 (
460 [
461 (header::CONTENT_TYPE, "image/png"),
462 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
463 ],
464 &include_bytes!("../assets/mark.png")[..],
465 )
466 .into_response()
467}
468
469async fn asset_css() -> Response {
471 (
472 [
473 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
474 (header::CACHE_CONTROL, "no-cache"),
478 ],
479 include_str!("../assets/laterite.css"),
480 )
481 .into_response()
482}
483
484async fn asset_font(Path(file): Path<String>) -> Response {
486 let bytes: &[u8] = match file.as_str() {
487 "space-grotesk-500.woff2" => &include_bytes!("../assets/fonts/space-grotesk-500.woff2")[..],
488 "space-grotesk-600.woff2" => &include_bytes!("../assets/fonts/space-grotesk-600.woff2")[..],
489 "space-grotesk-700.woff2" => &include_bytes!("../assets/fonts/space-grotesk-700.woff2")[..],
490 "ibm-plex-sans-400.woff2" => &include_bytes!("../assets/fonts/ibm-plex-sans-400.woff2")[..],
491 "ibm-plex-sans-600.woff2" => &include_bytes!("../assets/fonts/ibm-plex-sans-600.woff2")[..],
492 "ibm-plex-mono-400.woff2" => &include_bytes!("../assets/fonts/ibm-plex-mono-400.woff2")[..],
493 "ibm-plex-mono-600.woff2" => &include_bytes!("../assets/fonts/ibm-plex-mono-600.woff2")[..],
494 _ => return not_found(),
495 };
496 (
497 [
498 (header::CONTENT_TYPE, "font/woff2"),
499 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
500 ],
501 bytes,
502 )
503 .into_response()
504}
505
506fn mount_resource(resource: &Resource) -> Router<AdminState> {
511 let base = resource.base_path.clone();
512 let list_cfg = resource.list.clone();
513 let mut router = Router::new().route(
514 &base,
515 get(
516 move |State(state): State<AdminState>,
517 Extension(shell): Extension<Shell>,
518 Query(params): Query<list::ListParams>| {
519 let cfg = list_cfg.clone();
520 async move { list::handle(&state, &cfg, params, shell).await }
521 },
522 ),
523 );
524
525 if let Some(form_cfg) = resource.form.clone() {
526 let (new_cfg, create_cfg) = (form_cfg.clone(), form_cfg.clone());
527 router = router.route(
528 &format!("{base}/new"),
529 get(move |Extension(shell): Extension<Shell>| {
530 let cfg = new_cfg.clone();
531 async move { form::new_form(&cfg, shell) }
532 })
533 .post(
534 move |State(state): State<AdminState>,
535 Extension(shell): Extension<Shell>,
536 Form(data): Form<HashMap<String, String>>| {
537 let cfg = create_cfg.clone();
538 async move { form::create(&state, &cfg, data, shell).await }
539 },
540 ),
541 );
542
543 let (edit_cfg, update_cfg) = (form_cfg.clone(), form_cfg.clone());
544 router = router.route(
545 &format!("{base}/{{id}}/edit"),
546 get(
547 move |State(state): State<AdminState>,
548 Extension(shell): Extension<Shell>,
549 Path(id): Path<String>| {
550 let cfg = edit_cfg.clone();
551 async move { form::edit_form(&state, &cfg, id, shell).await }
552 },
553 )
554 .post(
555 move |State(state): State<AdminState>,
556 Extension(shell): Extension<Shell>,
557 Path(id): Path<String>,
558 Form(data): Form<HashMap<String, String>>| {
559 let cfg = update_cfg.clone();
560 async move { form::update(&state, &cfg, id, data, shell).await }
561 },
562 ),
563 );
564 }
565
566 if let Some(permission) = &resource.permission {
568 router = guard_with_permission(router, permission);
569 }
570 router
571}
572
573fn guard_with_permission(router: Router<AdminState>, permission: &str) -> Router<AdminState> {
578 let needed: Arc<str> = Arc::from(permission);
579 router.route_layer(middleware::from_fn(
580 move |Extension(user): Extension<AuthenticatedUser>, req: Request, next: Next| {
581 let needed = needed.clone();
582 async move {
583 if user.allows(&needed) {
584 next.run(req).await
585 } else {
586 forbidden()
587 }
588 }
589 },
590 ))
591}
592
593async fn require_auth(
596 State(state): State<AdminState>,
597 jar: CookieJar,
598 mut request: Request,
599 next: Next,
600) -> Response {
601 let identity = match jar.get(SESSION_COOKIE) {
602 Some(cookie) => state.auth.verify_session(cookie.value()).await.ok(),
603 None => None,
604 };
605 match identity {
606 Some(user) => {
607 let path = request.uri().path().to_string();
608 let (sidebar, active_nav) =
609 resolve_nav_context(&state.nav, &state.settings, &user.permissions, &path);
610 let brand = state.brand().await;
611 let shell = Shell::new(
612 brand,
613 &state.nav,
614 &user,
615 state.timezone,
616 sidebar,
617 active_nav.as_deref(),
618 );
619 request.extensions_mut().insert(user);
620 request.extensions_mut().insert(shell);
621 next.run(request).await
622 }
623 None => Redirect::to("/admin/login").into_response(),
624 }
625}
626
627async fn login_form(State(state): State<AdminState>) -> Response {
628 match state.auth.has_any_operator().await {
630 Ok(false) => Redirect::to("/admin/setup").into_response(),
631 Ok(true) => render(LoginTemplate {
632 brand: state.brand().await,
633 error: None,
634 }),
635 Err(_) => render_error(),
636 }
637}
638
639#[derive(Deserialize)]
640struct LoginForm {
641 username: String,
642 password: String,
643}
644
645async fn login_submit(
646 State(state): State<AdminState>,
647 jar: CookieJar,
648 Form(form): Form<LoginForm>,
649) -> Response {
650 match state
651 .auth
652 .authenticate(&form.username, &form.password, &RequestContext::default())
653 .await
654 {
655 Ok(session) => {
656 let cookie = session_cookie(session.token, state.secure_cookie);
657 (jar.add(cookie), Redirect::to("/admin")).into_response()
658 }
659 Err(_) => render(LoginTemplate {
660 brand: state.brand().await,
661 error: Some("Invalid username or password.".to_string()),
662 }),
663 }
664}
665
666fn session_cookie(token: String, secure: bool) -> Cookie<'static> {
669 Cookie::build((SESSION_COOKIE, token))
670 .path("/admin")
671 .http_only(true)
672 .secure(secure)
673 .same_site(SameSite::Lax)
674 .build()
675}
676
677#[derive(Deserialize)]
678struct SetupForm {
679 username: String,
680 first_name: String,
681 last_name: String,
682 email: String,
683 password: String,
684 timezone: String,
685}
686
687async fn setup_form(State(state): State<AdminState>) -> Response {
690 match state.auth.has_any_operator().await {
691 Ok(true) => Redirect::to("/admin/login").into_response(),
692 Ok(false) => render(setup_view(state.brand().await, state.timezone, None)),
693 Err(_) => render_error(),
694 }
695}
696
697async fn setup_submit(
698 State(state): State<AdminState>,
699 jar: CookieJar,
700 Form(form): Form<SetupForm>,
701) -> Response {
702 match state.auth.has_any_operator().await {
704 Ok(true) => return Redirect::to("/admin/login").into_response(),
705 Ok(false) => {}
706 Err(_) => return render_error(),
707 }
708
709 let username = form.username.trim();
710 let email = form.email.trim();
711 let first_name = form.first_name.trim();
712 let last_name = form.last_name.trim();
713 let tz = form.timezone.trim();
714 if username.is_empty() || email.is_empty() || first_name.is_empty() || form.password.is_empty()
715 {
716 return render(setup_view(
717 state.brand().await,
718 state.timezone,
719 Some("Username, first name, email, and password are all required."),
720 ));
721 }
722 if tz.parse::<Tz>().is_err() {
724 return render(setup_view(
725 state.brand().await,
726 state.timezone,
727 Some("That is not a recognised timezone."),
728 ));
729 }
730
731 let new = NewOperator {
732 username,
733 email,
734 first_name,
735 last_name: (!last_name.is_empty()).then_some(last_name),
736 password: &form.password,
737 timezone: Some(tz),
738 };
739 if state.auth.create_superuser(new).await.is_err() {
740 return render(setup_view(
741 state.brand().await,
742 state.timezone,
743 Some("Could not create the account. The username or email may already be taken."),
744 ));
745 }
746
747 match state
749 .auth
750 .authenticate(username, &form.password, &RequestContext::default())
751 .await
752 {
753 Ok(session) => {
754 let cookie = session_cookie(session.token, state.secure_cookie);
755 (jar.add(cookie), Redirect::to("/admin")).into_response()
756 }
757 Err(_) => Redirect::to("/admin/login").into_response(),
758 }
759}
760
761fn setup_view(brand: String, default_tz: Tz, error: Option<&str>) -> SetupTemplate {
764 let default_name = default_tz.name();
765 let zones = TZ_VARIANTS
766 .iter()
767 .map(|tz| TzOption {
768 name: tz.name().to_string(),
769 selected: tz.name() == default_name,
770 })
771 .collect();
772 SetupTemplate {
773 brand,
774 zones,
775 error: error.map(|e| e.to_string()),
776 }
777}
778
779async fn logout(State(state): State<AdminState>, jar: CookieJar) -> Response {
780 if let Some(cookie) = jar.get(SESSION_COOKIE) {
781 let _ = state.auth.logout(cookie.value()).await;
782 }
783 let removal = Cookie::build((SESSION_COOKIE, "")).path("/admin").build();
784 (jar.remove(removal), Redirect::to("/admin/login")).into_response()
785}
786
787async fn dashboard(
788 Extension(shell): Extension<Shell>,
789 Extension(user): Extension<AuthenticatedUser>,
790) -> Response {
791 render(DashboardTemplate {
792 username: user.user.username,
793 shell,
794 })
795}
796
797fn operator_can_see(item: &settings::SettingsItem, perms: &PermissionSet) -> bool {
800 match &item.permission {
801 None => true,
802 Some(p) => perms.allows(p),
803 }
804}
805
806fn visible_settings(
810 items: &[settings::SettingsItem],
811 perms: &PermissionSet,
812) -> Vec<settings::SettingsItem> {
813 items
814 .iter()
815 .filter(|item| operator_can_see(item, perms))
816 .cloned()
817 .collect()
818}
819
820async fn settings_index(Extension(shell): Extension<Shell>) -> Response {
821 settings::index(shell)
822}
823
824async fn settings_edit(
825 State(state): State<AdminState>,
826 Extension(shell): Extension<Shell>,
827 Extension(user): Extension<AuthenticatedUser>,
828 Path(code): Path<String>,
829) -> Response {
830 let items = visible_settings(&state.settings, &user.permissions);
833 match items
834 .iter()
835 .find(|item| item.code == code && item.link.is_none())
836 {
837 Some(item) => settings::edit_form(&state, item, shell).await,
838 None => not_found(),
839 }
840}
841
842async fn settings_update(
843 State(state): State<AdminState>,
844 Extension(shell): Extension<Shell>,
845 Extension(user): Extension<AuthenticatedUser>,
846 Path(code): Path<String>,
847 Form(data): Form<HashMap<String, String>>,
848) -> Response {
849 let items = visible_settings(&state.settings, &user.permissions);
850 match items
851 .iter()
852 .find(|item| item.code == code && item.link.is_none())
853 {
854 Some(item) => settings::update(&state, item, data, shell).await,
855 None => not_found(),
856 }
857}
858
859#[derive(Deserialize)]
860struct PreferencesQuery {
861 saved: Option<String>,
862}
863
864async fn preferences_form(
866 State(state): State<AdminState>,
867 Extension(shell): Extension<Shell>,
868 Extension(user): Extension<AuthenticatedUser>,
869 Query(query): Query<PreferencesQuery>,
870) -> Response {
871 render(preferences_view(
872 &shell,
873 &user,
874 state.timezone,
875 query.saved.is_some(),
876 None,
877 ))
878}
879
880#[derive(Deserialize)]
881struct PreferencesForm {
882 timezone: String,
883}
884
885async fn preferences_update(
886 State(state): State<AdminState>,
887 Extension(shell): Extension<Shell>,
888 Extension(user): Extension<AuthenticatedUser>,
889 Form(form): Form<PreferencesForm>,
890) -> Response {
891 let trimmed = form.timezone.trim();
892 let stored = if trimmed.is_empty() {
894 None
895 } else if trimmed.parse::<Tz>().is_ok() {
896 Some(trimmed)
897 } else {
898 return render(preferences_view(
899 &shell,
900 &user,
901 state.timezone,
902 false,
903 Some("That is not a recognised timezone."),
904 ));
905 };
906 match state.auth.set_user_timezone(user.user.id, stored).await {
907 Ok(()) => Redirect::to("/admin/preferences?saved=1").into_response(),
908 Err(_) => render_error(),
909 }
910}
911
912fn preferences_view(
916 shell: &Shell,
917 user: &AuthenticatedUser,
918 default_tz: Tz,
919 saved: bool,
920 error: Option<&str>,
921) -> PreferencesTemplate {
922 let current = user.user.timezone.as_deref();
923 let zones = TZ_VARIANTS
924 .iter()
925 .map(|tz| TzOption {
926 name: tz.name().to_string(),
927 selected: current == Some(tz.name()),
928 })
929 .collect();
930 PreferencesTemplate {
931 shell: shell.clone(),
932 zones,
933 effective_tz: shell.tz.name().to_string(),
934 default_tz: default_tz.name().to_string(),
935 inherits: current.is_none(),
936 saved,
937 error: error.map(|e| e.to_string()),
938 }
939}
940
941fn builtin_resources() -> Vec<Resource> {
943 vec![
944 Resource {
945 base_path: "/admin/users".to_string(),
946 nav_label: "Backend Users".to_string(),
947 list: backend_users_list_config(),
948 form: None,
949 permission: Some("backend.manage_users".to_string()),
950 },
951 Resource {
952 base_path: "/admin/roles".to_string(),
953 nav_label: "Roles".to_string(),
954 list: roles_list_config(),
955 form: None,
958 permission: Some("backend.manage_roles".to_string()),
959 },
960 ]
961}
962
963fn builtin_settings() -> Vec<settings::SettingsItem> {
967 vec![
968 settings::SettingsItem {
969 code: "backend.administrators".to_string(),
970 label: "Administrators".to_string(),
971 description: "Manage backend administrator accounts.".to_string(),
972 category: "Users".to_string(),
973 order: 10,
974 icon: Some("users".to_string()),
975 permission: Some("backend.manage_users".to_string()),
976 link: Some("/admin/users".to_string()),
977 fields: Vec::new(),
978 },
979 settings::SettingsItem {
980 code: "backend.roles".to_string(),
981 label: "Roles".to_string(),
982 description: "Manage roles and their permissions.".to_string(),
983 category: "Users".to_string(),
984 order: 20,
985 icon: Some("shield".to_string()),
986 permission: Some("backend.manage_roles".to_string()),
987 link: Some("/admin/roles".to_string()),
988 fields: Vec::new(),
989 },
990 settings::brand::settings_item(),
991 ]
992}
993
994fn backend_users_list_config() -> list::ListConfig {
995 list::ListConfig {
996 entity: "backend_users".to_string(),
997 title: "Backend Users".to_string(),
998 columns: vec![
999 list::ListColumn::new("username", "Username"),
1000 list::ListColumn::new("email", "Email"),
1001 list::ListColumn::new("first_name", "First name"),
1002 list::ListColumn::new("last_name", "Last name"),
1003 list::ListColumn::new("is_superuser", "Superuser").yes_no(),
1004 list::ListColumn::new("is_active", "Active").yes_no(),
1005 list::ListColumn::new("created_at", "Created").datetime(),
1006 ],
1007 order_by: "created_at".to_string(),
1008 order_dir: list::SortDir::Desc,
1009 per_page: 25,
1010 id_field: "id".to_string(),
1011 edit_base: Some("/admin/users".to_string()),
1014 creatable: false,
1015 }
1016}
1017
1018fn roles_list_config() -> list::ListConfig {
1019 list::ListConfig {
1020 entity: "backend_roles".to_string(),
1021 title: "Roles".to_string(),
1022 columns: vec![
1023 list::ListColumn::new("code", "Code"),
1024 list::ListColumn::new("name", "Name"),
1025 list::ListColumn::new("created_at", "Created").datetime(),
1026 ],
1027 order_by: "created_at".to_string(),
1028 order_dir: list::SortDir::Desc,
1029 per_page: 25,
1030 id_field: "id".to_string(),
1031 edit_base: Some("/admin/roles".to_string()),
1032 creatable: true,
1033 }
1034}
1035
1036#[derive(Template)]
1037#[template(path = "login.html")]
1038struct LoginTemplate {
1039 brand: String,
1040 error: Option<String>,
1041}
1042
1043#[derive(Template)]
1044#[template(path = "dashboard.html")]
1045struct DashboardTemplate {
1046 shell: Shell,
1047 username: String,
1048}
1049
1050#[derive(Template)]
1051#[template(path = "setup.html")]
1052struct SetupTemplate {
1053 brand: String,
1054 zones: Vec<TzOption>,
1055 error: Option<String>,
1056}
1057
1058#[derive(Template)]
1059#[template(path = "preferences.html")]
1060struct PreferencesTemplate {
1061 shell: Shell,
1062 zones: Vec<TzOption>,
1063 effective_tz: String,
1065 default_tz: String,
1067 inherits: bool,
1069 saved: bool,
1070 error: Option<String>,
1071}
1072
1073struct TzOption {
1074 name: String,
1075 selected: bool,
1076}
1077
1078#[derive(Clone)]
1079struct NavView {
1080 label: String,
1081 path: String,
1082 active: bool,
1083 icon: &'static str,
1086}
1087
1088pub(crate) fn render<T: Template>(template: T) -> Response {
1090 match template.render() {
1091 Ok(html) => Html(html).into_response(),
1092 Err(_) => render_error(),
1093 }
1094}
1095
1096pub(crate) fn render_error() -> Response {
1097 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
1098}
1099
1100pub(crate) fn not_found() -> Response {
1101 (StatusCode::NOT_FOUND, "Not found").into_response()
1102}
1103
1104pub(crate) fn forbidden() -> Response {
1105 (StatusCode::FORBIDDEN, "Forbidden").into_response()
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1113 fn display_tz_prefers_a_valid_operator_preference() {
1114 assert_eq!(
1115 resolve_display_tz(Some("Asia/Kolkata"), Tz::UTC),
1116 Tz::Asia__Kolkata
1117 );
1118 }
1119
1120 #[test]
1121 fn display_tz_falls_back_when_unset_or_invalid() {
1122 let default = Tz::Europe__London;
1123 assert_eq!(resolve_display_tz(None, default), default);
1125 assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
1127 assert_eq!(resolve_display_tz(Some(""), default), default);
1128 }
1129
1130 async fn empty_db() -> (Db, laterite_core::testing::TestGuard) {
1134 laterite_core::testing::connect_test(&[]).await
1135 }
1136
1137 #[tokio::test]
1138 async fn builtin_migrations_create_the_admin_tables() {
1139 let (db, _guard) = empty_db().await;
1140 laterite_core::migration::run(&db.pool, db.backend, &builtin_migrations())
1141 .await
1142 .unwrap();
1143 for table in ["backend_users", "settings"] {
1147 let probe = sqlx::query(&format!("select 1 from {table} where 1 = 0"))
1148 .fetch_optional(&db.pool)
1149 .await;
1150 assert!(
1151 probe.is_ok(),
1152 "{table} should exist after builtin_migrations"
1153 );
1154 }
1155 }
1156
1157 #[tokio::test]
1158 async fn brand_setting_overrides_config_and_blank_falls_back() {
1159 let (db, _guard) = laterite_core::testing::connect_test(&[settings::migrations()]).await;
1160 let state = AdminState {
1161 auth: AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
1162 db: db.clone(),
1163 nav: Arc::new(Vec::new()),
1164 settings: Arc::new(Vec::new()),
1165 permissions: Arc::new(builtin_permissions()),
1166 secure_cookie: false,
1167 timezone: Tz::UTC,
1168 app_name: "Configured Name".to_string(),
1169 brand_cache: Arc::new(RwLock::new(None)),
1170 };
1171
1172 assert_eq!(state.brand().await, "Configured Name");
1174
1175 settings::store::save(
1178 &db,
1179 &settings::BrandSetting {
1180 app_name: "Acme Corp".to_string(),
1181 },
1182 )
1183 .await
1184 .unwrap();
1185 state.invalidate_brand();
1186 assert_eq!(state.brand().await, "Acme Corp");
1187
1188 settings::store::save(
1190 &db,
1191 &settings::BrandSetting {
1192 app_name: " ".to_string(),
1193 },
1194 )
1195 .await
1196 .unwrap();
1197 state.invalidate_brand();
1198 assert_eq!(state.brand().await, "Configured Name");
1199 }
1200
1201 fn settings_item(code: &str, permission: Option<&str>) -> settings::SettingsItem {
1202 settings::SettingsItem {
1203 code: code.to_string(),
1204 label: code.to_string(),
1205 description: String::new(),
1206 category: "General".to_string(),
1207 order: 1,
1208 icon: None,
1209 permission: permission.map(str::to_string),
1210 link: None,
1211 fields: Vec::new(),
1212 }
1213 }
1214
1215 #[test]
1216 fn settings_visibility_respects_permissions() {
1217 let items = vec![
1218 settings_item("public", None),
1219 settings_item("gated", Some("backend.manage_users")),
1220 ];
1221
1222 let none = PermissionSet::new(false, Vec::<String>::new());
1224 let codes: Vec<String> = visible_settings(&items, &none)
1225 .into_iter()
1226 .map(|i| i.code)
1227 .collect();
1228 assert_eq!(codes, ["public"]);
1229
1230 let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
1232 assert_eq!(visible_settings(&items, &granted).len(), 2);
1233
1234 let superuser = PermissionSet::new(true, Vec::<String>::new());
1236 assert_eq!(visible_settings(&items, &superuser).len(), 2);
1237 }
1238
1239 #[test]
1240 fn context_sidebar_follows_settings_links() {
1241 let items = builtin_settings();
1242 let superuser = PermissionSet::new(true, Vec::<String>::new());
1243 let sidebar = |path: &str| resolve_nav_context(&[], &items, &superuser, path).0;
1244 let active_path = |path: &str| -> Option<String> {
1245 sidebar(path)
1246 .into_iter()
1247 .flat_map(|g| g.items)
1248 .find(|i| i.active)
1249 .map(|i| i.path)
1250 };
1251
1252 assert_eq!(active_path("/admin/users").as_deref(), Some("/admin/users"));
1254 assert_eq!(
1255 active_path("/admin/roles/42/edit").as_deref(),
1256 Some("/admin/roles")
1257 );
1258 assert!(!sidebar("/admin/settings").is_empty());
1260 assert_eq!(active_path("/admin/settings"), None);
1261 assert_eq!(
1263 active_path("/admin/settings/backend.roles").as_deref(),
1264 Some("/admin/roles")
1265 );
1266 assert!(sidebar("/admin").is_empty());
1268 }
1269
1270 #[test]
1271 fn active_nav_lights_the_right_tab() {
1272 let nav = vec![
1273 NavLink {
1274 label: "Dashboard".to_string(),
1275 path: "/admin".to_string(),
1276 icon: Some("layout-dashboard"),
1277 },
1278 NavLink {
1279 label: "Pages".to_string(),
1280 path: "/admin/pages".to_string(),
1281 icon: None,
1282 },
1283 NavLink {
1284 label: "Settings".to_string(),
1285 path: "/admin/settings".to_string(),
1286 icon: Some("settings"),
1287 },
1288 ];
1289
1290 assert_eq!(
1292 active_nav_path(&nav, false, "/admin").as_deref(),
1293 Some("/admin")
1294 );
1295 assert_eq!(
1297 active_nav_path(&nav, false, "/admin/pages/7/edit").as_deref(),
1298 Some("/admin/pages")
1299 );
1300 assert_eq!(active_nav_path(&nav, false, "/admin/pages-archive"), None);
1302 assert_eq!(active_nav_path(&nav, false, "/admin/preferences"), None);
1305 assert_eq!(
1308 active_nav_path(&nav, true, "/admin/users").as_deref(),
1309 Some("/admin/settings")
1310 );
1311 assert_eq!(
1312 active_nav_path(&nav, true, "/admin/settings").as_deref(),
1313 Some("/admin/settings")
1314 );
1315 }
1316}