Web dashboard step 7: docs, users page, polish
Users page, README and config.example updates, implementation notes, the rollout runbook, site-layout 404/500 pages, human-readable download sizes, dark-mode and narrow-screen polish, and a smoke test over every dashboard route; also removes zdiff3 ancestor markers left by earlier merges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -1185,4 +1185,45 @@ pub(crate) mod tests {
|
||||
assert!(body.contains("2 runs · max $0.11"), "{body}");
|
||||
assert!(!body.contains("style=\""), "no inline styles under the CSP");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_dashboard_route_template_renders_with_fixture_data() {
|
||||
let seed = seed().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO jobs
|
||||
(id, name, unit, requested_at, started_at, finished_at, status, message, run_id)
|
||||
VALUES (99, 'features-prune', 'daily-epub-job@features-prune.service',
|
||||
'2026-09-02T06:00:00Z', '2026-09-02T06:00:01Z',
|
||||
'2026-09-02T06:00:02Z', 'ok', 'pruned fixture rows', ?)",
|
||||
)
|
||||
.bind(seed.run_id)
|
||||
.execute(seed.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let app = app_with_users(&seed.db).await;
|
||||
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||
let routes = vec![
|
||||
"/dashboard".to_string(),
|
||||
"/dashboard/runs".to_string(),
|
||||
format!("/dashboard/runs/{}", seed.run_id),
|
||||
"/dashboard/articles".to_string(),
|
||||
"/dashboard/articles/1".to_string(),
|
||||
"/dashboard/ratings".to_string(),
|
||||
"/dashboard/ratings?tab=events".to_string(),
|
||||
"/dashboard/profile".to_string(),
|
||||
"/dashboard/stats?days=14".to_string(),
|
||||
"/dashboard/settings".to_string(),
|
||||
"/dashboard/settings/history".to_string(),
|
||||
"/dashboard/jobs".to_string(),
|
||||
"/dashboard/jobs/99".to_string(),
|
||||
"/dashboard/users".to_string(),
|
||||
];
|
||||
for uri in routes {
|
||||
let response = get(&app, &uri, Some(&admin)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK, "{uri}");
|
||||
let body = response_text(response).await;
|
||||
assert!(body.contains("<!doctype html>"), "{uri}: {body}");
|
||||
assert!(body.contains("The Daily EPUB"), "{uri}: {body}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
//! Dashboard: users pages. Filled in by web dashboard plan step 7.
|
||||
//! Dashboard: the read-only Users page (`/dashboard/users`, plan §6.1).
|
||||
|
||||
use askama::Template;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::routing::get;
|
||||
use axum_login::tower_sessions::Session;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::web::session::{AuthSession, Viewer};
|
||||
use crate::web::{Html, Page, WebError, format_time, take_flash};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UserLine {
|
||||
username: String,
|
||||
role: String,
|
||||
disabled: bool,
|
||||
created: String,
|
||||
last_login: String,
|
||||
open_sessions: i64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dashboard/users.html")]
|
||||
struct UsersTemplate {
|
||||
page: Page,
|
||||
users: Vec<UserLine>,
|
||||
}
|
||||
|
||||
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
Router::new().route("/dashboard/users", get(index))
|
||||
}
|
||||
|
||||
async fn index(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Html<UsersTemplate>, WebError> {
|
||||
let viewer = auth.user().await.map(Viewer::from);
|
||||
let config = state.config();
|
||||
let users = crate::web::users::list(&state.db)
|
||||
.await
|
||||
.map_err(WebError::Internal)?
|
||||
.into_iter()
|
||||
.map(|row| UserLine {
|
||||
username: row.user.username,
|
||||
role: row.user.role.to_string(),
|
||||
disabled: row.user.disabled,
|
||||
created: format_time(row.user.created_at, &config),
|
||||
last_login: row
|
||||
.user
|
||||
.last_login_at
|
||||
.map(|at| format_time(at, &config))
|
||||
.unwrap_or_else(|| "never".into()),
|
||||
open_sessions: row.open_sessions,
|
||||
})
|
||||
.collect();
|
||||
let mut page = Page::new("Users", viewer, "dashboard");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(UsersTemplate { page, users }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed};
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
|
||||
let seed = seed().await;
|
||||
let app = app_with_users(&seed.db).await;
|
||||
let body = assert_admin_only(&app, "/dashboard/users").await;
|
||||
|
||||
assert!(body.contains("<h1>Users</h1>"), "{body}");
|
||||
assert!(body.contains("reader"), "{body}");
|
||||
assert!(body.contains("admin"), "{body}");
|
||||
assert!(body.contains("Open sessions"), "{body}");
|
||||
assert!(body.contains("daily-epub users"), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user