From e56acfb9dc1e17dbac4a2b7bda9290b0f3614d09 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Mon, 7 Sep 2026 02:28:09 +0000 Subject: [PATCH] Carry `next` through the password change and let requesters pick a username A forced or voluntary password change now redirects to the validated `next` target (the page the user was heading for, or the home page) instead of landing back on the account form. Login and the must-change-password middleware pass the destination along as /account?change=1&next=... and the form carries it as a hidden field. The request-access form gains a required Username field validated with the account rules and rejected when an account or another open request already holds it (case-insensitive). Migration 0009 stores it on the request; the notification email and the Users dashboard show it, and the Approve form is prefilled with it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4 --- README.md | 5 +- migrations/0009_request_username.sql | 1 + src/web/access.rs | 175 ++++++++++++++++++++++--- src/web/dashboard/users.rs | 32 ++++- src/web/session.rs | 116 ++++++++++++++-- src/web/templates/account.html | 2 +- src/web/templates/dashboard/users.html | 5 +- src/web/templates/request_access.html | 2 +- 8 files changed, 301 insertions(+), 37 deletions(-) create mode 100644 migrations/0009_request_username.sql diff --git a/README.md b/README.md index 14a40e0..999e7a7 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,9 @@ from a single download menu. An article chapters, rate articles, and use every `/dashboard/*` page, including settings and jobs. Personalization is shared across accounts for now. -Visitors can request an account at `/request-access`; admins review open -requests on `/dashboard/users`. Approving a request creates a `user` account, +Visitors can request an account with their preferred username at +`/request-access`; admins review open requests on `/dashboard/users`. Approving +a request creates a `user` account, emails a random temporary password to the requester, and marks the request done; the new user must choose a new password on first sign-in. Approval is available only when `[mail]` is active, so an account is never created with a diff --git a/migrations/0009_request_username.sql b/migrations/0009_request_username.sql new file mode 100644 index 0000000..4013fff --- /dev/null +++ b/migrations/0009_request_username.sql @@ -0,0 +1 @@ +ALTER TABLE account_requests ADD COLUMN username TEXT; diff --git a/src/web/access.rs b/src/web/access.rs index f45aa8d..192b2f2 100644 --- a/src/web/access.rs +++ b/src/web/access.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use crate::server::AppState; use crate::web::session::{AuthSession, Viewer}; +use crate::web::users; use crate::web::{Html, Page, WebError}; const DESCRIPTION: &str = @@ -18,6 +19,8 @@ const DESCRIPTION: &str = pub(super) struct AccessRequestForm { email: String, #[serde(default)] + username: String, + #[serde(default)] reason: String, #[serde(default)] website: String, @@ -28,6 +31,7 @@ pub(super) struct AccessRequestForm { struct RequestAccessTemplate { page: Page, email: String, + username: String, reason: String, error: String, submitted: bool, @@ -37,6 +41,7 @@ fn template(viewer: Option) -> RequestAccessTemplate { RequestAccessTemplate { page: Page::new("Request access", viewer, "").with_description(DESCRIPTION), email: String::new(), + username: String::new(), reason: String::new(), error: String::new(), submitted: false, @@ -62,25 +67,62 @@ pub(super) async fn submit( } let email = form.email.trim(); + let username = form.username.trim(); let reason = form.reason.trim(); - if let Some(error) = validate(email, reason) { + if let Some(error) = validate(email, username, reason) { let mut view = template(viewer); view.email = email.to_string(); + view.username = username.to_string(); view.reason = reason.to_string(); - view.error = error.to_string(); + view.error = error; + return Ok(Html(view).into_response()); + } + if users::find_by_username(&state.db, username) + .await + .map_err(|error| WebError::Internal(error.into()))? + .is_some() + { + let mut view = template(viewer); + view.email = email.to_string(); + view.username = username.to_string(); + view.reason = reason.to_string(); + view.error = "That username is already taken.".into(); + return Ok(Html(view).into_response()); + } + let claimed: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM account_requests + WHERE status = 'open' + AND username = ? COLLATE NOCASE + AND email <> ? COLLATE NOCASE + )", + ) + .bind(username) + .bind(email) + .fetch_one(state.db.pool()) + .await + .map_err(|error| WebError::Db(error.into()))?; + if claimed { + let mut view = template(viewer); + view.email = email.to_string(); + view.username = username.to_string(); + view.reason = reason.to_string(); + view.error = "That username is already requested; choose another.".into(); return Ok(Html(view).into_response()); } let requested_at = crate::db::fmt_ts(jiff::Timestamp::now()); sqlx::query( - "INSERT INTO account_requests (email, reason, status, requested_at) - VALUES (?, ?, 'open', ?) + "INSERT INTO account_requests (email, username, reason, status, requested_at) + VALUES (?, ?, ?, 'open', ?) ON CONFLICT(email) WHERE status = 'open' DO UPDATE SET email = excluded.email, + username = excluded.username, reason = excluded.reason, requested_at = excluded.requested_at", ) .bind(email) + .bind(username) .bind((!reason.is_empty()).then_some(reason)) .bind(&requested_at) .execute(state.db.pool()) @@ -105,7 +147,7 @@ pub(super) async fn submit( to: to.to_string(), subject: format!("Access request from {email}"), body: format!( - "Email: {email}\nReason: {reason}\nRequested at: {requested_at}\nReview: {}/dashboard/users\n", + "Email: {email}\nUsername: {username}\nReason: {reason}\nRequested at: {requested_at}\nReview: {}/dashboard/users\n", config.server.public_url.trim_end_matches('/') ), }; @@ -121,16 +163,19 @@ pub(super) async fn submit( Ok(Html(view).into_response()) } -fn validate(email: &str, reason: &str) -> Option<&'static str> { +fn validate(email: &str, username: &str, reason: &str) -> Option { let valid_email = email.chars().count() <= 254 && email.split_once('@').is_some_and(|(local, domain)| { !local.is_empty() && !domain.is_empty() && !domain.contains('@') }); if !valid_email { - return Some("Enter a valid email address."); + return Some("Enter a valid email address.".into()); + } + if let Err(error) = users::validate_username(username) { + return Some(error.to_string()); } if reason.chars().count() > 2000 { - return Some("Reason or comment must be 2,000 characters or fewer."); + return Some("Reason or comment must be 2,000 characters or fewer.".into()); } None } @@ -226,17 +271,18 @@ mod tests { let (_dir, db, app) = app().await; let (status, body) = post( &app, - "email=reader%40example.com&reason=I+love+the+paper&website=", + "email=reader%40example.com&username=Morning.Reader&reason=I+love+the+paper&website=", ) .await; assert_eq!(status, StatusCode::OK); assert!(body.contains("requests are reviewed by hand"), "{body}"); - let row = sqlx::query("SELECT email, reason, status FROM account_requests") + let row = sqlx::query("SELECT email, username, reason, status FROM account_requests") .fetch_one(db.pool()) .await .unwrap(); assert_eq!(row.get::("email"), "reader@example.com"); + assert_eq!(row.get::("username"), "Morning.Reader"); assert_eq!( row.get::, _>("reason").as_deref(), Some("I love the paper") @@ -258,7 +304,11 @@ mod tests { state.mailer = Some(mailer); let app = router(state); - let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await; + let (status, _) = post( + &app, + "email=reader%40example.com&username=morning_reader&reason=&website=", + ) + .await; assert_eq!(status, StatusCode::OK); tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { @@ -285,6 +335,7 @@ mod tests { "Access request from reader@example.com" ); assert!(messages[0].body.contains("Email: reader@example.com")); + assert!(messages[0].body.contains("Username: morning_reader")); assert!(messages[0].body.contains("Reason: (no reason given)")); assert!(messages[0].body.contains("Requested at: ")); assert!( @@ -301,10 +352,18 @@ mod tests { let (_dir, _db, app) = app_with_config(config).await; for _ in 0..3 { - let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await; + let (status, _) = post( + &app, + "email=reader%40example.com&username=reader&reason=&website=", + ) + .await; assert_eq!(status, StatusCode::OK); } - let (status, _) = post(&app, "email=reader%40example.com&reason=&website=").await; + let (status, _) = post( + &app, + "email=reader%40example.com&username=reader&reason=&website=", + ) + .await; assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); } @@ -338,18 +397,102 @@ mod tests { assert_eq!(count, 0); } + #[tokio::test] + async fn invalid_username_rerenders_with_the_values_without_storing() { + let (_dir, db, app) = app().await; + let (status, body) = post( + &app, + "email=reader%40example.com&username=bad+name&reason=Please&website=", + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("username must be 1-32 characters from A-Z, a-z, 0-9"), + "{body}" + ); + assert!(body.contains("value=\"reader@example.com\""), "{body}"); + assert!(body.contains("value=\"bad name\""), "{body}"); + assert!(body.contains(">Please"), "{body}"); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_requests") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn username_matching_an_existing_account_is_rejected_case_insensitively() { + let (_dir, db, app) = app().await; + crate::web::users::add(&db, "Existing.Reader", "correct horse battery", false) + .await + .unwrap(); + + let (status, body) = post( + &app, + "email=new%40example.com&username=existing.reader&reason=&website=", + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert!(body.contains("That username is already taken."), "{body}"); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_requests") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + } + + #[tokio::test] + async fn username_claimed_by_another_open_request_is_rejected_case_insensitively() { + let (_dir, db, app) = app().await; + sqlx::query( + "INSERT INTO account_requests (email, username, requested_at) + VALUES ('first@example.com', 'Claimed.Name', '2026-09-05T12:00:00Z')", + ) + .execute(db.pool()) + .await + .unwrap(); + + let (status, body) = post( + &app, + "email=second%40example.com&username=claimed.name&reason=&website=", + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert!( + body.contains("That username is already requested; choose another."), + "{body}" + ); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_requests") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(count, 1); + } + #[tokio::test] async fn repeated_email_updates_the_open_request_case_insensitively() { let (_dir, db, app) = app().await; - post(&app, "email=Reader%40Example.com&reason=first&website=").await; - post(&app, "email=reader%40example.COM&reason=updated&website=").await; + post( + &app, + "email=Reader%40Example.com&username=first_name&reason=first&website=", + ) + .await; + post( + &app, + "email=reader%40example.COM&username=updated.name&reason=updated&website=", + ) + .await; - let rows = sqlx::query("SELECT email, reason FROM account_requests") + let rows = sqlx::query("SELECT email, username, reason FROM account_requests") .fetch_all(db.pool()) .await .unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].get::("email"), "reader@example.COM"); + assert_eq!(rows[0].get::("username"), "updated.name"); assert_eq!( rows[0].get::, _>("reason").as_deref(), Some("updated") diff --git a/src/web/dashboard/users.rs b/src/web/dashboard/users.rs index d3baf2d..fc9749c 100644 --- a/src/web/dashboard/users.rs +++ b/src/web/dashboard/users.rs @@ -69,7 +69,7 @@ async fn index( let viewer = auth.user().await.map(Viewer::from); let config = state.config(); let requests = sqlx::query( - "SELECT id, email, reason, requested_at FROM account_requests + "SELECT id, email, username, reason, requested_at FROM account_requests WHERE status = 'open' ORDER BY requested_at DESC, id DESC", ) .fetch_all(state.db.pool()) @@ -80,7 +80,9 @@ async fn index( let email: String = row.get("email"); AccessRequestLine { id: row.get("id"), - suggested_username: suggested_username(&email), + suggested_username: row + .get::, _>("username") + .unwrap_or_else(|| suggested_username(&email)), email, reason: row.get::, _>("reason").unwrap_or_default(), requested: fmt_stored_time( @@ -315,8 +317,8 @@ mod tests { async fn users_page_is_admin_only_and_lists_accounts_and_sessions() { let seed = seed().await; sqlx::query( - "INSERT INTO account_requests (email, reason, requested_at) - VALUES ('reader@example.com', 'Daily commute', '2026-09-05T12:00:00Z')", + "INSERT INTO account_requests (email, username, reason, requested_at) + VALUES ('reader@example.com', 'Requested.Name', 'Daily commute', '2026-09-05T12:00:00Z')", ) .execute(seed.db.pool()) .await @@ -327,8 +329,10 @@ mod tests { assert!(body.contains("

Users

"), "{body}"); assert!(body.contains("1 open access request"), "{body}"); assert!(body.contains("reader@example.com"), "{body}"); + assert!(body.contains("Username"), "{body}"); + assert!(body.contains(">Requested.Name"), "{body}"); assert!(body.contains("Daily commute"), "{body}"); - assert!(body.contains("value=\"reader\""), "{body}"); + assert!(body.contains("value=\"Requested.Name\""), "{body}"); assert!(body.contains(">Approve"), "{body}"); assert!(body.contains("Email is not configured"), "{body}"); assert!(body.contains("Mark done"), "{body}"); @@ -338,6 +342,24 @@ mod tests { assert!(body.contains("daily-epub users"), "{body}"); } + #[tokio::test] + async fn old_request_without_a_username_uses_the_email_suggestion() { + let seed = seed().await; + sqlx::query( + "INSERT INTO account_requests (email, requested_at) + VALUES ('Legacy.Reader+news@example.com', '2026-09-05T12:00:00Z')", + ) + .execute(seed.db.pool()) + .await + .unwrap(); + let app = app_with_users(&seed.db).await; + + let body = assert_admin_only(&app, "/dashboard/users").await; + + assert!(body.contains(">legacyreadernews"), "{body}"); + assert!(body.contains("value=\"legacyreadernews\""), "{body}"); + } + #[tokio::test] async fn marking_a_request_done_hides_it_from_the_open_list() { let seed = seed().await; diff --git a/src/web/session.rs b/src/web/session.rs index 3ed4d79..ba83f0d 100644 --- a/src/web/session.rs +++ b/src/web/session.rs @@ -18,7 +18,7 @@ use time::OffsetDateTime; use crate::db::{Db, fmt_ts}; use crate::server::AppState; use crate::web::users::{self, Role, User}; -use crate::web::{Html, Page, WebError}; +use crate::web::{Html, Page, WebError, encode_component}; /// The session key axum-login keeps the signed-in user under (its default /// `data_key`); the presence of this key is what "signed in" means to @@ -280,7 +280,15 @@ pub async fn require_password_change(auth: AuthSession, request: Request, next: .await .is_some_and(|user| user.must_change_password) { - return axum::response::Redirect::to("/account?change=1").into_response(); + let destination = request + .uri() + .path_and_query() + .map_or("/", |value| value.as_str()); + return axum::response::Redirect::to(&format!( + "/account?change=1&next={}", + encode_component(destination) + )) + .into_response(); } next.run(request).await } @@ -305,6 +313,7 @@ struct AccountTemplate { page: Page, error: String, change_required: bool, + next: String, } /// Query parameters accepted by the account page. @@ -312,6 +321,8 @@ struct AccountTemplate { pub struct AccountQuery { #[serde(default)] change: Option, + #[serde(default)] + next: Option, } /// The `` for both renders of the sign-in page. @@ -343,9 +354,9 @@ pub async fn login( { Some(user) => { let destination = if user.must_change_password { - "/account?change=1" + format!("/account?change=1&next={}", encode_component(&destination)) } else { - &destination + destination }; auth.login(&user) .await @@ -356,7 +367,7 @@ pub async fn login( .execute(state.db.pool()) .await .map_err(crate::db::DbError::from)?; - Ok(axum::response::Redirect::to(destination).into_response()) + Ok(axum::response::Redirect::to(&destination).into_response()) } None => Ok(( StatusCode::UNAUTHORIZED, @@ -389,6 +400,7 @@ pub async fn account( page: Page::new("Account", Some(user.into()), "account"), error: String::new(), change_required, + next: valid_next(query.next.as_deref()).to_string(), }) .into_response()) } @@ -398,6 +410,8 @@ pub struct PasswordForm { current_password: String, new_password: String, confirm_password: String, + #[serde(default)] + next: Option, } pub async fn change_password( @@ -408,6 +422,7 @@ pub async fn change_password( let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated { next: "/account".into(), })?; + let destination = valid_next(form.next.as_deref()).to_string(); let hash = user.password_hash.clone(); let current = form.current_password; let valid = tokio::task::spawn_blocking(move || users::verify_password(&hash, ¤t)) @@ -429,6 +444,7 @@ pub async fn change_password( change_required: user.must_change_password, page: Page::new("Account", Some(user.into()), "account"), error, + next: destination, }), ) .into_response()); @@ -453,7 +469,7 @@ pub async fn change_password( auth.login(&updated) .await .map_err(|error| WebError::Internal(error.into()))?; - Ok(axum::response::Redirect::to("/account").into_response()) + Ok(axum::response::Redirect::to(&destination).into_response()) } pub async fn logout_everywhere( @@ -599,6 +615,8 @@ mod tests { .await .unwrap(); let article_uri = format!("/issues/{}/articles/1", seed.date); + let password_change_uri = + format!("/account?change=1&next={}", encode_component(&article_uri)); let app = crate::server::router(AppState::new( seed.db.clone(), crate::config::Config::default(), @@ -624,7 +642,7 @@ mod tests { assert_eq!(login.status(), StatusCode::SEE_OTHER); assert_eq!( login.headers().get(header::LOCATION).unwrap(), - "/account?change=1" + password_change_uri.as_str() ); let cookie = login .headers() @@ -651,14 +669,14 @@ mod tests { assert_eq!(blocked.status(), StatusCode::SEE_OTHER); assert_eq!( blocked.headers().get(header::LOCATION).unwrap(), - "/account?change=1" + password_change_uri.as_str() ); let account = app .clone() .oneshot( Request::builder() - .uri("/account?change=1") + .uri(&password_change_uri) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), @@ -673,6 +691,35 @@ mod tests { ) .unwrap(); assert!(account_body.contains("Choose a new password to continue.")); + assert!(account_body.contains(&format!("name=\"next\" value=\"{article_uri}\""))); + + let invalid_change = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/account/password") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header("sec-fetch-site", "same-origin") + .body(Body::from(format!( + "current_password={temporary_password}&new_password=a+final+reader+password&confirm_password=does+not+match&next={}", + encode_component(&article_uri) + ))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_change.status(), StatusCode::BAD_REQUEST); + let invalid_body = String::from_utf8( + to_bytes(invalid_change.into_body(), 1024 * 1024) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert!(invalid_body.contains("Choose a new password to continue.")); + assert!(invalid_body.contains(&format!("name=\"next\" value=\"{article_uri}\""))); let changed = app .clone() @@ -684,13 +731,18 @@ mod tests { .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header("sec-fetch-site", "same-origin") .body(Body::from(format!( - "current_password={temporary_password}&new_password=a+final+reader+password&confirm_password=a+final+reader+password" + "current_password={temporary_password}&new_password=a+final+reader+password&confirm_password=a+final+reader+password&next={}", + encode_component(&article_uri) ))) .unwrap(), ) .await .unwrap(); assert_eq!(changed.status(), StatusCode::SEE_OTHER); + assert_eq!( + changed.headers().get(header::LOCATION).unwrap(), + article_uri.as_str() + ); assert!( !users::find_by_id(&seed.db, user.id) .await @@ -711,4 +763,48 @@ mod tests { .unwrap(); assert_eq!(article.status(), StatusCode::OK); } + + #[tokio::test] + async fn password_change_rejects_external_next_targets() { + let seed = crate::web::dashboard::tests::seed().await; + for username in ["unsafe_next_one", "unsafe_next_two"] { + users::add(&seed.db, username, "correct horse battery", false) + .await + .unwrap(); + } + let app = crate::server::router(AppState::new( + seed.db, + crate::config::Config::default(), + None, + )); + + for (username, destination) in [ + ("unsafe_next_one", "//evil"), + ("unsafe_next_two", "https://x"), + ] { + let cookie = + crate::web::dashboard::tests::login_cookie(&app, username, "correct horse battery") + .await; + let changed = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/account/password") + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header("sec-fetch-site", "same-origin") + .body(Body::from(format!( + "current_password=correct+horse+battery&new_password=a+final+reader+password&confirm_password=a+final+reader+password&next={}", + encode_component(destination) + ))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(changed.status(), StatusCode::SEE_OTHER); + assert_eq!(changed.headers().get(header::LOCATION).unwrap(), "/"); + } + } } diff --git a/src/web/templates/account.html b/src/web/templates/account.html index 8b0ab56..8be0783 100644 --- a/src/web/templates/account.html +++ b/src/web/templates/account.html @@ -1 +1 @@ -{% extends "layout.html" %}{% block ears %}Reader account{% endblock %}{% block content %}

