Approve access requests from the dashboard with an emailed temporary password

Each open access request on /dashboard/users gains an Approve form with
a suggested username. Approving creates a user-role account with a
20-character random temporary password, emails it to the requester with
the sign-in link, and marks the request done; if the email fails the
account is deleted so the admin can retry. Approval refuses when mail is
not configured.

Migration 0008 adds users.must_change_password. A middleware on the
signed-in routers sends flagged users to /account?change=1 until they
set a new password; login honours the flag regardless of `next`, and the
CLI's `users passwd` clears 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 01:43:10 +00:00
co-authored by Claude Fable 5.1
parent 502ba38607
commit 49a2de14ae
9 changed files with 631 additions and 41 deletions
+337 -13
View File
@@ -1,11 +1,12 @@
//! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1).
//! Dashboard: user accounts and access requests (`/dashboard/users`, plan §6.1).
use askama::Template;
use axum::Router;
use axum::extract::{Extension, Path, State};
use axum::extract::{Extension, Form, Path, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum_login::tower_sessions::Session;
use serde::Deserialize;
use sqlx::Row as _;
use crate::server::AppState;
@@ -28,6 +29,7 @@ struct UserLine {
struct AccessRequestLine {
id: i64,
email: String,
suggested_username: String,
reason: String,
requested: String,
}
@@ -38,12 +40,24 @@ struct UsersTemplate {
page: Page,
requests: Vec<AccessRequestLine>,
users: Vec<UserLine>,
mail_configured: bool,
}
#[derive(Debug, Deserialize)]
struct ApproveForm {
username: String,
}
#[derive(Debug)]
struct OpenRequest {
email: String,
}
/// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> {
Router::new()
.route("/dashboard/users", get(index))
.route("/dashboard/users/requests/{id}/approve", post(approve))
.route("/dashboard/users/requests/{id}/done", post(mark_done))
}
@@ -62,11 +76,18 @@ async fn index(
.await
.map_err(db_err)?
.into_iter()
.map(|row| AccessRequestLine {
id: row.get("id"),
email: row.get("email"),
reason: row.get::<Option<String>, _>("reason").unwrap_or_default(),
requested: fmt_stored_time(Some(row.get::<String, _>("requested_at").as_str()), &config),
.map(|row| {
let email: String = row.get("email");
AccessRequestLine {
id: row.get("id"),
suggested_username: suggested_username(&email),
email,
reason: row.get::<Option<String>, _>("reason").unwrap_or_default(),
requested: fmt_stored_time(
Some(row.get::<String, _>("requested_at").as_str()),
&config,
),
}
})
.collect();
let users = crate::web::users::list(&state.db)
@@ -92,9 +113,76 @@ async fn index(
page,
requests,
users,
mail_configured: state.mailer.is_some(),
}))
}
async fn approve(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Path(id): Path<i64>,
Form(form): Form<ApproveForm>,
) -> Result<Response, WebError> {
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/dashboard/users".into(),
})?;
let request = open_request(&state, id).await?;
let Some(mailer) = state.mailer.as_ref() else {
set_flash(
&session,
"error",
"Email is not configured; create the account with the CLI instead".into(),
)
.await?;
return Ok(Redirect::to("/dashboard/users").into_response());
};
let username = form.username.trim();
let (user, temporary_password) =
match crate::web::users::add_with_temporary_password(&state.db, username).await {
Ok(created) => created,
Err(error) => {
set_flash(&session, "error", error.to_string()).await?;
return Ok(Redirect::to("/dashboard/users").into_response());
}
};
let sign_in_url = format!(
"{}/login",
state.config().server.public_url.trim_end_matches('/')
);
let message = crate::mail::Message {
to: request.email.clone(),
subject: "Your Daily EPUB account".into(),
body: format!(
"Username: {username}\nTemporary password: {temporary_password}\nSign in: {sign_in_url}\n\nImportant: you will be asked to choose a new password when you sign in.\n"
),
};
if let Err(error) = mailer.send(message).await {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(user.id)
.execute(state.db.pool())
.await
.map_err(db_err)?;
set_flash(
&session,
"error",
format!("Could not email {}: {error}", request.email),
)
.await?;
return Ok(Redirect::to("/dashboard/users").into_response());
}
finish_request(&state, id, viewer.id).await?;
set_flash(
&session,
"success",
format!("Created {username} and emailed {}.", request.email),
)
.await?;
Ok(Redirect::to("/dashboard/users").into_response())
}
async fn mark_done(
State(state): State<AppState>,
auth: AuthSession,
@@ -104,12 +192,30 @@ async fn mark_done(
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
next: "/dashboard/users".into(),
})?;
finish_request(&state, id, viewer.id).await?;
set_flash(&session, "success", "Access request marked done.".into()).await?;
Ok(Redirect::to("/dashboard/users").into_response())
}
async fn open_request(state: &AppState, id: i64) -> Result<OpenRequest, WebError> {
sqlx::query("SELECT email FROM account_requests WHERE id = ? AND status = 'open'")
.bind(id)
.fetch_optional(state.db.pool())
.await
.map_err(db_err)?
.map(|row| OpenRequest {
email: row.get("email"),
})
.ok_or(WebError::NotFound)
}
async fn finish_request(state: &AppState, id: i64, handled_by: i64) -> Result<(), WebError> {
let result = sqlx::query(
"UPDATE account_requests SET status = 'done', handled_at = ?, handled_by = ?
WHERE id = ? AND status = 'open'",
)
.bind(crate::db::fmt_ts(jiff::Timestamp::now()))
.bind(viewer.id)
.bind(handled_by)
.bind(id)
.execute(state.db.pool())
.await
@@ -117,30 +223,94 @@ async fn mark_done(
if result.rows_affected() == 0 {
return Err(WebError::NotFound);
}
Ok(())
}
async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), WebError> {
session
.insert(
"flash",
Flash {
kind: "success".into(),
text: "Access request marked done.".into(),
kind: kind.into(),
text,
},
)
.await
.map_err(|error| WebError::Internal(error.into()))?;
Ok(Redirect::to("/dashboard/users").into_response())
.map_err(|error| WebError::Internal(error.into()))
}
fn suggested_username(email: &str) -> String {
email
.split_once('@')
.map_or(email, |(local, _)| local)
.bytes()
.map(|byte| byte.to_ascii_lowercase())
.filter(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
})
.map(char::from)
.collect()
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use sqlx::Row as _;
use tower::ServiceExt;
use super::*;
use crate::config::Config;
use crate::server::{AppState, router};
use crate::web::dashboard::tests::{
app_with_users, assert_admin_only, get, login_cookie, response_text, seed,
};
async fn insert_request(db: &crate::db::Db, email: &str) -> i64 {
sqlx::query_scalar(
"INSERT INTO account_requests (email, requested_at)
VALUES (?, '2026-09-05T12:00:00Z') RETURNING id",
)
.bind(email)
.fetch_one(db.pool())
.await
.unwrap()
}
async fn app_with_mailer(db: &crate::db::Db, mailer: crate::mail::Mailer) -> axum::Router {
crate::web::users::add(db, "reader", "correct horse battery", false)
.await
.unwrap();
crate::web::users::add(db, "admin", "correct horse battery", true)
.await
.unwrap();
let mut config = Config::default();
config.server.public_url = "https://daily.example/".into();
let mut state = AppState::new(db.clone(), config, None);
state.mailer = Some(mailer);
router(state)
}
async fn post_approve(
app: &axum::Router,
request_id: i64,
cookie: &str,
username: &str,
) -> axum::response::Response {
app.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(format!("/dashboard/users/requests/{request_id}/approve"))
.header(header::COOKIE, cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("sec-fetch-site", "same-origin")
.body(Body::from(format!("username={username}")))
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
let seed = seed().await;
@@ -158,6 +328,9 @@ mod tests {
assert!(body.contains("1 open access request"), "{body}");
assert!(body.contains("reader@example.com"), "{body}");
assert!(body.contains("Daily commute"), "{body}");
assert!(body.contains("value=\"reader\""), "{body}");
assert!(body.contains(">Approve</button>"), "{body}");
assert!(body.contains("Email is not configured"), "{body}");
assert!(body.contains("Mark done"), "{body}");
assert!(body.contains("reader"), "{body}");
assert!(body.contains("admin"), "{body}");
@@ -213,4 +386,155 @@ mod tests {
assert!(body.contains("0 open access requests"), "{body}");
assert!(!body.contains("done@example.com"), "{body}");
}
#[test]
fn username_suggestions_normalize_the_email_local_part() {
assert_eq!(
suggested_username("Some.Name+news@example.com"),
"somenamenews"
);
assert_eq!(suggested_username("R_E-A_D_E_R@example.com"), "r_e-a_d_e_r");
assert_eq!(suggested_username("...@example.com"), "");
}
#[tokio::test]
async fn approval_creates_a_flagged_user_emails_the_password_and_finishes_request() {
let seed = seed().await;
let request_id = insert_request(&seed.db, "new-reader@example.com").await;
let (mailer, messages) = crate::mail::Mailer::recording();
let app = app_with_mailer(&seed.db, mailer).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post_approve(&app, request_id, &admin, "new_reader").await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get(header::LOCATION).unwrap(),
"/dashboard/users"
);
let user = crate::web::users::find_by_username(&seed.db, "new_reader")
.await
.unwrap()
.unwrap();
assert_eq!(user.role, crate::web::users::Role::User);
assert!(user.must_change_password);
let request =
sqlx::query("SELECT status, handled_at, handled_by FROM account_requests WHERE id = ?")
.bind(request_id)
.fetch_one(seed.db.pool())
.await
.unwrap();
assert_eq!(request.get::<String, _>("status"), "done");
assert!(request.get::<Option<String>, _>("handled_at").is_some());
assert!(request.get::<Option<i64>, _>("handled_by").is_some());
let message = {
let messages = messages
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert_eq!(messages.len(), 1);
messages[0].clone()
};
assert_eq!(message.to, "new-reader@example.com");
assert_eq!(message.subject, "Your Daily EPUB account");
assert!(message.body.contains("Username: new_reader"));
assert!(
message
.body
.contains("Sign in: https://daily.example/login")
);
assert!(message.body.contains("choose a new password"));
let password = message
.body
.lines()
.find_map(|line| line.strip_prefix("Temporary password: "))
.unwrap();
assert_eq!(password.len(), 20);
assert!(crate::web::users::verify_password(
&user.password_hash,
password
));
let page = get(&app, "/dashboard/users", Some(&admin)).await;
let body = response_text(page).await;
assert!(
body.contains("Created new_reader and emailed new-reader@example.com."),
"{body}"
);
}
#[tokio::test]
async fn approval_without_mail_refuses_without_changing_request() {
let seed = seed().await;
let request_id = insert_request(&seed.db, "no-mail@example.com").await;
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post_approve(&app, request_id, &admin, "no_mail").await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert!(
crate::web::users::find_by_username(&seed.db, "no_mail")
.await
.unwrap()
.is_none()
);
let status: String = sqlx::query_scalar("SELECT status FROM account_requests WHERE id = ?")
.bind(request_id)
.fetch_one(seed.db.pool())
.await
.unwrap();
assert_eq!(status, "open");
let page = get(&app, "/dashboard/users", Some(&admin)).await;
let body = response_text(page).await;
assert!(
body.contains("Email is not configured; create the account with the CLI instead"),
"{body}"
);
}
#[tokio::test]
async fn failed_approval_email_deletes_the_user_and_leaves_request_open() {
let seed = seed().await;
let request_id = insert_request(&seed.db, "failure@example.com").await;
let app = app_with_mailer(&seed.db, crate::mail::Mailer::failing()).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post_approve(&app, request_id, &admin, "delivery_failure").await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert!(
crate::web::users::find_by_username(&seed.db, "delivery_failure")
.await
.unwrap()
.is_none()
);
let status: String = sqlx::query_scalar("SELECT status FROM account_requests WHERE id = ?")
.bind(request_id)
.fetch_one(seed.db.pool())
.await
.unwrap();
assert_eq!(status, "open");
let page = get(&app, "/dashboard/users", Some(&admin)).await;
let body = response_text(page).await;
assert!(
body.contains("Could not email failure@example.com"),
"{body}"
);
}
#[tokio::test]
async fn approval_is_admin_only() {
let seed = seed().await;
let request_id = insert_request(&seed.db, "forbidden@example.com").await;
let app = app_with_users(&seed.db).await;
let reader = login_cookie(&app, "reader", "correct horse battery").await;
let response = post_approve(&app, request_id, &reader, "forbidden").await;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(
crate::web::users::find_by_username(&seed.db, "forbidden")
.await
.unwrap()
.is_none()
);
}
}