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;
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}
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/// Deployment-level admin settings passed to [`router`]. Per-install brand and
67/// per-operator preferences are settings/preferences, not deployment config.
68#[derive(Clone)]
69pub struct AdminConfig {
70    /// Set the `Secure` attribute on the session cookie. Enable behind HTTPS in
71    /// production; leave off for plain-HTTP local development.
72    pub secure_cookie: bool,
73    /// Default display timezone for the admin (an IANA name like `Asia/Kolkata`).
74    /// Storage is UTC; this only affects rendering. Invalid or empty falls back
75    /// to UTC. An operator's own preference overrides it (later).
76    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    /// An icon name (a Lucide subset, see [`icons`]), or `None` for a text-only
93    /// tab. The built-in Dashboard and Settings entries set one.
94    icon: Option<&'static str>,
95}
96
97/// The chrome shared by every authenticated page: the top-nav links and the
98/// signed-in operator. Built once by the auth guard and injected into request
99/// extensions, so page handlers render inside the same shell without each
100/// rebuilding it. Templates embed it as `shell` and `base.html` renders it.
101#[derive(Clone)]
102pub(crate) struct Shell {
103    nav: Vec<NavView>,
104    full_name: String,
105    initial: String,
106    /// The timezone this operator's timestamps render in, resolved once per
107    /// request: the operator's own preference if set and valid, else the
108    /// deployment default. List and detail screens format dates in it.
109    tz: Tz,
110    /// The context sidebar for the current section, resolved once per request
111    /// from the path (see [`resolve_nav_context`]). Empty means no sidebar.
112    /// `base.html` renders it, so any screen in a settings context shows it.
113    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
159/// Whether `path` sits in the settings context, and if so which item code is
160/// active. A screen is in the settings context when it is the settings index or
161/// a settings form, or when its path falls under a settings item's `link` (its
162/// list, forms and sub-pages). The matching item is returned so the sidebar can
163/// highlight it. `visible` is the operator's permitted items, so a linked
164/// resource they cannot see never claims the context.
165fn 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        // A linked resource: the settings item whose link is the longest prefix
172        // of this path owns the context (so /admin/roles/5/edit still resolves).
173        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
183/// The top-nav item to highlight for `path`. A screen in the settings context
184/// lights the Settings tab (so a linked resource such as the users list keeps
185/// Settings active); otherwise a section owns its own path subtree and stays
186/// active across its sub-pages, with the longest matching prefix winning. The
187/// `/admin` root is every path's ancestor, so it lights the Dashboard tab only
188/// on an exact match: a screen belonging to no section (Preferences, say) lights
189/// nothing rather than falling back to Dashboard.
190fn 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
202/// Resolves the per-request navigation context from the descriptors: the
203/// settings sidebar (empty when the screen sits outside any settings context)
204/// and the top-nav item to highlight. The auth guard runs this once per request
205/// and hands both to the [`Shell`].
206fn 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
223/// Resolves the timezone an operator's timestamps render in: their own
224/// preference when it is set and a valid IANA name, otherwise the deployment
225/// default. An unparseable stored value falls back rather than erroring.
226fn 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
232/// An admin resource: a list screen, optionally with a create/edit form, mounted
233/// under `base_path` and shown in the menu as `nav_label`.
234pub struct Resource {
235    pub base_path: String,
236    pub nav_label: String,
237    pub list: list::ListConfig,
238    pub form: Option<form::FormConfig>,
239    /// The permission an operator must hold to reach any of the resource's
240    /// routes. `None` leaves the resource open to any signed-in operator; a
241    /// dotted string gates every route the resource mounts, and an operator who
242    /// lacks it receives `403 Forbidden`. Superusers pass regardless.
243    pub permission: Option<String>,
244}
245
246/// A permission an operator can be granted: a dotted `code`, a human `label`,
247/// and a `group` heading it sorts under in the role editor. The framework
248/// registers its own (see the built-in grants), and an application registers
249/// its permissions through [`router`] so they appear in the editor alongside.
250#[derive(Clone)]
251pub struct Permission {
252    pub code: String,
253    pub label: String,
254    pub group: String,
255}
256
257/// The framework's own permissions, offered in the role editor under a
258/// "Backend" group. These gate the built-in Users and Roles screens.
259fn 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
274/// The migration sets for every module the admin mounts: the auth schema
275/// (users, roles, sessions, access log) and the settings store. Run these
276/// before serving [`router`] so its built-in screens have their tables, so an
277/// application never has to know which framework modules the admin pulls in.
278///
279/// An application with its own modules appends their sets:
280///
281/// ```no_run
282/// # async fn f(db: laterite_core::Db) -> Result<(), Box<dyn std::error::Error>> {
283/// let mut migrations = laterite_admin::builtin_migrations();
284/// // migrations.extend([my_module::migrations()]);
285/// laterite_core::migration::run(&db.pool, db.backend, &migrations).await?;
286/// # Ok(()) }
287/// ```
288pub fn builtin_migrations() -> Vec<laterite_core::MigrationSet> {
289    vec![laterite_auth::migrations(), settings::migrations()]
290}
291
292/// Builds the admin router. `app_resources` are the application's own list/form
293/// screens; `app_settings` are its settings models; `app_permissions` are the
294/// permissions it defines, offered in the role editor alongside the framework's.
295/// All are mounted alongside the framework's built-in equivalents.
296pub 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    // Main menu (top nav): Dashboard, the application's own sections, then
311    // Settings. Built-in Users and Roles are settings items (see the settings
312    // menu), not main-menu tabs.
313    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    // The roles screen has a dedicated create/edit form (the permission editor),
347    // gated by the same permission as its list.
348    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    // The backend users screen edits a user's per-permission overrides, gated by
358    // the same permission as its list.
359    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        // Public routes (not covered by the guard above): the login and first-run
381        // setup screens and the embedded stylesheet and fonts (needed before
382        // authentication).
383        .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
392/// Serves the embedded brick mark (SVG badge, used for the favicon).
393async fn asset_mark() -> Response {
394    (
395        [(header::CONTENT_TYPE, "image/svg+xml")],
396        include_str!("../assets/mark.svg"),
397    )
398        .into_response()
399}
400
401/// Serves the embedded brick mark (PNG, the visible logo).
402async 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
413/// Serves the embedded admin stylesheet.
414async fn asset_css() -> Response {
415    (
416        [
417            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
418            // The stylesheet is embedded in the binary, so it changes only when
419            // the binary does. Revalidating on each load keeps a browser from
420            // serving a stale copy after an upgrade.
421            (header::CACHE_CONTROL, "no-cache"),
422        ],
423        include_str!("../assets/laterite.css"),
424    )
425        .into_response()
426}
427
428/// Serves an embedded webfont by file name.
429async 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
450/// Builds a resource's list, create, and edit routes as generic handlers that
451/// carry the resource's descriptors. When the resource sets a `permission`, every
452/// route it mounts is wrapped in a guard that answers `403 Forbidden` for an
453/// operator who lacks it; the caller merges the result into the protected router.
454fn 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    // Gate every route the resource mounts on its permission.
511    if let Some(permission) = &resource.permission {
512        router = guard_with_permission(router, permission);
513    }
514    router
515}
516
517/// Wraps every route currently in `router` in a permission guard: the auth guard
518/// runs first and injects the identity, so this reads it from the request
519/// extensions and answers `403 Forbidden` for an operator who lacks `permission`.
520/// Shared by resource mounting and the roles permission editor.
521fn 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
537/// Redirects unauthenticated requests to the login screen, and injects the
538/// resolved identity into request extensions for downstream handlers.
539async 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    // A fresh install with no operators goes to first-run setup instead.
571    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
604/// Builds the session cookie, scoped to the admin and flagged `Secure` behind
605/// HTTPS. Shared by login and first-run setup.
606fn 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
625/// The first-run setup screen: shown only while no operator exists, so a fresh
626/// install can create its first administrator without the CLI.
627async 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    // Setup only ever creates the first operator; once one exists it is closed.
641    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    // The setup select always carries a value, but guard against a bad one.
660    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    // Sign the new administrator straight in through the normal login path.
683    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
696/// Builds the setup view, its timezone select defaulting to the deployment
697/// default so the first administrator can accept or change it.
698fn 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
731/// Whether an operator may see a settings item: items with no permission are
732/// public, otherwise the operator must hold the item's permission.
733fn 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
740/// The settings items this operator may see, in registry order. Both the index
741/// and the form use this set, so an operator never sees or edits an item their
742/// permissions do not allow.
743fn 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    // Filter first, so an operator cannot open a settings form they lack the
765    // permission to see.
766    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
798/// The self-service Preferences screen for the signed-in operator.
799async 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    // An empty choice clears the preference so the operator inherits the default.
827    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
846/// Builds the Preferences view. `shell.tz` is the timezone currently in force
847/// (the operator's preference or the default); the operator's stored preference
848/// selects the matching option, or the inherit option when unset.
849fn 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
875/// The framework's own admin screens.
876fn 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            // The create/edit form is the dedicated permission editor (see the
890            // `roles` module), mounted separately, not the generic form.
891            form: None,
892            permission: Some("backend.manage_roles".to_string()),
893        },
894    ]
895}
896
897/// The framework's own settings items. The built-in Users and Roles resources
898/// appear in the settings menu under a Users category (linking to their list
899/// screens), rather than as main-menu tabs.
900fn 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        // Rows link to the per-user permission editor; users are created from the
945        // CLI or first-run setup, so no "New" screen here.
946        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    /// The timezone dates currently render in for this operator.
995    effective_tz: String,
996    /// The deployment default, named in the inherit option.
997    default_tz: String,
998    /// Whether the operator currently inherits the default (no preference set).
999    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    /// Inline SVG for the tab's icon, or empty for a text-only tab. Rendered raw
1015    /// with `|safe`.
1016    icon: &'static str,
1017}
1018
1019/// Renders a template to an HTML response, mapping a render failure to a 500.
1020pub(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        // No preference: use the deployment default.
1055        assert_eq!(resolve_display_tz(None, default), default);
1056        // Junk stored value: fall back rather than error.
1057        assert_eq!(resolve_display_tz(Some("Not/AZone"), default), default);
1058        assert_eq!(resolve_display_tz(Some(""), default), default);
1059    }
1060
1061    /// A fresh test database with no migrations applied, the blank slate an
1062    /// application starts from before it runs its migration set. Hold the guard
1063    /// for the test's lifetime.
1064    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        // A table from each bundled module exists, so an app that only ran
1075        // builtin_migrations has everything the admin's screens need. A no-row
1076        // probe succeeds only if the table exists, portably on every backend.
1077        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        // An operator without the grant sees only the unpermissioned item.
1110        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        // Holding the permission reveals the gated item.
1118        let granted = PermissionSet::new(false, ["backend.manage_users".to_string()]);
1119        assert_eq!(visible_settings(&items, &granted).len(), 2);
1120
1121        // A superuser sees everything.
1122        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        // A linked resource, and its sub-pages, resolve to that item as active.
1140        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        // The settings index shows the sidebar, with nothing active.
1146        assert!(!sidebar("/admin/settings").is_empty());
1147        assert_eq!(active_path("/admin/settings"), None);
1148        // A settings form activates its own item.
1149        assert_eq!(
1150            active_path("/admin/settings/backend.roles").as_deref(),
1151            Some("/admin/roles")
1152        );
1153        // The dashboard is not a settings context, so it has no sidebar.
1154        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        // Dashboard lights only on an exact match, never as a prefix of deeper paths.
1178        assert_eq!(
1179            active_nav_path(&nav, false, "/admin").as_deref(),
1180            Some("/admin")
1181        );
1182        // A section keeps its own tab active across its sub-pages.
1183        assert_eq!(
1184            active_nav_path(&nav, false, "/admin/pages/7/edit").as_deref(),
1185            Some("/admin/pages")
1186        );
1187        // A sibling section that merely shares a prefix does not steal the tab.
1188        assert_eq!(active_nav_path(&nav, false, "/admin/pages-archive"), None);
1189        // A screen under no section (reached from the user menu) lights nothing,
1190        // rather than the root falling back to Dashboard.
1191        assert_eq!(active_nav_path(&nav, false, "/admin/preferences"), None);
1192        // The settings context lights Settings, including for a linked resource
1193        // whose path lives outside /admin/settings.
1194        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}