//! The four sessionless auth pages, described.
//!
//! `/login`, `/forgot-password`, `/reset-password` and `/auth/2fa`: the screens
//! a reader reaches without a session, where the site header would offer a
//! Library and a Dashboard they cannot open. They replace
//! `templates/pages/{login,forgot_password,reset_password,two_factor}.html`
//! and the four Askama structs behind them.
//!
//! They are the first consumer of [`quasi_router::Screen::opens_at`], which is
//! why they were converted together: each carried exactly one `autofocus`, and
//! the caret landing in the one box a reader came here to type into is the
//! whole of what these pages do.
//!
//! # Why these are not on a quasi mount
//!
//! Every other described document in this server is registered through
//! [`super::public_document_mount`], and these are not. The mount hands a
//! handler a [`Viewer`](super::Viewer), which carries the app state, the
//! runtime and the CSRF token and does **not** carry the session. All four of
//! these GETs need it: `/auth/2fa` reads a pending-2FA key and re-checks the
//! tracking row against the database, `/reset-password` re-validates a signed
//! HMAC link, and `/login` reads two query parameters and `Config::sso`.
//!
//! So the route stays an ordinary axum handler with the extractors it already
//! had, and what is described is the *document*: this module owns the screens
//! and the shell, and the handler renders one instead of a template. Moving the
//! addresses onto the mount would mean moving four auth guards with them, which
//! is a rewrite of four security-sensitive flows to change no pixel.
//!
//! The line that separates them: the mount owns *routing* -- the address, the
//! viewer factory, the fragment protocol -- and these pages register no
//! described route. Their POSTs are answered by the handlers that always
//! answered them, into the region the description names.
//!
//! # Why the four screens each write the same three settings
//!
//! `measured`, `documented` and `indexed false` are the same on all four, and
//! they used to be a `page(title, body)` helper. R2 makes a `-> Screen` shape
//! its single `screen`, so a helper that takes a document and hands it back is
//! the shape `custom_page::strip` was refused for, and the settings are written
//! out. The one worth reading once is the third: none of the four wants to be
//! indexed, because three of them answer only to a reader holding a link or a
//! pending session, and a login form in a search result is a phishing lure with
//! our name on it.
//!
//! # What each form's answer lands in
//!
//! A named region, through [`Action::replacing`], which is documented as the
//! call for "a route the description layer does not serve". `/login` fills
//! `login-errors` and the other three fill `form-feedback`, which is what the
//! templates already targeted.
//!
//! `/auth/verify-2fa` is the one that changed. It targeted
//! `closest .login-container` with `outerHTML` and answered with a whole
//! rendered page, so a failed code swapped an entire document into a `div`.
//! That is not sayable -- [`quasi_router::Replaces`] names a region, not a
//! selector -- and it should not be: it now answers an alert into
//! `form-feedback` like its two siblings.
//!
//! # The one thing that did not survive
//!
//! `reset_password.html` wrote `minlength="8"` on both password boxes.
//! [`quasi_router::Field`] carries `required` and `max_length` and has no
//! minimum, and adding one means a member on `makeover_layout::Field`, which is
//! published and would pull the whole suite through a cascade for a browser
//! hint. The rule is the server's and always was --
//! `crate::validation::limits::PASSWORD_MIN`, checked in
//! `reset_password_handler` before the token is consumed -- so what is lost is
//! a tooltip. A hint on the field says the same thing in words, before the
//! reader submits rather than after.
use makeover_layout as layout;
use quasi_declare::declare;
use quasi_router::{Action, Document, Node, RegionKind, Screen as Described, Slot};
use quasi_webview::Webview;
/// The region every one of these pages is, and what the skip link points at.
///
/// `login-container` because that is what the templates called the `div` and
/// what `style.css` still styles.
pub const PAGE_REGION: &str = "login-container";
/// Where `/login`'s answer lands.
pub const LOGIN_FEEDBACK: &str = "login-errors";
/// Where the other three pages' answers land.
pub const FEEDBACK: &str = "form-feedback";
/// The width these pages run at. Every one of them wrote it on the body.
const MEASURE: layout::Measure = layout::Measure::Contained;
/// The document any of these screens is drawn in.
///
/// The wordmark rather than the site header, which is the whole reason these
/// pages are their own family: [`crate::shell::wordmark`] says why.
///
/// No `Chrome`, so no shortcuts binding and no overlay container. A reader who
/// cannot sign in has nothing to reach with a key.
#[must_use]
pub fn renderer(csrf: &str, tail: &str) -> Webview {
Webview::new().with_shell(
crate::shell::described()
.sending("X-CSRF-Token", csrf)
.with_body_last(format!("{}{tail}", crate::shell::body_last()))
.with_body_first(format!(
"{}{}",
crate::shell::skip_link(PAGE_REGION),
crate::shell::wordmark()
))
.with_head(format!(
"",
crate::helpers::escape_html(csrf)
)),
)
}
/// The passkey offer, as markup, because none of it is describable.
///
/// A `data-action` the classic dispatcher resolves, a container a script
/// unhides once it has asked the browser whether it can do WebAuthn at all, and
/// an element that script writes a failure into. A description can say a
/// control calls a route; it cannot say a control calls a function in this
/// page's own JavaScript, and it should not learn to.
///
/// So the region is a [`RegionKind::Handover`]: the description says there is a
/// place here and who owes the markup, and this is the host paying it. The id
/// is the one `static/page-login-2.js` looks for.
const PASSKEY: &str = concat!(
"
or
",
"",
"",
);
/// The region the passkey offer is handed over in.
pub const PASSKEY_REGION: &str = "passkey-login";
/// The two scripts the login page carried in `{% block scripts %}`.
const LOGIN_SCRIPTS: &str = concat!(
"",
"",
);
/// Render one of these screens as a whole document.
#[must_use]
pub fn document(csrf: Option<&str>, screen: &Described) -> String {
use quasi_axum::Serves as _;
// The login screen is the only one of the four that hands a region over,
// and it is the only one that needs the two scripts that fill it. Asked of
// the screen rather than passed in, so a caller cannot render the login
// page without the thing that makes its passkey button work.
let offers_passkey = screen.slot(PASSKEY_REGION).is_some();
let mut webview = renderer(
csrf.unwrap_or_default(),
if offers_passkey { LOGIN_SCRIPTS } else { "" },
);
if offers_passkey {
webview = webview.with_fill(PASSKEY_REGION, PASSKEY);
}
webview.screen(screen)
}
/// The feedback region, filled, for a POST to answer an htmx submit with.
///
/// The described forms use [`Action::replacing`], which is `hx-target="#"`
/// plus `hx-swap="outerMorph"`: what the answer replaces is **the region
/// itself**. So an answer that were a bare alert would replace the element the
/// next attempt has to aim at, and a second wrong password would land nowhere.
/// Answering with the region keeps its id, which is what makes a refused form
/// retryable.
///
/// That is also why these no longer answer `AlertTemplate`. The banner is the
/// description's now, drawn by the same renderer that drew the page, so the two
/// halves of one screen cannot come out of two hands.
#[must_use]
pub fn answered(
id: &str,
tone: layout::Tone,
message: &str,
onward: Option<(&str, &str)>,
) -> String {
use quasi_axum::Serves as _;
let mut slot = Slot::new(id, RegionKind::Pane).with(Node::banner(tone, message));
if let Some((route, label)) = onward {
slot = slot.with(Node::act(label, Action::get(route).navigating()));
}
Webview::new().fragment(&Node::Region(slot))
}
declare! {
/// An empty region for a route's answer to land in.
///
/// Empty, and that is the point: it is an address rather than content. The
/// error a full-page POST re-renders goes in through `error` instead,
/// because on that path there is no swap to land anything.
///
/// An `Option` is an iterator of at most one, and `.into_iter()` is the
/// method step that says so.
shape feedback(id: &str, error: Option<&str>) -> Node;
region id as Pane {
for message in error.into_iter() {
banner layout::Tone::Danger message;
}
}
}
declare! {
/// The link back to the login form, which three of these four carry.
shape back_to_login() -> Node;
link "Back to login" to get "/login" navigating;
}
declare! {
/// `/login`.
///
/// `sso_enabled` is the testnot.work preview, where there is no local
/// password at all and the whole form is replaced by one link. Two shapes
/// of the same screen rather than two screens, because everything around
/// them -- the wordmark, the measure, the notice -- is the same. Said as
/// guards rather than as a dispatch, because the two branches offer a
/// different *number* of things and a dispatch arm is one emission.
#[must_use]
pub shape login(
prefill: &str,
error: Option<&str>,
notice: Option<&str>,
sso_enabled: bool,
) -> Screen;
screen single "Log In - Makenotwork" {
measured MEASURE;
documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
indexed false;
// The caret in the box the reader came here to fill. The password box
// is the wrong answer even for somebody whose browser fills the first
// one: a filled box is still where a correction is made. The preview
// has no box at all, so it opens at nothing.
opening_at "login" unless sso_enabled;
region PAGE_REGION as Pane {
for note in notice.into_iter() {
banner layout::Tone::Info note;
}
include feedback(LOGIN_FEEDBACK, error);
section "Log in";
text "testnot.work is a preview of makenot.work. Sign in with your \
makenot.work account to continue. Your password is only ever \
entered on makenot.work."
when sso_enabled;
act "Sign in with Makenot.work" to get "/sso/login" navigating when sso_enabled;
form post "/login" replacing LOGIN_FEEDBACK unless sso_enabled {
submit "Log In";
field Text "login" "Username or Email" {
required;
// What was typed, so a wrong password does not cost the
// address as well. `login_handler`'s full-page error path
// has always done this.
value prefill;
placeholder "username or you@example.com";
}
field Secret "password" "Password" {
required;
placeholder "--------";
}
field Checkbox "remember_me" "Remember me";
}
link "Reset Password" to get "/forgot-password" navigating unless sso_enabled;
link "Join now" to get "/join" navigating unless sso_enabled;
// Hidden until `page-login-2.js` has asked the browser whether it
// can do WebAuthn. See `PASSKEY`.
region PASSKEY_REGION as RegionKind::handover("the passkey offer")
unless sso_enabled {}
}
}
}
declare! {
/// `/forgot-password`.
#[must_use]
pub shape forgot_password() -> Screen;
screen single "Reset Password - Makenotwork" {
measured MEASURE;
documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
indexed false;
opening_at "email";
region PAGE_REGION as Pane {
include feedback(FEEDBACK, None);
section "Reset Password";
text "Enter your email address and we'll send you a link to reset your password.";
form post "/forgot-password" replacing FEEDBACK {
submit "Send Reset Link";
field Email "email" "Email" {
required;
placeholder "you@example.com";
}
}
include back_to_login();
}
}
}
declare! {
/// `/reset-password`.
///
/// `valid` is whether the signed link still resolves. An expired one is a
/// different screen rather than a disabled form: there is nothing to type,
/// and the only useful control is the one that asks for a fresh link.
///
/// The token rides on the action as a parameter rather than as a hidden
/// field. A hidden input is markup standing in for a value the call already
/// carries, and `with` is what the vocabulary has for it.
#[must_use]
pub shape reset_password(valid: bool, token: &str, error: Option<&str>) -> Screen;
screen single "Set New Password - Makenotwork" {
measured MEASURE;
documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
indexed false;
// Nothing to type into on the expired screen, so nothing to open at. A
// name no field carries would be honoured as nothing anyway; saying
// nothing is the honest spelling of it.
opening_at "password" when valid;
region PAGE_REGION as Pane {
include feedback(FEEDBACK, error);
section "Set New Password" when valid;
text "Enter your new password below." when valid;
form post "/reset-password" with "token" token replacing FEEDBACK
when valid {
submit "Set Password";
field Secret "password" "New Password" {
required;
// The rule the server enforces, said before the reader
// submits rather than after. See the module header for why
// it is not `minlength`.
hint "At least 8 characters";
placeholder "--------";
}
field Secret "password_confirm" "Confirm Password" {
required;
placeholder "--------";
}
}
section "Link Expired" unless valid;
text "This password reset link has expired or is invalid. Please request a \
new one."
unless valid;
act "Request New Link" to get "/forgot-password" navigating unless valid;
include back_to_login();
}
}
}
declare! {
/// `/auth/2fa`.
#[must_use]
pub shape two_factor(error: Option<&str>) -> Screen;
screen single "Two-Factor Authentication - Makenotwork" {
measured MEASURE;
documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
indexed false;
opening_at "code";
region PAGE_REGION as Pane {
include feedback(FEEDBACK, error);
section "Two-Factor Authentication";
text "Enter the 6-digit code from your authenticator app, or use a backup code.";
form post "/auth/verify-2fa" replacing FEEDBACK {
submit "Verify";
field Text "code" "Verification Code" {
required;
placeholder "000000";
// Six digits or an eight-character backup code, which is
// what the template capped it at. A cap the box enforces,
// unlike the password minimum above: `Field` carries a
// maximum and no minimum.
limited_to 8;
}
}
include back_to_login();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn html(screen: &Described) -> String {
document(Some("tok&en"), screen)
}
/// The whole point of the pass: each of these pages carried exactly one
/// `autofocus`, and the caret lands in the box the reader came to type in.
#[test]
fn every_page_opens_where_its_template_put_the_caret() {
for (screen, name) in [
(login("", None, None, false), "login"),
(forgot_password(), "email"),
(reset_password(true, "t", None), "password"),
(two_factor(None), "code"),
] {
assert_eq!(
screen.opens_at.as_deref(),
Some(name),
"{} opened somewhere else",
screen.title
);
let rendered = html(&screen);
assert_eq!(
rendered.matches("autofocus").count(),
1,
"{} emitted more or less than one caret: {rendered}",
screen.title
);
}
}
/// A page with nothing to type into opens nowhere. Saying a name no field
/// carries would be honoured as nothing anyway; saying nothing is the
/// honest spelling.
#[test]
fn an_expired_link_has_no_caret_to_place() {
let screen = reset_password(false, "", None);
assert!(screen.opens_at.is_none());
assert!(!html(&screen).contains("autofocus"));
}
/// The token rides on the call rather than in a hidden input, and it is
/// what the POST reads back as `token`.
#[test]
fn the_reset_token_travels_with_the_call() {
let rendered = html(&reset_password(true, "a-real-token", None));
assert!(rendered.contains("a-real-token"), "{rendered}");
assert!(
!rendered.contains("type=\"hidden\""),
"the token is markup again: {rendered}"
);
}
/// Each form's answer aims at the region the template's `hx-target` named,
/// which is what keeps the POST handlers untouched.
#[test]
fn every_form_aims_at_the_region_its_handler_answers_into() {
assert!(html(&login("", None, None, false)).contains(LOGIN_FEEDBACK));
for screen in [
forgot_password(),
reset_password(true, "t", None),
two_factor(None),
] {
assert!(html(&screen).contains(FEEDBACK), "{}", screen.title);
}
}
/// A failed POST re-renders the page with the address intact, which the
/// template did and a conversion that dropped it would cost a retype.
#[test]
fn a_refused_login_keeps_what_was_typed_and_says_why() {
let rendered = html(&login(
"areader",
Some("Invalid username or password"),
None,
false,
));
assert!(rendered.contains("areader"), "{rendered}");
assert!(
rendered.contains("Invalid username or password"),
"{rendered}"
);
}
/// The preview mirror has no local password at all, so the form is one
/// link. The caret has nothing to go in either.
#[test]
fn the_sso_shape_offers_one_way_in_and_no_form() {
let screen = login("", None, None, true);
assert!(screen.opens_at.is_none());
let rendered = html(&screen);
assert!(rendered.contains("/sso/login"), "{rendered}");
assert!(!rendered.contains("name=\"password\""), "{rendered}");
}
/// The passkey offer is markup because none of it is describable, and the
/// scripts that drive it ride with the page that has it.
#[test]
fn the_login_page_hands_over_the_passkey_offer_and_carries_its_scripts() {
let rendered = html(&login("", None, None, false));
assert!(rendered.contains("loginWithPasskey"), "{rendered}");
assert!(rendered.contains("page-login-2.js"), "{rendered}");
assert!(rendered.contains("passkey.js"), "{rendered}");
// And no other page pays for them.
let other = html(&forgot_password());
assert!(!other.contains("passkey"), "{other}");
}
/// None of the four is a page a search result should offer. A login form
/// in one is a phishing lure with our name on it.
#[test]
fn none_of_these_pages_is_indexable() {
for screen in [
login("", None, None, false),
forgot_password(),
reset_password(true, "t", None),
two_factor(None),
] {
assert!(!screen.discovery.indexable, "{}", screen.title);
assert!(html(&screen).contains("noindex"), "{}", screen.title);
}
}
/// These pages carry the wordmark and no header: the nav would offer a
/// Library and a Dashboard a reader who cannot sign in cannot open.
#[test]
fn these_pages_show_the_wordmark_and_no_site_header() {
let rendered = html(&login("", None, None, false));
assert!(rendered.contains("brand-h1"), "{rendered}");
assert!(!rendered.contains("chrome-band"), "{rendered}");
}
/// `736f45a5`: a described screen's markup carries none of the four
/// spellings. All four templates wrote an `htmx-indicator` span.
#[test]
fn these_pages_spell_no_spinner() {
for screen in [
login("", None, None, false),
forgot_password(),
reset_password(true, "t", None),
two_factor(None),
] {
let rendered = html(&screen);
for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
assert!(
!rendered.contains(spelling),
"{spelling} in {}",
screen.title
);
}
}
}
}