460 lines
18 KiB
Rust
460 lines
18 KiB
Rust
//! 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/<the page's path under src>/`.
|
|
|
|
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 `<html>`
|
|
/// document to be swapped into a `<div>` 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<Cow<'static, str>, 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<T: Template>(template: &T) -> String {
|
|
template
|
|
.render()
|
|
.unwrap_or_else(|error| format!("<p class=\"error\">Template error: {error}</p>"))
|
|
}
|
|
|
|
#[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 `<div>` inside the document already open.
|
|
let body = alert(crate::i18n::Locale::default(), "Pattern line 1 is malformed.");
|
|
assert!(!body.contains("<html"), "{body}");
|
|
assert!(!body.contains("<!doctype"), "{body}");
|
|
}
|
|
|
|
/// The messages rendered `|safe` carry their own markup, so Askama does no
|
|
/// escaping for them at all — which would make an interpolated value a way
|
|
/// to write HTML into the page. The value is escaped here instead.
|
|
///
|
|
/// Nothing today can put a `<` in one of these: role names are checked by
|
|
/// the backend and the column type is matched against the catalogue. This
|
|
/// test is what keeps that from mattering.
|
|
#[test]
|
|
fn a_value_interpolated_into_html_cannot_carry_markup_of_its_own() {
|
|
let nav = Nav::default();
|
|
|
|
let rendered = nav.tr_args_html(
|
|
"grants-inherits-none",
|
|
&[("role", "<script>alert(1)</script>".to_string())],
|
|
);
|
|
|
|
assert!(
|
|
!rendered.contains("<script>"),
|
|
"an argument reached the page as markup: {rendered}"
|
|
);
|
|
assert!(rendered.contains("<script>"), "{rendered}");
|
|
// The message's own markup is left alone: it is the catalogue's, and
|
|
// `messages_rendered_as_html_use_only_balanced_inline_tags` vouches
|
|
// for it.
|
|
assert!(rendered.contains("<strong>"), "{rendered}");
|
|
}
|
|
|
|
/// Every form on the site answers through `Alert`, so this is the one
|
|
/// place that decides a failure cannot go unnoticed.
|
|
#[test]
|
|
fn a_failed_form_gets_a_dialog_and_a_successful_one_does_not() {
|
|
let failure = render(&Alert::error(
|
|
crate::i18n::Locale::default(),
|
|
"Could not save",
|
|
"A name is required.",
|
|
));
|
|
assert!(!failure.contains("Template error"), "{failure}");
|
|
assert!(failure.contains(r#"role="alert""#));
|
|
assert!(failure.contains(r#"role="dialog""#));
|
|
// Once in the inline alert, once in the dialog.
|
|
assert_eq!(failure.matches("A name is required.").count(), 2);
|
|
|
|
// A success only asks the toast stack in `ui/base.html` to show it: it
|
|
// dismisses itself, so it neither blocks the page nor stays behind in
|
|
// the layout, and it renders nothing of its own here.
|
|
let success = render(&Alert::success(
|
|
crate::i18n::Locale::default(),
|
|
"Saved",
|
|
"Two rows written.",
|
|
));
|
|
assert!(!success.contains("Template error"), "{success}");
|
|
assert!(success.contains("$dispatch('notify'"));
|
|
assert!(success.contains(r#"data-message="Two rows written.""#));
|
|
assert!(!success.contains(r#"role="dialog""#));
|
|
assert!(!success.contains(r#"role="alert""#));
|
|
}
|
|
}
|