Let public visitors request a reader account
New public page GET/POST /request-access explains what a signed-in reader account gets (complete issues online, EPUB/XTC downloads, no ratings or admin tools) and takes an email plus an optional reason. A honeypot field drops bots and a partial unique index keeps one open request per email, updating it on resubmit. Open requests show on /dashboard/users with a "Mark done" button and as a tile on the overview; accounts are still created with the daily-epub users CLI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
@@ -203,7 +203,10 @@ sees complete issues and article chapters and can download artifacts. An
|
||||
`admin` can additionally rate articles and use every `/dashboard/*` page,
|
||||
including settings and jobs. Personalization is shared across accounts for now.
|
||||
|
||||
Accounts are deliberately managed on the host, not in the browser. Usernames
|
||||
Visitors can request an account at `/request-access`; admins review open
|
||||
requests on `/dashboard/users`, create accounts with `daily-epub users add
|
||||
<username>` on the server, and then mark each request done. Accounts are
|
||||
deliberately managed on the host, not in the browser. Usernames
|
||||
are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
|
||||
`daily-epub users add <username> --admin`; use `users passwd`, `role`,
|
||||
`disable`/`enable`, `list`, and `logout` for later administration. Password
|
||||
@@ -236,6 +239,7 @@ these writes.
|
||||
| `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. |
|
||||
| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. |
|
||||
| `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. |
|
||||
| `GET/POST /request-access` | Public | Request a reader account; requests are reviewed by an admin and fulfilled with the CLI. |
|
||||
| `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. |
|
||||
| `POST /rate` | Admin | Append an attributed dashboard rating event. |
|
||||
| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
|
||||
@@ -243,7 +247,7 @@ these writes.
|
||||
| `GET/POST /dashboard/profile`, `POST /dashboard/profile/restore` | Admin | Edit `profile.md`, inspect prompts/adjustments, and restore a version. |
|
||||
| `GET/POST /dashboard/settings`, `POST /dashboard/settings/providers`, `GET /dashboard/settings/history` | Admin | Edit validated configuration and inspect its audit log. |
|
||||
| `GET /dashboard/jobs`, `GET /dashboard/jobs/{id}`, `POST /dashboard/jobs/{name}` | Admin | Start fixed systemd jobs and inspect status and logs. |
|
||||
| `GET /dashboard/users` | Admin | Read-only users and open-session list; edits use the CLI. |
|
||||
| `GET /dashboard/users`, `POST /dashboard/users/requests/{id}/done` | Admin | Review access requests and view users/open sessions; account edits use the CLI. |
|
||||
| `GET /files/epub/{name}`, `/files/xtc/{name}` | Public if Basic auth is unset; otherwise session or Basic auth | Published downloads. Keeping them public when Basic auth is absent preserves existing OPDS acquisition links. |
|
||||
| `GET /opds`, `/opds/`, `/opds/daily.xml` | Existing optional Basic auth | OPDS acquisition feed. |
|
||||
| `GET /r/...`, `/healthz`, `/issues.json` | Existing policy | HMAC rating links, health, and issue reports. |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE account_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL COLLATE NOCASE,
|
||||
reason TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('open', 'done')) DEFAULT 'open',
|
||||
requested_at TEXT NOT NULL,
|
||||
handled_at TEXT,
|
||||
handled_by INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_account_requests_open_email
|
||||
ON account_requests(email) WHERE status = 'open';
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Public account-access request page.
|
||||
|
||||
use askama::Template;
|
||||
use axum::Form;
|
||||
use axum::extract::State;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::web::session::{AuthSession, Viewer};
|
||||
use crate::web::{Html, Page, WebError};
|
||||
|
||||
const DESCRIPTION: &str =
|
||||
"Request an account to read complete issues online and download the EPUB and XTC editions.";
|
||||
|
||||
/// Fields accepted by the public access-request form.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AccessRequestForm {
|
||||
email: String,
|
||||
#[serde(default)]
|
||||
reason: String,
|
||||
#[serde(default)]
|
||||
website: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "request_access.html")]
|
||||
struct RequestAccessTemplate {
|
||||
page: Page,
|
||||
email: String,
|
||||
reason: String,
|
||||
error: String,
|
||||
submitted: bool,
|
||||
}
|
||||
|
||||
fn template(viewer: Option<Viewer>) -> RequestAccessTemplate {
|
||||
RequestAccessTemplate {
|
||||
page: Page::new("Request access", viewer, "").with_description(DESCRIPTION),
|
||||
email: String::new(),
|
||||
reason: String::new(),
|
||||
error: String::new(),
|
||||
submitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /request-access`: explain reader accounts and show the request form.
|
||||
pub(super) async fn page(auth: AuthSession) -> Response {
|
||||
Html(template(auth.user().await.map(Viewer::from))).into_response()
|
||||
}
|
||||
|
||||
/// `POST /request-access`: validate and store (or update) an open request.
|
||||
pub(super) async fn submit(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Form(form): Form<AccessRequestForm>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth.user().await.map(Viewer::from);
|
||||
if !form.website.is_empty() {
|
||||
let mut view = template(viewer);
|
||||
view.submitted = true;
|
||||
return Ok(Html(view).into_response());
|
||||
}
|
||||
|
||||
let email = form.email.trim();
|
||||
let reason = form.reason.trim();
|
||||
if let Some(error) = validate(email, reason) {
|
||||
let mut view = template(viewer);
|
||||
view.email = email.to_string();
|
||||
view.reason = reason.to_string();
|
||||
view.error = error.to_string();
|
||||
return Ok(Html(view).into_response());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO account_requests (email, reason, status, requested_at)
|
||||
VALUES (?, ?, 'open', ?)
|
||||
ON CONFLICT(email) WHERE status = 'open' DO UPDATE SET
|
||||
email = excluded.email,
|
||||
reason = excluded.reason,
|
||||
requested_at = excluded.requested_at",
|
||||
)
|
||||
.bind(email)
|
||||
.bind((!reason.is_empty()).then_some(reason))
|
||||
.bind(crate::db::fmt_ts(jiff::Timestamp::now()))
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(|error| WebError::Db(error.into()))?;
|
||||
|
||||
let mut view = template(viewer);
|
||||
view.submitted = true;
|
||||
Ok(Html(view).into_response())
|
||||
}
|
||||
|
||||
fn validate(email: &str, reason: &str) -> Option<&'static str> {
|
||||
let valid_email = email.chars().count() <= 254
|
||||
&& email.split_once('@').is_some_and(|(local, domain)| {
|
||||
!local.is_empty() && !domain.is_empty() && !domain.contains('@')
|
||||
});
|
||||
if !valid_email {
|
||||
return Some("Enter a valid email address.");
|
||||
}
|
||||
if reason.chars().count() > 2000 {
|
||||
return Some("Reason or comment must be 2,000 characters or fewer.");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use sqlx::Row as _;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::server::{AppState, router};
|
||||
|
||||
async fn app() -> (tempfile::TempDir, Db, axum::Router) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
|
||||
.await
|
||||
.unwrap();
|
||||
let app = router(AppState::new(db.clone(), Config::default(), None));
|
||||
(dir, db, app)
|
||||
}
|
||||
|
||||
async fn get(app: &axum::Router) -> (StatusCode, String) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/request-access")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = String::from_utf8(
|
||||
to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn post(app: &axum::Router, body: &str) -> (StatusCode, String) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/request-access")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = String::from_utf8(
|
||||
to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_explains_reader_access() {
|
||||
let (_dir, _db, app) = app().await;
|
||||
let (status, body) = get(&app).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("every article's full text"), "{body}");
|
||||
assert!(body.contains("EPUB and XTC editions"), "{body}");
|
||||
assert!(
|
||||
body.contains("no access to ratings or admin tools"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_request_is_stored_and_confirmed() {
|
||||
let (_dir, db, app) = app().await;
|
||||
let (status, body) = post(
|
||||
&app,
|
||||
"email=reader%40example.com&reason=I+love+the+paper&website=",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("requests are reviewed by hand"), "{body}");
|
||||
let row = sqlx::query("SELECT email, reason, status FROM account_requests")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.get::<String, _>("email"), "reader@example.com");
|
||||
assert_eq!(
|
||||
row.get::<Option<String>, _>("reason").as_deref(),
|
||||
Some("I love the paper")
|
||||
);
|
||||
assert_eq!(row.get::<String, _>("status"), "open");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn honeypot_pretends_success_without_storing() {
|
||||
let (_dir, db, app) = app().await;
|
||||
let (status, body) =
|
||||
post(&app, "email=bot%40example.com&reason=spam&website=bot-site").await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("requests are reviewed by hand"), "{body}");
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_requests")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_email_rerenders_with_an_error_without_storing() {
|
||||
let (_dir, db, app) = app().await;
|
||||
let (status, body) = post(&app, "email=not-an-email&reason=hello&website=").await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body.contains("Enter a valid email address."), "{body}");
|
||||
assert!(body.contains("value=\"not-an-email\""), "{body}");
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_requests")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_email_updates_the_open_request_case_insensitively() {
|
||||
let (_dir, db, app) = app().await;
|
||||
post(&app, "email=Reader%40Example.com&reason=first&website=").await;
|
||||
post(&app, "email=reader%40example.COM&reason=updated&website=").await;
|
||||
|
||||
let rows = sqlx::query("SELECT email, reason FROM account_requests")
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].get::<String, _>("email"), "reader@example.COM");
|
||||
assert_eq!(
|
||||
rows[0].get::<Option<String>, _>("reason").as_deref(),
|
||||
Some("updated")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -421,6 +421,7 @@ struct OverviewTemplate {
|
||||
budget: Vec<BudgetLine>,
|
||||
ratings: Vec<LabelCount>,
|
||||
ratings_total: i64,
|
||||
access_requests: i64,
|
||||
unrated: Vec<UnratedPick>,
|
||||
active_jobs: Vec<JobLine>,
|
||||
finished_jobs: Vec<JobLine>,
|
||||
@@ -442,6 +443,11 @@ async fn overview(
|
||||
let last_run = last_run_card(db, &config).await?;
|
||||
let budget = budget_lines(db, &config, now).await?;
|
||||
let (ratings, ratings_total) = ratings_this_week(db, now).await?;
|
||||
let access_requests: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM account_requests WHERE status = 'open'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
let unrated = unrated_picks(db).await?;
|
||||
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
|
||||
let sparklines = overview_sparklines(db).await?;
|
||||
@@ -459,6 +465,7 @@ async fn overview(
|
||||
budget,
|
||||
ratings,
|
||||
ratings_total,
|
||||
access_requests,
|
||||
unrated,
|
||||
active_jobs,
|
||||
finished_jobs,
|
||||
|
||||
+142
-6
@@ -2,13 +2,17 @@
|
||||
|
||||
use askama::Template;
|
||||
use axum::Router;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::routing::get;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum_login::tower_sessions::Session;
|
||||
use sqlx::Row as _;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::web::session::{AuthSession, Viewer};
|
||||
use crate::web::{Html, Page, WebError, format_time, take_flash};
|
||||
use crate::web::{Flash, Html, Page, WebError, format_time, take_flash};
|
||||
|
||||
use super::{db_err, fmt_stored_time};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct UserLine {
|
||||
@@ -20,16 +24,27 @@ struct UserLine {
|
||||
open_sessions: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AccessRequestLine {
|
||||
id: i64,
|
||||
email: String,
|
||||
reason: String,
|
||||
requested: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dashboard/users.html")]
|
||||
struct UsersTemplate {
|
||||
page: Page,
|
||||
requests: Vec<AccessRequestLine>,
|
||||
users: Vec<UserLine>,
|
||||
}
|
||||
|
||||
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/dashboard/users", get(index))
|
||||
Router::new()
|
||||
.route("/dashboard/users", get(index))
|
||||
.route("/dashboard/users/requests/{id}/done", post(mark_done))
|
||||
}
|
||||
|
||||
async fn index(
|
||||
@@ -39,6 +54,21 @@ async fn index(
|
||||
) -> Result<Html<UsersTemplate>, WebError> {
|
||||
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
|
||||
WHERE status = 'open' ORDER BY requested_at DESC, id DESC",
|
||||
)
|
||||
.fetch_all(state.db.pool())
|
||||
.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),
|
||||
})
|
||||
.collect();
|
||||
let users = crate::web::users::list(&state.db)
|
||||
.await
|
||||
.map_err(WebError::Internal)?
|
||||
@@ -58,23 +88,129 @@ async fn index(
|
||||
.collect();
|
||||
let mut page = Page::new("Users", viewer, "users");
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(UsersTemplate { page, users }))
|
||||
Ok(Html(UsersTemplate {
|
||||
page,
|
||||
requests,
|
||||
users,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mark_done(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthSession,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Response, WebError> {
|
||||
let viewer = auth.user().await.ok_or_else(|| WebError::Unauthenticated {
|
||||
next: "/dashboard/users".into(),
|
||||
})?;
|
||||
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(id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(WebError::NotFound);
|
||||
}
|
||||
session
|
||||
.insert(
|
||||
"flash",
|
||||
Flash {
|
||||
kind: "success".into(),
|
||||
text: "Access request marked done.".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| WebError::Internal(error.into()))?;
|
||||
Ok(Redirect::to("/dashboard/users").into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::web::dashboard::tests::{app_with_users, assert_admin_only, seed};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode, header};
|
||||
use sqlx::Row as _;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::web::dashboard::tests::{
|
||||
app_with_users, assert_admin_only, get, login_cookie, response_text, seed,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
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')",
|
||||
)
|
||||
.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("<h1>Users</h1>"), "{body}");
|
||||
assert!(body.contains("1 open access request"), "{body}");
|
||||
assert!(body.contains("reader@example.com"), "{body}");
|
||||
assert!(body.contains("Daily commute"), "{body}");
|
||||
assert!(body.contains("Mark done"), "{body}");
|
||||
assert!(body.contains("reader"), "{body}");
|
||||
assert!(body.contains("admin"), "{body}");
|
||||
assert!(body.contains("Open sessions"), "{body}");
|
||||
assert!(body.contains("daily-epub users"), "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marking_a_request_done_hides_it_from_the_open_list() {
|
||||
let seed = seed().await;
|
||||
let request_id = sqlx::query(
|
||||
"INSERT INTO account_requests (email, reason, requested_at)
|
||||
VALUES ('done@example.com', NULL, '2026-09-05T12:00:00Z') RETURNING id",
|
||||
)
|
||||
.fetch_one(seed.db.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
.get::<i64, _>("id");
|
||||
let app = app_with_users(&seed.db).await;
|
||||
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/dashboard/users/requests/{request_id}/done"))
|
||||
.header(header::COOKIE, &admin)
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get(header::LOCATION).unwrap(),
|
||||
"/dashboard/users"
|
||||
);
|
||||
|
||||
let row =
|
||||
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!(row.get::<String, _>("status"), "done");
|
||||
assert!(row.get::<Option<String>, _>("handled_at").is_some());
|
||||
assert!(row.get::<Option<i64>, _>("handled_by").is_some());
|
||||
|
||||
let page = get(&app, "/dashboard/users", Some(&admin)).await;
|
||||
let body = response_text(page).await;
|
||||
assert!(body.contains("0 open access requests"), "{body}");
|
||||
assert!(!body.contains("done@example.com"), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod access;
|
||||
pub mod dashboard;
|
||||
pub mod issue;
|
||||
pub mod public;
|
||||
@@ -694,6 +695,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
||||
|
||||
axum::Router::new()
|
||||
.route("/", get(public::latest))
|
||||
.route("/request-access", get(access::page).post(access::submit))
|
||||
.route("/issues", get(public::archive))
|
||||
.route("/issues/{date}", get(public::show_issue))
|
||||
.route("/feed.xml", get(public::feed))
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@
|
||||
<div class="tile"><span class="tile-label">Verdicts, 7 days</span><span class="tile-num">{{ ratings_total }}</span><span class="tile-delta"><a href="/dashboard/ratings">rating history</a></span></div>
|
||||
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
|
||||
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
|
||||
<div class="tile"><span class="tile-label">Access requests</span><span class="tile-num">{{ access_requests }}</span><span class="tile-delta"><a href="/dashboard/users">review requests</a></span></div>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||
<header class="page-head"><div>
|
||||
<h1>Users</h1>
|
||||
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
|
||||
<p class="page-desc">{{ requests.len() }} open access request{% if requests.len() != 1 %}s{% endif %}; every account on this server, with its role and open sessions.</p>
|
||||
</div></header>
|
||||
<p class="muted text-sm">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
|
||||
<p class="muted text-sm">Create accounts with <code>daily-epub users add <username></code> on the server, then mark the request done. Change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI.</p>
|
||||
<section class="card"><h2>Access requests</h2>
|
||||
{% if requests.is_empty() %}<p class="muted">No open requests.</p>{% else %}<div class="scroll-x"><table>
|
||||
<thead><tr><th>Email</th><th>Reason or comment</th><th>Requested</th><th><span class="sr-only">Action</span></th></tr></thead>
|
||||
<tbody>{% for request in requests %}<tr>
|
||||
<td class="font-medium text-ink"><a href="mailto:{{ request.email }}">{{ request.email }}</a></td>
|
||||
<td class="cell-wrap">{% if request.reason.is_empty() %}<span class="text-muted">—</span>{% else %}{{ request.reason }}{% endif %}</td>
|
||||
<td class="cell-tight text-muted">{{ request.requested }}</td>
|
||||
<td><form method="post" action="/dashboard/users/requests/{{ request.id }}/done"><button class="btn" type="submit">Mark done</button></form></td>
|
||||
</tr>{% endfor %}</tbody></table></div>{% endif %}
|
||||
</section>
|
||||
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
|
||||
<tbody>{% for user in users %}<tr>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<article class="reader-page mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
||||
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
|
||||
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
|
||||
<p class="notice mb-8"><a href="/login">Sign in</a> to read every article's full text and download the editions, or <a href="/request-access">request access</a>.</p>
|
||||
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||
{% for section in issue.sections %}<section class="mt-14"><h2 class="reader-section-heading">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min</p>{% match entry.summary %}{% when Some with (summary) %}<p class="index-summary mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
|
||||
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Sign in</h1><p class="mt-4 text-ink-2">Open the full paper, article chapters, and downloads.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label class="grid gap-1.5 font-medium">Username <input class="min-h-11 w-full" name="username" autocomplete="username" required></label><label class="grid gap-1.5 font-medium">Password <input class="min-h-11 w-full" type="password" name="password" autocomplete="current-password" required></label><button class="btn-primary mt-1 w-full" type="submit">Sign in</button></form></section>{% endblock %}
|
||||
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Sign in</h1><p class="mt-4 text-ink-2">Open the full paper, article chapters, and downloads.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label class="grid gap-1.5 font-medium">Username <input class="min-h-11 w-full" name="username" autocomplete="username" required></label><label class="grid gap-1.5 font-medium">Password <input class="min-h-11 w-full" type="password" name="password" autocomplete="current-password" required></label><button class="btn-primary mt-1 w-full" type="submit">Sign in</button></form><p class="mt-6 text-center font-sans text-sm text-muted">Don't have an account? <a href="/request-access">Request access</a>.</p></section>{% endblock %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Request access</h1>{% if submitted %}<p class="notice mt-6">Thanks — requests are reviewed by hand; you'll hear back by email.</p>{% else %}<p class="mt-4 text-ink-2">Signed-in accounts can read the complete issue online, including every article's full text, and download the EPUB and XTC editions. They have no access to ratings or admin tools.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/request-access"><label class="grid gap-1.5 font-medium">Email <input class="min-h-11 w-full" type="email" name="email" autocomplete="email" value="{{ email }}" maxlength="254" required></label><label class="grid gap-1.5 font-medium">Reason or comment <span class="font-normal text-muted">Optional</span><textarea class="w-full" name="reason" maxlength="2000">{{ reason }}</textarea></label><label class="sr-only" aria-hidden="true">Website <input name="website" autocomplete="off" tabindex="-1"></label><button class="btn-primary mt-1 w-full" type="submit">Request access</button></form>{% endif %}</section>{% endblock %}
|
||||
Reference in New Issue
Block a user