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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
2026-09-07 02:28:09 +00:00
co-authored by Claude Fable 5.1
parent a3a041dc06
commit e56acfb9dc
8 changed files with 301 additions and 37 deletions
+27 -5
View File
@@ -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::<Option<String>, _>("username")
.unwrap_or_else(|| suggested_username(&email)),
email,
reason: row.get::<Option<String>, _>("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("<h1>Users</h1>"), "{body}");
assert!(body.contains("1 open access request"), "{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("value=\"reader\""), "{body}");
assert!(body.contains("value=\"Requested.Name\""), "{body}");
assert!(body.contains(">Approve</button>"), "{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</td>"), "{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;