Files
komp_ac/web/src/ui/mod.rs
2026-08-15 15:08:35 +02:00

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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#x27;"),
_ => 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("&lt;script&gt;"), "{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""#));
}
}