Skip to main content

laterite_admin/
lib.rs

1//! Laterite admin: the operator-facing web surface.
2//!
3//! An Axum router mounted at `/admin`: a login screen and session cookie
4//! verified against `laterite-auth`, and descriptor-driven screens.
5//!
6//! Screens are **resources**: a module declares a [`Resource`] (a
7//! [`list::ListConfig`], optionally a [`form::FormConfig`], a base path, and a
8//! menu label), and the framework mounts the list, create, and edit routes and
9//! adds it to the menu. This is the extension point that lets an application
10//! contribute its own admin screens. The framework's own screens (users, roles)
11//! are just built-in resources.
12
13pub 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/// Shared state for the admin router. Constructed by [`router`].
40#[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    /// The configured application name (the baseline brand). A brand setting
50    /// overrides it; see [`AdminState::brand`].
51    app_name: String,
52    /// The resolved brand name, cached across requests so the brand setting is
53    /// not read from the database on every page. Invalidated when the setting is
54    /// saved. `None` means "not resolved yet".
55    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    /// The brand name shown across the admin: the [`settings::BrandSetting`]
75    /// `app_name` when set, otherwise the configured application name. The
76    /// resolved value is cached until [`AdminState::invalidate_brand`] clears it.
77    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    /// Clears the cached brand so the next resolution re-reads the setting.
93    fn invalidate_brand(&self) {
94        *self.brand_cache.write().unwrap() = None;
95    }
96}
97
98/// Deployment-level admin settings passed to [`router`]. Per-install brand and
99/// per-operator preferences are settings/preferences, not deployment config.
100#[derive(Clone)]
101pub struct AdminConfig {
102    /// Set the `Secure` attribute on the session cookie. Enable behind HTTPS in
103    /// production; leave off for plain-HTTP local development.
104    pub secure_cookie: bool,
105    /// Default display timezone for the admin (an IANA name like `Asia/Kolkata`).
106    /// Storage is UTC; this only affects rendering. Invalid or empty falls back
107    /// to UTC. An operator's own preference overrides it (later).
108    pub timezone: String,
109    /// The application name, shown as the admin brand. This is the baseline; a
110    /// `BrandSetting` in the admin overrides it. Typically the configured
111    /// `app.name`. Empty falls back to `Laterite`.
112    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    /// An icon name (a Lucide subset, see [`icons`]), or `None` for a text-only
130    /// tab. The built-in Dashboard and Settings entries set one.
131    icon: Option<&'static str>,
132}
133
134/// The chrome shared by every authenticated page: the top-nav links and the
135/// signed-in operator. Built once by the auth guard and injected into request
136/// extensions, so page handlers render inside the same shell without each
137/// rebuilding it. Templates embed it as `shell` and `base.html` renders it.
138#[derive(Clone)]
139pub(crate) struct Shell {
140    /// The brand name shown in the top nav and drawer, resolved once per request
141    /// (the brand setting, or the configured application name). See
142    /// [`AdminState::brand`].
143    brand: String,
144    nav: Vec<NavView>,
145    full_name: String,
146    initial: String,
147    /// The timezone this operator's timestamps render in, resolved once per
148    /// request: the operator's own preference if set and valid, else the
149    /// deployment default. List and detail screens format dates in it.
150    tz: Tz,
151    /// The context sidebar for the current section, resolved once per request
152    /// from the path (see [`resolve_nav_context`]). Empty means no sidebar.
153    /// `base.html` renders it, so any screen in a settings context shows it.
154    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
203/// Whether `path` sits in the settings context, and if so which item code is
204/// active. A screen is in the settings context when it is the settings index or
205/// a settings form, or when its path falls under a settings item's `link` (its
206/// list, forms and sub-pages). The matching item is returned so the sidebar can
207/// highlight it. `visible` is the operator's permitted items, so a linked
208/// resource they cannot see never claims the context.
209fn 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        // A linked resource: the settings item whose link is the longest prefix
216        // of this path owns the context (so /admin/roles/5/edit still resolves).
217        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
227/// The top-nav item to highlight for `path`. A screen in the settings context
228/// lights the Settings tab (so a linked resource such as the users list keeps
229/// Settings active); otherwise a section owns its own path subtree and stays
230/// active across its sub-pages, with the longest matching prefix winning. The
231/// `/admin` root is every path's ancestor, so it lights the Dashboard tab only
232/// on an exact match: a screen belonging to no section (Preferences, say) lights
233/// nothing rather than falling back to Dashboard.
234fn 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
246/// Resolves the per-request navigation context from the descriptors: the
247/// settings sidebar (empty when the screen sits outside any settings context)
248/// and the top-nav item to highlight. The auth guard runs this once per request
249/// and hands both to the [`Shell`].
250fn 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
267/// Resolves the timezone an operator's timestamps render in: their own
268/// preference when it is set and a valid IANA name, otherwise the deployment
269/// default. An unparseable stored value falls back rather than erroring.
270fn 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
276/// An admin resource: a list screen, optionally with a create/edit form, mounted
277/// under `base_path` and shown in the menu as `nav_label`.
278pub struct Resource {
279    pub base_path: String,
280    pub nav_label: String,
281    pub list: list::ListConfig,
282    pub form: Option<form::FormConfig>,
283    /// The permission an operator must hold to reach any of the resource's
284    /// routes. `None` leaves the resource open to any signed-in operator; a
285    /// dotted string gates every route the resource mounts, and an operator who
286    /// lacks it receives `403 Forbidden`. Superusers pass regardless.
287    pub permission: Option<String>,
288}
289
290/// A permission an operator can be granted: a dotted `code`, a human `label`,
291/// and a `group` heading it sorts under in the role editor. The framework
292/// registers its own (see the built-in grants), and an application registers
293/// its permissions through [`router`] so they appear in the editor alongside.
294#[derive(Clone)]
295pub struct Permission {
296    pub code: String,
297    pub label: String,
298    pub group: String,
299}
300
301/// The framework's own permissions, offered in the role editor under a
302/// "Backend" group. These gate the built-in Users and Roles screens.
303fn 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
323/// The migration sets for every module the admin mounts: the auth schema
324/// (users, roles, sessions, access log) and the settings store. Run these
325/// before serving [`router`] so its built-in screens have their tables, so an
326/// application never has to know which framework modules the admin pulls in.
327///
328/// An application with its own modules appends their sets:
329///
330/// ```no_run
331/// # async fn f(db: laterite_core::Db) -> Result<(), Box<dyn std::error::Error>> {
332/// let mut migrations = laterite_admin::builtin_migrations();
333/// // migrations.extend([my_module::migrations()]);
334/// laterite_core::migration::run(&db.pool, db.backend, &migrations).await?;
335/// # Ok(()) }
336/// ```
337pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
338    vec![laterite_auth::migrations(), settings::migrations()]
339}
340
341/// Builds the admin router. `app_resources` are the application's own list/form
342/// screens; `app_settings` are its settings models; `app_permissions` are the
343/// permissions it defines, offered in the role editor alongside the framework's.
344/// All are mounted alongside the framework's built-in equivalents.
345pub 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    // Main menu (top nav): Dashboard, the application's own sections, then
360    // Settings. Built-in Users and Roles are settings items (see the settings
361    // menu), not main-menu tabs.
362    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    // The roles screen has a dedicated create/edit form (the permission editor),
403    // gated by the same permission as its list.
404    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    // The backend users screen edits a user's per-permission overrides, gated by
414    // the same permission as its list.
415    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        // Public routes (not covered by the guard above): the login and first-run
437        // setup screens and the embedded stylesheet and fonts (needed before
438        // authentication).
439        .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
448/// Serves the embedded brick mark (SVG badge, used for the favicon).
449async fn asset_mark() -> Response {
450    (
451        [(header::CONTENT_TYPE, "image/svg+xml")],
452        include_str!("../assets/mark.svg"),
453    )
454        .into_response()
455}
456
457/// Serves the embedded brick mark (PNG, the visible logo).
458async 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
469/// Serves the embedded admin stylesheet.
470async fn asset_css() -> Response {
471    (
472        [
473            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
474            // The stylesheet is embedded in the binary, so it changes only when
475            // the binary does. Revalidating on each load keeps a browser from
476            // serving a stale copy after an upgrade.
477            (header::CACHE_CONTROL, "no-cache"),
478        ],
479        include_str!("../assets/laterite.css"),
480    )
481        .into_response()
482}
483
484/// Serves an embedded webfont by file name.
485async 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
506/// Builds a resource's list, create, and edit routes as generic handlers that
507/// carry the resource's descriptors. When the resource sets a `permission`, every
508/// route it mounts is wrapped in a guard that answers `403 Forbidden` for an
509/// operator who lacks it; the caller merges the result into the protected router.
510fn 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    // Gate every route the resource mounts on its permission.
567    if let Some(permission) = &resource.permission {
568        router = guard_with_permission(router, permission);
569    }
570    router
571}
572
573/// Wraps every route currently in `router` in a permission guard: the auth guard
574/// runs first and injects the identity, so this reads it from the request
575/// extensions and answers `403 Forbidden` for an operator who lacks `permission`.
576/// Shared by resource mounting and the roles permission editor.
577fn 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
593/// Redirects unauthenticated requests to the login screen, and injects the
594/// resolved identity into request extensions for downstream handlers.
595async 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    // A fresh install with no operators goes to first-run setup instead.
629    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
666/// Builds the session cookie, scoped to the admin and flagged `Secure` behind
667/// HTTPS. Shared by login and first-run setup.
668fn 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
687/// The first-run setup screen: shown only while no operator exists, so a fresh
688/// install can create its first administrator without the CLI.
689async 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    // Setup only ever creates the first operator; once one exists it is closed.
703    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    // The setup select always carries a value, but guard against a bad one.
723    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    // Sign the new administrator straight in through the normal login path.
748    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
761/// Builds the setup view, its timezone select defaulting to the deployment
762/// default so the first administrator can accept or change it.
763fn 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
797/// Whether an operator may see a settings item: items with no permission are
798/// public, otherwise the operator must hold the item's permission.
799fn 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
806/// The settings items this operator may see, in registry order. Both the index
807/// and the form use this set, so an operator never sees or edits an item their
808/// permissions do not allow.
809fn 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    // Filter first, so an operator cannot open a settings form they lack the
831    // permission to see.
832    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
864/// The self-service Preferences screen for the signed-in operator.
865async 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    // An empty choice clears the preference so the operator inherits the default.
893    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
912/// Builds the Preferences view. `shell.tz` is the timezone currently in force
913/// (the operator's preference or the default); the operator's stored preference
914/// selects the matching option, or the inherit option when unset.
915fn 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
941/// The framework's own admin screens.
942fn 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            // The create/edit form is the dedicated permission editor (see the
956            // `roles` module), mounted separately, not the generic form.
957            form: None,
958            permission: Some("backend.manage_roles".to_string()),
959        },
960    ]
961}
962
963/// The framework's own settings items. The built-in Users and Roles resources
964/// appear in the settings menu under a Users category (linking to their list
965/// screens), rather than as main-menu tabs.
966fn 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        // Rows link to the per-user permission editor; users are created from the
1012        // CLI or first-run setup, so no "New" screen here.
1013        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    /// The timezone dates currently render in for this operator.
1064    effective_tz: String,
1065    /// The deployment default, named in the inherit option.
1066    default_tz: String,
1067    /// Whether the operator currently inherits the default (no preference set).
1068    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    /// Inline SVG for the tab's icon, or empty for a text-only tab. Rendered raw
1084    /// with `|safe`.
1085    icon: &'static str,
1086}
1087
1088/// Renders a template to an HTML response, mapping a render failure to a 500.
1089pub(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        // No preference: use the deployment default.
1124        assert_eq!(resolve_display_tz(None, default), default);
1125        // Junk stored value: fall back rather than error.
1126        assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
1127        assert_eq!(resolve_display_tz(Some(""), default), default);
1128    }
1129
1130    /// A fresh test database with no migrations applied, the blank slate an
1131    /// application starts from before it runs its migration set. Hold the guard
1132    /// for the test's lifetime.
1133    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        // A table from each bundled module exists, so an app that only ran
1144        // builtin_migrations has everything the admin's screens need. A no-row
1145        // probe succeeds only if the table exists, portably on every backend.
1146        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        // With no brand setting, the configured application name is the brand.
1173        assert_eq!(state.brand().await, "Configured Name");
1174
1175        // A brand setting overrides the configured name (cache re-reads after
1176        // invalidation).
1177        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        // A blank brand setting falls back to the configured name.
1189        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        // An operator without the grant sees only the unpermissioned item.
1223        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        // Holding the permission reveals the gated item.
1231        let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
1232        assert_eq!(visible_settings(&items, &granted).len(), 2);
1233
1234        // A superuser sees everything.
1235        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        // A linked resource, and its sub-pages, resolve to that item as active.
1253        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        // The settings index shows the sidebar, with nothing active.
1259        assert!(!sidebar("/admin/settings").is_empty());
1260        assert_eq!(active_path("/admin/settings"), None);
1261        // A settings form activates its own item.
1262        assert_eq!(
1263            active_path("/admin/settings/backend.roles").as_deref(),
1264            Some("/admin/roles")
1265        );
1266        // The dashboard is not a settings context, so it has no sidebar.
1267        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        // Dashboard lights only on an exact match, never as a prefix of deeper paths.
1291        assert_eq!(
1292            active_nav_path(&nav, false, "/admin").as_deref(),
1293            Some("/admin")
1294        );
1295        // A section keeps its own tab active across its sub-pages.
1296        assert_eq!(
1297            active_nav_path(&nav, false, "/admin/pages/7/edit").as_deref(),
1298            Some("/admin/pages")
1299        );
1300        // A sibling section that merely shares a prefix does not steal the tab.
1301        assert_eq!(active_nav_path(&nav, false, "/admin/pages-archive"), None);
1302        // A screen under no section (reached from the user menu) lights nothing,
1303        // rather than the root falling back to Dashboard.
1304        assert_eq!(active_nav_path(&nav, false, "/admin/preferences"), None);
1305        // The settings context lights Settings, including for a linked resource
1306        // whose path lives outside /admin/settings.
1307        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}