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:
+337
-13
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user