Merge branch 'fix-request-access': public account request flow

# Conflicts:
#	src/web/static/app.css
This commit is contained in:
2026-09-06 18:04:19 +00:00
12 changed files with 445 additions and 12 deletions
+6 -2
View File
@@ -205,7 +205,10 @@ from a single download menu. An
article chapters, rate articles, and use every `/dashboard/*` page, including article chapters, rate articles, and use every `/dashboard/*` page, including
settings and jobs. Personalization is shared across accounts for now. 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 are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
`daily-epub users add <username> --admin`; use `users passwd`, `role`, `daily-epub users add <username> --admin`; use `users passwd`, `role`,
`disable`/`enable`, `list`, and `logout` for later administration. Password `disable`/`enable`, `list`, and `logout` for later administration. Password
@@ -261,6 +264,7 @@ articles do not yet have embeddings to compare.
| `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. | | `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 /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 /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. | | `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. | | `POST /rate` | Admin | Append an attributed dashboard rating event. |
| `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. | | `GET /dashboard` | Admin | Run, budget, rating, job, and config overview. |
@@ -269,7 +273,7 @@ articles do not yet have embeddings to compare.
| `GET/POST /dashboard/profile`, `POST /dashboard/profile/restore` | Admin | Edit `profile.md`, inspect prompts/adjustments, and restore a version. | | `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/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/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 /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 /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. | | `GET /r/...`, `/healthz`, `/issues.json` | Existing policy | HMAC rating links, health, and issue reports. |
+12
View File
@@ -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';
+259
View File
@@ -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")
);
}
}
+7
View File
@@ -421,6 +421,7 @@ struct OverviewTemplate {
budget: Vec<BudgetLine>, budget: Vec<BudgetLine>,
ratings: Vec<LabelCount>, ratings: Vec<LabelCount>,
ratings_total: i64, ratings_total: i64,
access_requests: i64,
unrated: Vec<UnratedPick>, unrated: Vec<UnratedPick>,
active_jobs: Vec<JobLine>, active_jobs: Vec<JobLine>,
finished_jobs: Vec<JobLine>, finished_jobs: Vec<JobLine>,
@@ -442,6 +443,11 @@ async fn overview(
let last_run = last_run_card(db, &config).await?; let last_run = last_run_card(db, &config).await?;
let budget = budget_lines(db, &config, now).await?; let budget = budget_lines(db, &config, now).await?;
let (ratings, ratings_total) = ratings_this_week(db, 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 unrated = unrated_picks(db).await?;
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?; let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
let sparklines = overview_sparklines(db).await?; let sparklines = overview_sparklines(db).await?;
@@ -459,6 +465,7 @@ async fn overview(
budget, budget,
ratings, ratings,
ratings_total, ratings_total,
access_requests,
unrated, unrated,
active_jobs, active_jobs,
finished_jobs, finished_jobs,
+142 -6
View File
@@ -2,13 +2,17 @@
use askama::Template; use askama::Template;
use axum::Router; use axum::Router;
use axum::extract::{Extension, State}; use axum::extract::{Extension, Path, State};
use axum::routing::get; use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum_login::tower_sessions::Session; use axum_login::tower_sessions::Session;
use sqlx::Row as _;
use crate::server::AppState; use crate::server::AppState;
use crate::web::session::{AuthSession, Viewer}; 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)] #[derive(Debug)]
struct UserLine { struct UserLine {
@@ -20,16 +24,27 @@ struct UserLine {
open_sessions: i64, open_sessions: i64,
} }
#[derive(Debug)]
struct AccessRequestLine {
id: i64,
email: String,
reason: String,
requested: String,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "dashboard/users.html")] #[template(path = "dashboard/users.html")]
struct UsersTemplate { struct UsersTemplate {
page: Page, page: Page,
requests: Vec<AccessRequestLine>,
users: Vec<UserLine>, users: Vec<UserLine>,
} }
/// Routes contributed by this page group (merged by `dashboard::router`). /// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> { 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( async fn index(
@@ -39,6 +54,21 @@ async fn index(
) -> Result<Html<UsersTemplate>, WebError> { ) -> Result<Html<UsersTemplate>, WebError> {
let viewer = auth.user().await.map(Viewer::from); let viewer = auth.user().await.map(Viewer::from);
let config = state.config(); 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) let users = crate::web::users::list(&state.db)
.await .await
.map_err(WebError::Internal)? .map_err(WebError::Internal)?
@@ -58,23 +88,129 @@ async fn index(
.collect(); .collect();
let mut page = Page::new("Users", viewer, "users"); let mut page = Page::new("Users", viewer, "users");
page.flash = take_flash(&session).await?; 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)] #[cfg(test)]
mod tests { 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] #[tokio::test]
async fn users_page_is_admin_only_and_lists_accounts_and_sessions() { async fn users_page_is_admin_only_and_lists_accounts_and_sessions() {
let seed = seed().await; 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 app = app_with_users(&seed.db).await;
let body = assert_admin_only(&app, "/dashboard/users").await; let body = assert_admin_only(&app, "/dashboard/users").await;
assert!(body.contains("<h1>Users</h1>"), "{body}"); 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("reader"), "{body}");
assert!(body.contains("admin"), "{body}"); assert!(body.contains("admin"), "{body}");
assert!(body.contains("Open sessions"), "{body}"); assert!(body.contains("Open sessions"), "{body}");
assert!(body.contains("daily-epub users"), "{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}");
}
} }
+2
View File
@@ -1,3 +1,4 @@
pub mod access;
pub mod dashboard; pub mod dashboard;
pub mod issue; pub mod issue;
pub mod public; pub mod public;
@@ -694,6 +695,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
axum::Router::new() axum::Router::new()
.route("/", get(public::latest)) .route("/", get(public::latest))
.route("/request-access", get(access::page).post(access::submit))
.route("/issues", get(public::archive)) .route("/issues", get(public::archive))
.route("/issues/{date}", get(public::show_issue)) .route("/issues/{date}", get(public::show_issue))
.route("/feed.xml", get(public::feed)) .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">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">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">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>
<div class="cards"> <div class="cards">
+12 -2
View File
@@ -1,9 +1,19 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard"> {% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div> <header class="page-head"><div>
<h1>Users</h1> <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> </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 &lt;username&gt;</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> {% 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> <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> <tbody>{% for user in users %}<tr>
+1
View File
@@ -2,6 +2,7 @@
<article class="reader-page mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6"> <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 %} {% 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> <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 %} {% 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 %} {% 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> <p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
+1 -1
View File
@@ -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 %}
+1
View File
@@ -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 %}