//! The page shell: navbar, layouts, and the status blocks every page reuses. //! //! `templates/` mirrors `src/` directory for directory, so this module's //! markup is in `templates/ui/` and a page's markup is in //! `templates//`. use askama::Template; use axum::{ http::{HeaderMap, StatusCode}, response::{Html, IntoResponse, Redirect, Response}, }; pub(crate) const SESSION_COOKIE: &str = "analytics_token"; /// Why a form submission failed. /// /// The status a failure answers with is decided here, once, by *what went /// wrong* — never by which handler happened to notice. Handlers used to pick /// their own, and the same class of failure ended up as 200 on one form, 422 /// on another and 502 on a third; the browser hid it, because /// `ui/base.html` swaps every response body regardless of status. pub(crate) enum FormError { /// The submission was understood and rejected: the user has to change /// something before it can succeed. Whether this crate spotted it while /// building the request or the backend spotted it afterwards is an /// implementation detail of *when*, not a difference in kind. Rejected(String), /// The session is gone. Nothing is rendered; the browser goes to /login. Unauthenticated, /// Signed in, but not allowed to do this. Forbidden(String), /// Nothing wrong with the submission — the backend could not answer. Unavailable(String), } impl FormError { /// Classifies a gRPC failure by its code. The blanket `Err(_) =>` arms /// this replaces reported an unreachable backend as a rejected submission /// on some pages and a rejected submission as an unreachable backend on /// others. pub(crate) fn from_status(status: &tonic::Status) -> Self { let message = status.message().to_string(); match status.code() { tonic::Code::Unauthenticated => Self::Unauthenticated, tonic::Code::PermissionDenied => Self::Forbidden(message), // Everything the caller can fix by editing the form: a value the // server rejected, a name already taken, a row that does not // satisfy the table's own rules. tonic::Code::InvalidArgument | tonic::Code::FailedPrecondition | tonic::Code::OutOfRange | tonic::Code::AlreadyExists | tonic::Code::NotFound => Self::Rejected(message), _ => Self::Unavailable(message), } } pub(crate) fn status_code(&self) -> StatusCode { match self { // Understood, and refused. Not 400: the request parsed fine. Self::Rejected(_) => StatusCode::UNPROCESSABLE_ENTITY, Self::Unauthenticated => StatusCode::UNAUTHORIZED, Self::Forbidden(_) => StatusCode::FORBIDDEN, Self::Unavailable(_) => StatusCode::BAD_GATEWAY, } } pub(crate) fn message(&self) -> &str { match self { Self::Rejected(message) | Self::Forbidden(message) | Self::Unavailable(message) => { message } Self::Unauthenticated => "", } } /// The response, rendered through the page's own alert renderer so each /// form keeps its own error title. /// /// The body is always the alert *fragment*, because that is what a form's /// `hx-target` is: a status block. Re-rendering the whole page here — as /// the locally-detected errors used to — sends a complete `` /// document to be swapped into a `
` inside the page that is already /// open. pub(crate) fn into_response( self, locale: crate::i18n::Locale, render_alert: fn(crate::i18n::Locale, &str) -> String, ) -> Response { if matches!(self, Self::Unauthenticated) { return Redirect::to("/login").into_response(); } let status = self.status_code(); (status, Html(render_alert(locale, self.message()))).into_response() } } /// Navbar state. Every full-page template struct carries one of these, because /// `ui/base.html` renders `ui/navbar.html` unconditionally. #[derive(Clone, Debug)] pub(crate) struct Nav { pub locale: crate::i18n::Locale, pub authenticated: bool, pub role: String, pub can_admin: bool, pub can_permissions: bool, pub can_import: bool, pub can_export: bool, pub can_ecb: bool, pub active: &'static str, } impl Nav { /// The navbar for a page, resolved in full: this fetches the caller's /// authorization itself, so a handler cannot forget to and end up /// rendering a navbar with every capability link missing. /// /// `active` is the nav link to highlight: `"admin"`, `"permissions"`, /// `"analytics"`, `"login"`, or `""` for pages that are not themselves nav /// entries. pub(crate) async fn for_request( state: crate::AppState, headers: &HeaderMap, active: &'static str, ) -> Self { let Ok(request) = crate::services::authenticated_request(headers, crate::auth::GetAuthorizationRequest {}) else { return Self::without_authorization(headers, active); }; let mut auth = state.auth; match auth.get_authorization(request).await { Ok(response) => Self::from_authorization(headers, active, response.get_ref()), Err(_) => Self::without_authorization(headers, active), } } /// [`Self::for_request`] for a handler that already holds the snapshot, /// because gating the page needed it — the same navbar without a second /// round-trip. pub(crate) fn from_authorization( headers: &HeaderMap, active: &'static str, authorization: &crate::auth::AuthorizationSnapshot, ) -> Self { let mut nav = Self::without_authorization(headers, active); // The backend answered for this caller, so they are signed in — a // stronger fact than the cookie's mere presence, which is all // `without_authorization` has to go on. nav.authenticated = true; nav.role = authorization.role.clone(); nav.can_admin = crate::authz::can_open_admin(authorization); // Permissions is its own nav section, so it is not gated on the admin // panel: managing either roles or users is enough to open it. nav.can_permissions = crate::authz::can_manage(authorization, crate::authz::STRUCT_ROLE) || crate::authz::can_manage(authorization, crate::authz::STRUCT_USER); nav.can_import = authorization.permissions.iter().any(|permission| { permission.action == "insert" && permission.object.starts_with("data:") }); nav.can_export = authorization.permissions.iter().any(|permission| { permission.action == "read" && permission.object.starts_with("data:") }); nav.can_ecb = crate::authz::can_read_ecb(authorization); nav } /// A navbar with no capabilities at all — every `can_*` link hidden. /// /// This is only correct for markup that has no navbar to get wrong: the /// login and register pages, the HTMX fragments that carry a `Nav` purely /// to translate their labels, and the error pages rendered when the /// authorization could not be loaded in the first place. A page rendering /// `ui/navbar.html` for a signed-in user wants [`Self::for_request`]; /// reaching for this one there is what leaves the navbar half empty. pub(crate) fn without_authorization(headers: &HeaderMap, active: &'static str) -> Self { Self { locale: crate::i18n::Locale::from_headers(headers), authenticated: crate::cookie_value(headers, SESSION_COOKIE).is_some(), role: String::new(), can_admin: false, can_permissions: false, can_import: false, can_export: false, can_ecb: false, active, } } pub(crate) fn language(&self) -> &'static str { self.locale.code() } pub(crate) fn tr(&self, key: &str) -> String { self.locale.lookup(key) } /// Like [`Self::tr`], but interpolates named `{ $name }` placeholders. /// Values are strings; a template needing a *number* (for Fluent plural /// selection) should have the label built in Rust instead, where the /// `tr!` macro accepts `i64` arguments. pub(crate) fn tr_args(&self, key: &str, args: &[(&str, String)]) -> String { use std::{borrow::Cow, collections::HashMap}; let map: HashMap, fluent_templates::fluent_bundle::FluentValue<'_>> = args.iter() .map(|(name, value)| (Cow::Owned(name.to_string()), value.clone().into())) .collect(); self.locale.lookup_args(key, &map) } /// Like [`Self::tr_args`], but for the messages that carry markup of their /// own and are therefore rendered with `|safe`. /// /// `|safe` turns Askama's escaping off for the whole rendered string, /// message and arguments alike. The escaping the arguments still need /// happens here instead: the *message* is trusted markup from the /// catalogue, the *values* are data — a role name, a column type — and stay /// data even if one ever arrives holding a `<`. pub(crate) fn tr_args_html(&self, key: &str, args: &[(&str, String)]) -> String { let escaped: Vec<(&str, String)> = args .iter() .map(|(name, value)| (*name, escape_html(value))) .collect(); self.tr_args(key, &escaped) } /// Translates a key with one numeric argument, for Fluent plural /// selection (`{ $count -> [one] … *[other] … }`). pub(crate) fn tr_count(&self, key: &str, name: &str, count: &i64) -> String { use std::{borrow::Cow, collections::HashMap}; let mut map: HashMap< Cow<'static, str>, fluent_templates::fluent_bundle::FluentValue<'_>, > = HashMap::new(); map.insert(Cow::Owned(name.to_string()), (*count).into()); self.locale.lookup_args(key, &map) } } /// The escaping Askama's `escape` filter would have applied, for the values /// interpolated into a message rendered `|safe`. fn escape_html(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for character in value.chars() { match character { '&' => escaped.push_str("&"), '<' => escaped.push_str("<"), '>' => escaped.push_str(">"), '"' => escaped.push_str("""), '\'' => escaped.push_str("'"), _ => escaped.push(character), } } escaped } impl Default for Nav { fn default() -> Self { Self { locale: crate::i18n::Locale::default(), authenticated: false, role: String::new(), can_admin: false, can_permissions: false, can_import: false, can_export: false, can_ecb: false, active: "", } } } /// The swap target every form POST answers with. An error carries the dialog /// that puts the message in front of the user; see `ui/alert_fragment.html`. #[derive(Template)] #[template(path = "ui/alert_fragment.html")] pub(crate) struct Alert<'a> { /// The request's language, which the shared alert/dialog markup needs for /// its own chrome (the dismiss aria-label, the "Back to the form" button). locale: crate::i18n::Locale, success: bool, title: &'a str, message: &'a str, } impl<'a> Alert<'a> { pub(crate) fn error(locale: crate::i18n::Locale, title: &'a str, message: &'a str) -> Self { Self { locale, success: false, title, message, } } pub(crate) fn success(locale: crate::i18n::Locale, title: &'a str, message: &'a str) -> Self { Self { locale, success: true, title, message, } } } /// A one-line notice, for places where the alert card is too heavy. #[derive(Template)] #[template(path = "ui/notice.html")] pub(crate) struct Notice<'a> { locale: crate::i18n::Locale, error: bool, message: &'a str, login_link: bool, } impl<'a> Notice<'a> { pub(crate) fn error(locale: crate::i18n::Locale, message: &'a str) -> Self { Self { locale, error: true, message, login_link: false, } } pub(crate) fn login_required(locale: crate::i18n::Locale, message: &'a str) -> Self { Self { locale, error: true, message, login_link: true, } } } /// Standalone page for load failures that are not worth a redirect. #[derive(Template)] #[template(path = "ui/error.html")] pub(crate) struct ErrorPage<'a> { pub nav: Nav, pub heading: &'a str, pub message: &'a str, } /// Renders a template, or a plain error paragraph if the template itself /// fails. Askama only fails on `fmt` errors, so this is a formality. pub(crate) fn render(template: &T) -> String { template .render() .unwrap_or_else(|error| format!("

Template error: {error}

")) } #[cfg(test)] mod tests { use super::*; /// The status is a property of what went wrong, and a gRPC failure knows /// which it was. The blanket arms this replaced answered 422 for an /// unreachable backend on one page and 502 for a rejected row on another. #[test] fn a_backend_failure_is_classified_by_its_code() { let rejected = FormError::from_status(&tonic::Status::invalid_argument( "Script calculated '3', but user provided '99'", )); assert_eq!(rejected.status_code(), StatusCode::UNPROCESSABLE_ENTITY); let unreachable = FormError::from_status(&tonic::Status::unavailable("connection refused")); assert_eq!(unreachable.status_code(), StatusCode::BAD_GATEWAY); let forbidden = FormError::from_status(&tonic::Status::permission_denied("not your profile")); assert_eq!(forbidden.status_code(), StatusCode::FORBIDDEN); assert!(matches!( FormError::from_status(&tonic::Status::unauthenticated("expired")), FormError::Unauthenticated )); } /// A submission refused here and a submission refused by the backend are /// the same event to the user, so they are the same response: 422, and the /// alert *fragment* the form's `hx-target` expects — never a whole page. #[test] fn a_rejection_answers_with_a_fragment_and_not_a_document() { fn alert(locale: crate::i18n::Locale, message: &str) -> String { render(&Alert::error(locale, "Could not save", message)) } let response = FormError::Rejected("Pattern line 1 is malformed.".to_string()) .into_response(crate::i18n::Locale::default(), alert); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); // What that response carries. A form targets a status block, so the // body has to be a fragment: the locally-detected errors used to // answer with a re-rendered page, i.e. a whole document to be swapped // into a `
` inside the document already open. let body = alert(crate::i18n::Locale::default(), "Pattern line 1 is malformed."); assert!(!body.contains("alert(1)".to_string())], ); assert!( !rendered.contains("