Merge branch 'followups': password-change redirect and requested usernames
This commit is contained in:
@@ -205,8 +205,9 @@ from a single download menu. An
|
|||||||
article chapters, rate articles, and use every `/dashboard/*` page, including
|
article chapters, rate articles, and use every `/dashboard/*` page, including
|
||||||
settings and jobs. Personalization is shared across accounts for now.
|
settings and jobs. Personalization is shared across accounts for now.
|
||||||
|
|
||||||
Visitors can request an account at `/request-access`; admins review open
|
Visitors can request an account with their preferred username at
|
||||||
requests on `/dashboard/users`. Approving a request creates a `user` account,
|
`/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
|
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
|
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
|
available only when `[mail]` is active, so an account is never created with a
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE account_requests ADD COLUMN username TEXT;
|
||||||
+159
-16
@@ -8,6 +8,7 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
use crate::web::session::{AuthSession, Viewer};
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::users;
|
||||||
use crate::web::{Html, Page, WebError};
|
use crate::web::{Html, Page, WebError};
|
||||||
|
|
||||||
const DESCRIPTION: &str =
|
const DESCRIPTION: &str =
|
||||||
@@ -18,6 +19,8 @@ const DESCRIPTION: &str =
|
|||||||
pub(super) struct AccessRequestForm {
|
pub(super) struct AccessRequestForm {
|
||||||
email: String,
|
email: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
username: String,
|
||||||
|
#[serde(default)]
|
||||||
reason: String,
|
reason: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
website: String,
|
website: String,
|
||||||
@@ -28,6 +31,7 @@ pub(super) struct AccessRequestForm {
|
|||||||
struct RequestAccessTemplate {
|
struct RequestAccessTemplate {
|
||||||
page: Page,
|
page: Page,
|
||||||
email: String,
|
email: String,
|
||||||
|
username: String,
|
||||||
reason: String,
|
reason: String,
|
||||||
error: String,
|
error: String,
|
||||||
submitted: bool,
|
submitted: bool,
|
||||||
@@ -37,6 +41,7 @@ fn template(viewer: Option<Viewer>) -> RequestAccessTemplate {
|
|||||||
RequestAccessTemplate {
|
RequestAccessTemplate {
|
||||||
page: Page::new("Request access", viewer, "").with_description(DESCRIPTION),
|
page: Page::new("Request access", viewer, "").with_description(DESCRIPTION),
|
||||||
email: String::new(),
|
email: String::new(),
|
||||||
|
username: String::new(),
|
||||||
reason: String::new(),
|
reason: String::new(),
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
submitted: false,
|
submitted: false,
|
||||||
@@ -62,25 +67,62 @@ pub(super) async fn submit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let email = form.email.trim();
|
let email = form.email.trim();
|
||||||
|
let username = form.username.trim();
|
||||||
let reason = form.reason.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);
|
let mut view = template(viewer);
|
||||||
view.email = email.to_string();
|
view.email = email.to_string();
|
||||||
|
view.username = username.to_string();
|
||||||
view.reason = reason.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());
|
return Ok(Html(view).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let requested_at = crate::db::fmt_ts(jiff::Timestamp::now());
|
let requested_at = crate::db::fmt_ts(jiff::Timestamp::now());
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO account_requests (email, reason, status, requested_at)
|
"INSERT INTO account_requests (email, username, reason, status, requested_at)
|
||||||
VALUES (?, ?, 'open', ?)
|
VALUES (?, ?, ?, 'open', ?)
|
||||||
ON CONFLICT(email) WHERE status = 'open' DO UPDATE SET
|
ON CONFLICT(email) WHERE status = 'open' DO UPDATE SET
|
||||||
email = excluded.email,
|
email = excluded.email,
|
||||||
|
username = excluded.username,
|
||||||
reason = excluded.reason,
|
reason = excluded.reason,
|
||||||
requested_at = excluded.requested_at",
|
requested_at = excluded.requested_at",
|
||||||
)
|
)
|
||||||
.bind(email)
|
.bind(email)
|
||||||
|
.bind(username)
|
||||||
.bind((!reason.is_empty()).then_some(reason))
|
.bind((!reason.is_empty()).then_some(reason))
|
||||||
.bind(&requested_at)
|
.bind(&requested_at)
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
@@ -105,7 +147,7 @@ pub(super) async fn submit(
|
|||||||
to: to.to_string(),
|
to: to.to_string(),
|
||||||
subject: format!("Access request from {email}"),
|
subject: format!("Access request from {email}"),
|
||||||
body: format!(
|
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('/')
|
config.server.public_url.trim_end_matches('/')
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -121,16 +163,19 @@ pub(super) async fn submit(
|
|||||||
Ok(Html(view).into_response())
|
Ok(Html(view).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(email: &str, reason: &str) -> Option<&'static str> {
|
fn validate(email: &str, username: &str, reason: &str) -> Option<String> {
|
||||||
let valid_email = email.chars().count() <= 254
|
let valid_email = email.chars().count() <= 254
|
||||||
&& email.split_once('@').is_some_and(|(local, domain)| {
|
&& email.split_once('@').is_some_and(|(local, domain)| {
|
||||||
!local.is_empty() && !domain.is_empty() && !domain.contains('@')
|
!local.is_empty() && !domain.is_empty() && !domain.contains('@')
|
||||||
});
|
});
|
||||||
if !valid_email {
|
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 {
|
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
|
None
|
||||||
}
|
}
|
||||||
@@ -226,17 +271,18 @@ mod tests {
|
|||||||
let (_dir, db, app) = app().await;
|
let (_dir, db, app) = app().await;
|
||||||
let (status, body) = post(
|
let (status, body) = post(
|
||||||
&app,
|
&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;
|
.await;
|
||||||
|
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert!(body.contains("requests are reviewed by hand"), "{body}");
|
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())
|
.fetch_one(db.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(row.get::<String, _>("email"), "reader@example.com");
|
assert_eq!(row.get::<String, _>("email"), "reader@example.com");
|
||||||
|
assert_eq!(row.get::<String, _>("username"), "Morning.Reader");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
row.get::<Option<String>, _>("reason").as_deref(),
|
row.get::<Option<String>, _>("reason").as_deref(),
|
||||||
Some("I love the paper")
|
Some("I love the paper")
|
||||||
@@ -258,7 +304,11 @@ mod tests {
|
|||||||
state.mailer = Some(mailer);
|
state.mailer = Some(mailer);
|
||||||
let app = router(state);
|
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);
|
assert_eq!(status, StatusCode::OK);
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||||
loop {
|
loop {
|
||||||
@@ -285,6 +335,7 @@ mod tests {
|
|||||||
"Access request from reader@example.com"
|
"Access request from reader@example.com"
|
||||||
);
|
);
|
||||||
assert!(messages[0].body.contains("Email: 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("Reason: (no reason given)"));
|
||||||
assert!(messages[0].body.contains("Requested at: "));
|
assert!(messages[0].body.contains("Requested at: "));
|
||||||
assert!(
|
assert!(
|
||||||
@@ -301,10 +352,18 @@ mod tests {
|
|||||||
let (_dir, _db, app) = app_with_config(config).await;
|
let (_dir, _db, app) = app_with_config(config).await;
|
||||||
|
|
||||||
for _ in 0..3 {
|
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);
|
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);
|
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,18 +397,102 @@ mod tests {
|
|||||||
assert_eq!(count, 0);
|
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</textarea>"), "{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]
|
#[tokio::test]
|
||||||
async fn repeated_email_updates_the_open_request_case_insensitively() {
|
async fn repeated_email_updates_the_open_request_case_insensitively() {
|
||||||
let (_dir, db, app) = app().await;
|
let (_dir, db, app) = app().await;
|
||||||
post(&app, "email=Reader%40Example.com&reason=first&website=").await;
|
post(
|
||||||
post(&app, "email=reader%40example.COM&reason=updated&website=").await;
|
&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())
|
.fetch_all(db.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(rows[0].get::<String, _>("email"), "reader@example.COM");
|
assert_eq!(rows[0].get::<String, _>("email"), "reader@example.COM");
|
||||||
|
assert_eq!(rows[0].get::<String, _>("username"), "updated.name");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
rows[0].get::<Option<String>, _>("reason").as_deref(),
|
rows[0].get::<Option<String>, _>("reason").as_deref(),
|
||||||
Some("updated")
|
Some("updated")
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async fn index(
|
|||||||
let viewer = auth.user().await.map(Viewer::from);
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
let config = state.config();
|
let config = state.config();
|
||||||
let requests = sqlx::query(
|
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",
|
WHERE status = 'open' ORDER BY requested_at DESC, id DESC",
|
||||||
)
|
)
|
||||||
.fetch_all(state.db.pool())
|
.fetch_all(state.db.pool())
|
||||||
@@ -80,7 +80,9 @@ async fn index(
|
|||||||
let email: String = row.get("email");
|
let email: String = row.get("email");
|
||||||
AccessRequestLine {
|
AccessRequestLine {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
suggested_username: suggested_username(&email),
|
suggested_username: row
|
||||||
|
.get::<Option<String>, _>("username")
|
||||||
|
.unwrap_or_else(|| suggested_username(&email)),
|
||||||
email,
|
email,
|
||||||
reason: row.get::<Option<String>, _>("reason").unwrap_or_default(),
|
reason: row.get::<Option<String>, _>("reason").unwrap_or_default(),
|
||||||
requested: fmt_stored_time(
|
requested: fmt_stored_time(
|
||||||
@@ -315,8 +317,8 @@ mod tests {
|
|||||||
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
|
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
|
||||||
let seed = seed().await;
|
let seed = seed().await;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO account_requests (email, reason, requested_at)
|
"INSERT INTO account_requests (email, username, reason, requested_at)
|
||||||
VALUES ('reader@example.com', 'Daily commute', '2026-09-05T12:00:00Z')",
|
VALUES ('reader@example.com', 'Requested.Name', 'Daily commute', '2026-09-05T12:00:00Z')",
|
||||||
)
|
)
|
||||||
.execute(seed.db.pool())
|
.execute(seed.db.pool())
|
||||||
.await
|
.await
|
||||||
@@ -327,8 +329,10 @@ mod tests {
|
|||||||
assert!(body.contains("<h1>Users</h1>"), "{body}");
|
assert!(body.contains("<h1>Users</h1>"), "{body}");
|
||||||
assert!(body.contains("1 open access request"), "{body}");
|
assert!(body.contains("1 open access request"), "{body}");
|
||||||
assert!(body.contains("reader@example.com"), "{body}");
|
assert!(body.contains("reader@example.com"), "{body}");
|
||||||
|
assert!(body.contains("<th>Username</th>"), "{body}");
|
||||||
|
assert!(body.contains(">Requested.Name</td>"), "{body}");
|
||||||
assert!(body.contains("Daily commute"), "{body}");
|
assert!(body.contains("Daily commute"), "{body}");
|
||||||
assert!(body.contains("value=\"reader\""), "{body}");
|
assert!(body.contains("value=\"Requested.Name\""), "{body}");
|
||||||
assert!(body.contains(">Approve</button>"), "{body}");
|
assert!(body.contains(">Approve</button>"), "{body}");
|
||||||
assert!(body.contains("Email is not configured"), "{body}");
|
assert!(body.contains("Email is not configured"), "{body}");
|
||||||
assert!(body.contains("Mark done"), "{body}");
|
assert!(body.contains("Mark done"), "{body}");
|
||||||
@@ -338,6 +342,24 @@ mod tests {
|
|||||||
assert!(body.contains("daily-epub users"), "{body}");
|
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</td>"), "{body}");
|
||||||
|
assert!(body.contains("value=\"legacyreadernews\""), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn marking_a_request_done_hides_it_from_the_open_list() {
|
async fn marking_a_request_done_hides_it_from_the_open_list() {
|
||||||
let seed = seed().await;
|
let seed = seed().await;
|
||||||
|
|||||||
+106
-10
@@ -18,7 +18,7 @@ use time::OffsetDateTime;
|
|||||||
use crate::db::{Db, fmt_ts};
|
use crate::db::{Db, fmt_ts};
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
use crate::web::users::{self, Role, User};
|
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
|
/// 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
|
/// `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
|
.await
|
||||||
.is_some_and(|user| user.must_change_password)
|
.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
|
next.run(request).await
|
||||||
}
|
}
|
||||||
@@ -305,6 +313,7 @@ struct AccountTemplate {
|
|||||||
page: Page,
|
page: Page,
|
||||||
error: String,
|
error: String,
|
||||||
change_required: bool,
|
change_required: bool,
|
||||||
|
next: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query parameters accepted by the account page.
|
/// Query parameters accepted by the account page.
|
||||||
@@ -312,6 +321,8 @@ struct AccountTemplate {
|
|||||||
pub struct AccountQuery {
|
pub struct AccountQuery {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
change: Option<String>,
|
change: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
next: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `<meta name="description">` for both renders of the sign-in page.
|
/// The `<meta name="description">` for both renders of the sign-in page.
|
||||||
@@ -343,9 +354,9 @@ pub async fn login(
|
|||||||
{
|
{
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
let destination = if user.must_change_password {
|
let destination = if user.must_change_password {
|
||||||
"/account?change=1"
|
format!("/account?change=1&next={}", encode_component(&destination))
|
||||||
} else {
|
} else {
|
||||||
&destination
|
destination
|
||||||
};
|
};
|
||||||
auth.login(&user)
|
auth.login(&user)
|
||||||
.await
|
.await
|
||||||
@@ -356,7 +367,7 @@ pub async fn login(
|
|||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(crate::db::DbError::from)?;
|
.map_err(crate::db::DbError::from)?;
|
||||||
Ok(axum::response::Redirect::to(destination).into_response())
|
Ok(axum::response::Redirect::to(&destination).into_response())
|
||||||
}
|
}
|
||||||
None => Ok((
|
None => Ok((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
@@ -389,6 +400,7 @@ pub async fn account(
|
|||||||
page: Page::new("Account", Some(user.into()), "account"),
|
page: Page::new("Account", Some(user.into()), "account"),
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
change_required,
|
change_required,
|
||||||
|
next: valid_next(query.next.as_deref()).to_string(),
|
||||||
})
|
})
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
@@ -398,6 +410,8 @@ pub struct PasswordForm {
|
|||||||
current_password: String,
|
current_password: String,
|
||||||
new_password: String,
|
new_password: String,
|
||||||
confirm_password: String,
|
confirm_password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
next: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn change_password(
|
pub async fn change_password(
|
||||||
@@ -408,6 +422,7 @@ pub async fn change_password(
|
|||||||
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||||
next: "/account".into(),
|
next: "/account".into(),
|
||||||
})?;
|
})?;
|
||||||
|
let destination = valid_next(form.next.as_deref()).to_string();
|
||||||
let hash = user.password_hash.clone();
|
let hash = user.password_hash.clone();
|
||||||
let current = form.current_password;
|
let current = form.current_password;
|
||||||
let valid = tokio::task::spawn_blocking(move || users::verify_password(&hash, ¤t))
|
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,
|
change_required: user.must_change_password,
|
||||||
page: Page::new("Account", Some(user.into()), "account"),
|
page: Page::new("Account", Some(user.into()), "account"),
|
||||||
error,
|
error,
|
||||||
|
next: destination,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response());
|
.into_response());
|
||||||
@@ -453,7 +469,7 @@ pub async fn change_password(
|
|||||||
auth.login(&updated)
|
auth.login(&updated)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| WebError::Internal(error.into()))?;
|
.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(
|
pub async fn logout_everywhere(
|
||||||
@@ -599,6 +615,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let article_uri = format!("/issues/{}/articles/1", seed.date);
|
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(
|
let app = crate::server::router(AppState::new(
|
||||||
seed.db.clone(),
|
seed.db.clone(),
|
||||||
crate::config::Config::default(),
|
crate::config::Config::default(),
|
||||||
@@ -624,7 +642,7 @@ mod tests {
|
|||||||
assert_eq!(login.status(), StatusCode::SEE_OTHER);
|
assert_eq!(login.status(), StatusCode::SEE_OTHER);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
login.headers().get(header::LOCATION).unwrap(),
|
login.headers().get(header::LOCATION).unwrap(),
|
||||||
"/account?change=1"
|
password_change_uri.as_str()
|
||||||
);
|
);
|
||||||
let cookie = login
|
let cookie = login
|
||||||
.headers()
|
.headers()
|
||||||
@@ -651,14 +669,14 @@ mod tests {
|
|||||||
assert_eq!(blocked.status(), StatusCode::SEE_OTHER);
|
assert_eq!(blocked.status(), StatusCode::SEE_OTHER);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
blocked.headers().get(header::LOCATION).unwrap(),
|
blocked.headers().get(header::LOCATION).unwrap(),
|
||||||
"/account?change=1"
|
password_change_uri.as_str()
|
||||||
);
|
);
|
||||||
|
|
||||||
let account = app
|
let account = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/account?change=1")
|
.uri(&password_change_uri)
|
||||||
.header(header::COOKIE, &cookie)
|
.header(header::COOKIE, &cookie)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
@@ -673,6 +691,35 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(account_body.contains("Choose a new password to continue."));
|
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
|
let changed = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -684,13 +731,18 @@ mod tests {
|
|||||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
.header("sec-fetch-site", "same-origin")
|
.header("sec-fetch-site", "same-origin")
|
||||||
.body(Body::from(format!(
|
.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(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(changed.status(), StatusCode::SEE_OTHER);
|
assert_eq!(changed.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(
|
||||||
|
changed.headers().get(header::LOCATION).unwrap(),
|
||||||
|
article_uri.as_str()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!users::find_by_id(&seed.db, user.id)
|
!users::find_by_id(&seed.db, user.id)
|
||||||
.await
|
.await
|
||||||
@@ -711,4 +763,48 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(article.status(), StatusCode::OK);
|
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(), "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}<span>Reader account</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader account</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Account</h1>{% if change_required %}<p class="notice mt-6">Choose a new password to continue. Your temporary password goes in the current-password field.</p>{% endif %}{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 border-b border-rule pb-9 font-sans text-sm" method="post" action="/account/password"><label class="grid gap-1.5 font-medium">Current password <input class="min-h-11 w-full" type="password" name="current_password" autocomplete="current-password" required></label><label class="grid gap-1.5 font-medium">New password <input class="min-h-11 w-full" type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label class="grid gap-1.5 font-medium">Confirm password <input class="min-h-11 w-full" type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="btn-primary mt-1 w-full">Change password</button></form><div class="mt-7 flex flex-wrap gap-3 font-sans text-sm"><form class="m-0" method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button class="btn">Sign out everywhere</button></form><form class="m-0" method="post" action="/logout"><button class="btn">Sign out</button></form></div></section>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span>Reader account</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader account</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Account</h1>{% if change_required %}<p class="notice mt-6">Choose a new password to continue. Your temporary password goes in the current-password field.</p>{% endif %}{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 border-b border-rule pb-9 font-sans text-sm" method="post" action="/account/password"><input type="hidden" name="next" value="{{ next }}"><label class="grid gap-1.5 font-medium">Current password <input class="min-h-11 w-full" type="password" name="current_password" autocomplete="current-password" required></label><label class="grid gap-1.5 font-medium">New password <input class="min-h-11 w-full" type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label class="grid gap-1.5 font-medium">Confirm password <input class="min-h-11 w-full" type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="btn-primary mt-1 w-full">Change password</button></form><div class="mt-7 flex flex-wrap gap-3 font-sans text-sm"><form class="m-0" method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button class="btn">Sign out everywhere</button></form><form class="m-0" method="post" action="/logout"><button class="btn">Sign out</button></form></div></section>{% endblock %}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
<p class="muted text-sm">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 <code>daily-epub users add</code> and mark the request done. Change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI.</p>
|
<p class="muted text-sm">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 <code>daily-epub users add</code> and mark the request done. Change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI.</p>
|
||||||
<section class="card"><h2>Access requests</h2>
|
<section class="card"><h2>Access requests</h2>
|
||||||
{% if requests.is_empty() %}<p class="muted">No open requests.</p>{% else %}<div class="scroll-x"><table>
|
{% if requests.is_empty() %}<p class="muted">No open requests.</p>{% else %}<div class="scroll-x"><table>
|
||||||
<thead><tr><th>Email</th><th>Reason or comment</th><th>Requested</th><th><span class="sr-only">Action</span></th></tr></thead>
|
<thead><tr><th>Email</th><th>Username</th><th>Reason or comment</th><th>Requested</th><th><span class="sr-only">Action</span></th></tr></thead>
|
||||||
<tbody>{% for request in requests %}<tr>
|
<tbody>{% for request in requests %}<tr>
|
||||||
<td class="font-medium text-ink"><a href="mailto:{{ request.email }}">{{ request.email }}</a></td>
|
<td class="font-medium text-ink"><a href="mailto:{{ request.email }}">{{ request.email }}</a></td>
|
||||||
|
<td class="font-medium text-ink">{{ request.suggested_username }}</td>
|
||||||
<td class="cell-wrap">{% if request.reason.is_empty() %}<span class="text-muted">—</span>{% else %}{{ request.reason }}{% endif %}</td>
|
<td class="cell-wrap">{% if request.reason.is_empty() %}<span class="text-muted">—</span>{% else %}{{ request.reason }}{% endif %}</td>
|
||||||
<td class="cell-tight text-muted">{{ request.requested }}</td>
|
<td class="cell-tight text-muted">{{ request.requested }}</td>
|
||||||
<td><div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/users/requests/{{ request.id }}/approve"><input type="text" name="username" value="{{ request.suggested_username }}" required pattern="[a-z0-9_-]+" aria-label="Username for {{ request.email }}"><button class="btn-primary" type="submit"{% if !mail_configured %} disabled title="Email is not configured"{% endif %}>Approve</button></form><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></div></td>
|
<td><div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/users/requests/{{ request.id }}/approve"><input type="text" name="username" value="{{ request.suggested_username }}" autocomplete="username" maxlength="32" required pattern="[A-Za-z0-9._-]+" aria-label="Username for {{ request.email }}"><button class="btn-primary" type="submit"{% if !mail_configured %} disabled title="Email is not configured"{% endif %}>Approve</button></form><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></div></td>
|
||||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Request access</h1>{% if submitted %}<p class="notice mt-6">Thanks — requests are reviewed by hand; you'll hear back by email.</p>{% else %}<p class="mt-4 text-ink-2">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.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/request-access"><label class="grid gap-1.5 font-medium">Email <input class="min-h-11 w-full" type="email" name="email" autocomplete="email" value="{{ email }}" maxlength="254" required></label><label class="grid gap-1.5 font-medium">Reason or comment <span class="font-normal text-muted">Optional</span><textarea class="w-full" name="reason" maxlength="2000">{{ reason }}</textarea></label><label class="sr-only" aria-hidden="true">Website <input name="website" autocomplete="off" tabindex="-1"></label><button class="btn-primary mt-1 w-full" type="submit">Request access</button></form>{% endif %}</section>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Request access</h1>{% if submitted %}<p class="notice mt-6">Thanks — requests are reviewed by hand; you'll hear back by email.</p>{% else %}<p class="mt-4 text-ink-2">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.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/request-access"><label class="grid gap-1.5 font-medium">Email <input class="min-h-11 w-full" type="email" name="email" autocomplete="email" value="{{ email }}" maxlength="254" required></label><label class="grid gap-1.5 font-medium">Username <input class="min-h-11 w-full" type="text" name="username" autocomplete="username" value="{{ username }}" maxlength="32" pattern="[A-Za-z0-9._-]+" required><span class="font-normal text-muted">1–32 characters: letters, digits, . _ -</span></label><label class="grid gap-1.5 font-medium">Reason or comment <span class="font-normal text-muted">Optional</span><textarea class="w-full" name="reason" maxlength="2000">{{ reason }}</textarea></label><label class="sr-only" aria-hidden="true">Website <input name="website" autocomplete="off" tabindex="-1"></label><button class="btn-primary mt-1 w-full" type="submit">Request access</button></form>{% endif %}</section>{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user