//! 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.
//!
//! # 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_router::screen::Field;
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))
}
/// The page body every one of these four shares: one region, at the measure
/// they all run at, carrying whatever the page put in it.
fn page(title: &str, body: Slot) -> Described {
Described::single(title)
.measured(MEASURE)
.documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
// None of the four wants to be indexed: 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.
.indexed(false)
.with(body)
}
/// 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.
fn feedback(id: &str, error: Option<&str>) -> Node {
let mut slot = Slot::new(id, RegionKind::Pane);
if let Some(message) = error {
slot = slot.with(Node::banner(layout::Tone::Danger, message));
}
Node::Region(slot)
}
/// The link back to the login form, which three of these four carry.
fn back_to_login() -> Node {
Node::Link {
text: "Back to login".to_owned(),
action: Action::get("/login").navigating(),
}
}
/// `/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.
#[must_use]
pub fn login(
prefill: &str,
error: Option<&str>,
notice: Option<&str>,
sso_enabled: bool,
) -> Described {
let mut body = Slot::new(PAGE_REGION, RegionKind::Pane);
if let Some(note) = notice {
body = body.with(Node::banner(layout::Tone::Info, note));
}
if sso_enabled {
return page(
"Log In - Makenotwork",
body.with(feedback(LOGIN_FEEDBACK, error))
.with(Node::section("Log in"))
.with(Node::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.",
))
.with(Node::act(
"Sign in with Makenot.work",
Action::get("/sso/login").navigating(),
)),
);
}
let mut login_field = Field::new(layout::FieldKind::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);
login_field.placeholder = Some("username or you@example.com".to_owned());
let mut password = Field::new(layout::FieldKind::Secret, "password", "Password").required();
password.placeholder = Some("--------".to_owned());
page(
"Log In - Makenotwork",
body.with(feedback(LOGIN_FEEDBACK, error))
.with(Node::section("Log in"))
.with(Node::Form {
action: Action::post("/login").replacing(LOGIN_FEEDBACK),
submit: "Log In".to_owned(),
fields: vec![
login_field,
password,
Field::new(layout::FieldKind::Checkbox, "remember_me", "Remember me"),
],
})
.with(Node::Link {
text: "Reset Password".to_owned(),
action: Action::get("/forgot-password").navigating(),
})
.with(Node::Link {
text: "Join now".to_owned(),
action: Action::get("/join").navigating(),
})
// Hidden until `page-login-2.js` has asked the browser whether it
// can do WebAuthn. See `PASSKEY`.
.with(Node::Region(Slot::handover(
PASSKEY_REGION,
"the passkey offer",
))),
)
// 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.
.opening_at("login")
}
/// `/forgot-password`.
#[must_use]
pub fn forgot_password() -> Described {
let mut email = Field::new(layout::FieldKind::Email, "email", "Email").required();
email.placeholder = Some("you@example.com".to_owned());
page(
"Reset Password - Makenotwork",
Slot::new(PAGE_REGION, RegionKind::Pane)
.with(feedback(FEEDBACK, None))
.with(Node::section("Reset Password"))
.with(Node::text(
"Enter your email address and we'll send you a link to reset your password.",
))
.with(Node::Form {
action: Action::post("/forgot-password").replacing(FEEDBACK),
submit: "Send Reset Link".to_owned(),
fields: vec![email],
})
.with(back_to_login()),
)
.opening_at("email")
}
/// `/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 [`Action::with`] is what the vocabulary has for "send this along".
#[must_use]
pub fn reset_password(valid: bool, token: &str, error: Option<&str>) -> Described {
let mut body = Slot::new(PAGE_REGION, RegionKind::Pane).with(feedback(FEEDBACK, error));
if valid {
let mut password = Field::new(layout::FieldKind::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");
password.placeholder = Some("--------".to_owned());
let mut confirm = Field::new(
layout::FieldKind::Secret,
"password_confirm",
"Confirm Password",
)
.required();
confirm.placeholder = Some("--------".to_owned());
body = body
.with(Node::section("Set New Password"))
.with(Node::text("Enter your new password below."))
.with(Node::Form {
action: Action::post("/reset-password")
.with("token", token)
.replacing(FEEDBACK),
submit: "Set Password".to_owned(),
fields: vec![password, confirm],
});
} else {
body = body
.with(Node::section("Link Expired"))
.with(Node::text(
"This password reset link has expired or is invalid. Please request a new one.",
))
.with(Node::act(
"Request New Link",
Action::get("/forgot-password").navigating(),
));
}
let screen = page("Set New Password - Makenotwork", body.with(back_to_login()));
if valid {
screen.opening_at("password")
} else {
// Nothing to type into, so nothing to open at. A name no field carries
// would be honoured as nothing anyway; saying nothing is the honest
// spelling of it.
screen
}
}
/// `/auth/2fa`.
#[must_use]
pub fn two_factor(error: Option<&str>) -> Described {
let mut code = Field::new(layout::FieldKind::Text, "code", "Verification Code").required();
code.placeholder = Some("000000".to_owned());
// 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 below:
// `Field` carries a maximum and no minimum.
code.max_length = Some(8);
page(
"Two-Factor Authentication - Makenotwork",
Slot::new(PAGE_REGION, RegionKind::Pane)
.with(feedback(FEEDBACK, error))
.with(Node::section("Two-Factor Authentication"))
.with(Node::text(
"Enter the 6-digit code from your authenticator app, or use a backup code.",
))
.with(Node::Form {
action: Action::post("/auth/verify-2fa").replacing(FEEDBACK),
submit: "Verify".to_owned(),
fields: vec![code],
})
.with(back_to_login()),
)
.opening_at("code")
}
#[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
);
}
}
}
}