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:
2026-09-03 18:19:08 +00:00
co-authored by Claude Fable 5.1
parent 10cbe9ed79
commit eb960f1b57
16 changed files with 599 additions and 50 deletions
+72 -2
View File
@@ -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}");
}
}