Reader account

Account

{% if change_required %}

Choose a new password to continue. Your temporary password goes in the current-password field.

{% endif %}{% if !error.is_empty() %}

{{ error }}

{% endif %}
{% endblock %} +{% extends "layout.html" %}{% block ears %}Reader account{% endblock %}{% block content %}

Reader account

Account

{% if change_required %}

Choose a new password to continue. Your temporary password goes in the current-password field.

{% endif %}{% if !error.is_empty() %}

{{ error }}

{% endif %}
{% endblock %} diff --git a/src/web/templates/dashboard/users.html b/src/web/templates/dashboard/users.html index 70d24bd..4ba7f72 100644 --- a/src/web/templates/dashboard/users.html +++ b/src/web/templates/dashboard/users.html @@ -6,12 +6,13 @@

Approve creates a user account with a temporary password and emails it; the user must choose a new password on first sign-in. Or create accounts with daily-epub users add and mark the request done. Change, disable, enable, or sign out users with the daily-epub users CLI.

Access requests

{% if requests.is_empty() %}

No open requests.

{% else %}
- +{% for request in requests %} + - +{% endfor %}
EmailReason or commentRequestedAction
EmailUsernameReason or commentRequestedAction
{{ request.email }}{{ request.suggested_username }} {% if request.reason.is_empty() %}—{% else %}{{ request.reason }}{% endif %} {{ request.requested }}
{% endif %}
{% if users.len() > 1 %}{% endif %}
diff --git a/src/web/templates/request_access.html b/src/web/templates/request_access.html index ef9c277..685c97b 100644 --- a/src/web/templates/request_access.html +++ b/src/web/templates/request_access.html @@ -1 +1 @@ -{% extends "layout.html" %}{% block ears %}Reader access{% endblock %}{% block content %}

Reader access

Request access

{% if submitted %}

Thanks — requests are reviewed by hand; you'll hear back by email.

{% else %}

Signed-in accounts can read the complete issue online, including every article's full text, and download the EPUB and XTC editions. They have no access to ratings or admin tools.

{% if !error.is_empty() %}

{{ error }}

{% endif %}
{% endif %}
{% endblock %} +{% extends "layout.html" %}{% block ears %}Reader access{% endblock %}{% block content %}

Reader access

Request access

{% if submitted %}

Thanks — requests are reviewed by hand; you'll hear back by email.

{% else %}

Signed-in accounts can read the complete issue online, including every article's full text, and download the EPUB and XTC editions. They have no access to ratings or admin tools.

{% if !error.is_empty() %}

{{ error }}

{% endif %}
{% endif %}
{% endblock %}