chore(quotesdb): commit tickets, TODO, and infra README update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent c4a59ec9ad
commit 00d195c86f

@ -0,0 +1,162 @@
+++
title = "Filter quotes by date range (before/after with year/month/day granularity)"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Feature
Extend `GET /api/quotes` with date range filtering. Users can specify a "before" and/or "after" bound, with year/month/day granularity for each. Quotes without a date (`date IS NULL`) are excluded when a date filter is active.
---
## Query parameter design
Six optional query params are added:
| Param | Type | Description |
|---|---|---|
| `date_after_year` | u16 | Only include quotes dated on or after this year |
| `date_after_month` | u8 (112) | Narrows after-bound to this month |
| `date_after_day` | u8 (131) | Narrows after-bound to this day |
| `date_before_year` | u16 | Only include quotes dated on or before this year |
| `date_before_month` | u8 (112) | Narrows before-bound to this month |
| `date_before_day` | u8 (131) | Narrows before-bound to this day |
The handler constructs two partial ISO strings from these fields before calling `list_quotes`:
- **after bound** (`>=`): `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` — string comparison anchors to the start of the specified period (e.g., `2020` means ≥ `2020-01-01` in text comparison, but actually `>= '2020'` which works correctly since any 2020 date starts with `2020`).
- **before bound** (`<=`): `YYYY-MM-DD` where missing month defaults to `12` and missing day defaults to `31` — so `date_before_year=2020` means `<= '2020-12-31'`.
**Validation rules:**
- Month must be 112 if provided (without its year, it is an error).
- Day must be 131 if provided (without its year+month, it is an error).
- Return `400 Bad Request` for invalid combinations.
---
## Part 1 — API: `handlers/mod.rs`
**Extend `ListParams`:**
```rust
#[derive(Debug, Deserialize)]
struct ListParams {
#[serde(default = "default_page")]
page: u32,
author: Option<String>,
tag: Option<String>,
// Date range filter
date_after_year: Option<u16>,
date_after_month: Option<u8>,
date_after_day: Option<u8>,
date_before_year: Option<u16>,
date_before_month: Option<u8>,
date_before_day: Option<u8>,
}
```
**In `list_handler`**, construct the `date_after` and `date_before` strings before calling `repo.list_quotes`:
```rust
fn build_date_bound(year: Option<u16>, month: Option<u8>, day: Option<u8>) -> Option<String> {
match (year, month, day) {
(None, _, _) => None,
(Some(y), None, _) => Some(format!("{y:04}")),
(Some(y), Some(m), None) => Some(format!("{y:04}-{m:02}")),
(Some(y), Some(m), Some(d)) => Some(format!("{y:04}-{m:02}-{d:02}")),
}
}
```
Return `400` if month is present without year, or day is present without year+month.
---
## Part 2 — Repository trait: `db/mod.rs`
Extend `list_quotes` signature:
```rust
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>, // new
date_before: Option<&str>, // new
) -> Result<ListResult, DbError>;
```
---
## Part 3 — Native implementation: `db/native.rs`
In `NativeRepository::list_quotes`, build the WHERE clause dynamically. Current query filters on `author` and `tag` via subquery. Extend it:
```sql
-- Additional clauses appended when filters are present:
AND q.date IS NOT NULL
AND q.date >= ? -- when date_after is Some
AND q.date <= ? -- when date_before is Some
```
Use string comparison — ISO YYYY-MM-DD format sorts lexicographically, so `>=`/`<=` on the `date` TEXT column is correct. Prefix matching (e.g. `date >= '2020'` with `date = '2020-06-15'`) works because `'2020-06-15' >= '2020'` is true in SQLite string comparison.
---
## Part 4 — D1 implementation: `db/d1.rs`
Apply the same WHERE clause extensions to the D1 query builder.
---
## Part 5 — UI: Browse page
File: `src/bin/ui/pages/browse.rs`
Add date filter controls to the filter panel alongside author and tag filters:
- "After:" year input (`<input type="number" min="0" max="9999">`), optional month select (112), optional day input.
- "Before:" same.
On filter apply, include the populated fields as query params. When fields are empty, omit them.
---
## Part 6 — Mock repo in tests: `handlers/mod.rs`
Update `MockRepo::list_quotes` to accept the two new `date_after` / `date_before` params (ignore them in the mock — just extend the signature).
---
## Part 7 — OpenAPI spec: `api/openapi.yaml`
Add the six new query parameters to the `GET /api/quotes` operation with descriptions and `schema: {type: integer}`.
---
## Files touched
- `src/bin/api/handlers/mod.rs``ListParams` + `list_handler` + `MockRepo`
- `src/bin/api/db/mod.rs``QuoteRepository::list_quotes` signature
- `src/bin/api/db/native.rs` — SQL query extension
- `src/bin/api/db/d1.rs` — SQL query extension
- `src/bin/ui/pages/browse.rs` — date filter UI
- `api/openapi.yaml` — new query params
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
redocly lint api/openapi.yaml
```
Test manually:
- `GET /api/quotes?date_after_year=1900&date_before_year=2000` — should return only quotes with dates in the 20th century.
- `GET /api/quotes?date_after_year=2020&date_after_month=6` — on or after June 2020.
- `GET /api/quotes?date_after_month=3` — should return 400 (month without year).
## Commit scope
`feat(quotesdb): date range filter for quotes list`

@ -0,0 +1,89 @@
+++
title = "quotesdb/api: POST /api/admin/lock and /api/admin/unlock endpoints"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## POST /api/admin/lock and /api/admin/unlock endpoints
Add the two admin-protected endpoints that toggle the global submissions lock. Both require `X-Admin-Code` and return the current lock state after the operation.
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait and seeds the `submissions_locked` row in the database. Complete 35685a first.
---
## Files to modify
- `src/bin/api/handlers/mod.rs` (or `src/bin/api/handlers/admin.rs`) — add `lock_submissions` and `unlock_submissions` handlers
- `src/bin/api/main.rs` — register the two new routes
No new DB trait methods are needed; both handlers reuse `set_submissions_locked(bool)` introduced in 35685a.
---
## Handlers
```rust
/// POST /api/admin/lock
/// Requires X-Admin-Code header. Sets submissions_locked = true.
/// Response: 200 { "submissions_locked": true } or 403 on bad code.
pub async fn lock_submissions(
State(repo): State<Arc<dyn QuoteRepository>>,
headers: HeaderMap,
) -> impl IntoResponse {
let admin_code = extract_admin_code(&headers);
if !verify_admin_code(&repo, admin_code).await { ... }
match repo.set_submissions_locked(true).await {
Ok(()) => Json(json!({ "submissions_locked": true })).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
/// POST /api/admin/unlock
/// Requires X-Admin-Code header. Sets submissions_locked = false.
/// Response: 200 { "submissions_locked": false } or 403 on bad code.
pub async fn unlock_submissions(
State(repo): State<Arc<dyn QuoteRepository>>,
headers: HeaderMap,
) -> impl IntoResponse {
// same pattern, locked = false
}
```
Implement a shared helper `verify_admin_code(repo, code) -> bool` (or extract inline) that fetches the stored admin code from `admin_config` and compares it. Use constant-time comparison if possible.
---
## Route registration (src/bin/api/main.rs)
```rust
.route("/api/admin/lock", post(handlers::lock_submissions))
.route("/api/admin/unlock", post(handlers::unlock_submissions))
```
---
## Tests
- `POST /api/admin/lock` with correct `X-Admin-Code``200 { "submissions_locked": true }`
- `POST /api/admin/unlock` with correct `X-Admin-Code``200 { "submissions_locked": false }`
- `POST /api/admin/lock` with wrong code → `403`
- `POST /api/admin/unlock` with missing header → `403`
- Lock/unlock idempotent: locking when already locked still returns `200 { "submissions_locked": true }`
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints
```

@ -0,0 +1,100 @@
+++
title = "quotesdb/ui: /submit page locked-state banner"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = ["161f32"]
+++
## /submit page locked-state banner (UI)
Modify the existing /submit page to check the submission lock on mount. When the lock is active, hide the submission form entirely and show a closed-submissions banner in its place.
Depends on ticket 161f32 (admin API client functions). Complete that ticket first.
---
## Files to modify
- `src/bin/ui/pages/submit.rs` — add status check on mount and conditional rendering
---
## Changes to SubmitPage component
### New state fields
Add to the existing component state:
| Field | Type | Purpose |
|---|---|---|
| `submissions_locked` | `Option<bool>` | `None` while loading, `Some(true/false)` after status check |
| `status_error` | `bool` | Set if `get_status()` itself fails (show form as fallback) |
### On mount
Spawn an async task that calls `api::get_status()`:
```rust
// In use_effect_with or similar hook, fired once on mount:
let submissions_locked = submissions_locked.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::get_status().await {
Ok(status) => submissions_locked.set(Some(status.submissions_locked)),
Err(_) => {
// On error, default to showing the form (fail open).
submissions_locked.set(Some(false));
}
}
});
```
### Render logic
```
if submissions_locked == None:
show a loading indicator (or nothing / skeleton)
elif submissions_locked == Some(true):
show closed banner, hide form
else:
show form as normal
```
### Closed banner markup (approximate)
```html
<div class="submissions-closed-banner">
<p>Submissions are currently closed.</p>
</div>
```
Style the banner to be visually distinct — use a muted/warning colour. Add the `.submissions-closed-banner` CSS class to `src/bin/ui/style.css`.
### Fail-open behaviour
If `api::get_status()` returns an error, treat it as unlocked (`Some(false)`) and display the form. Do not block a user from submitting due to a network error on the status check.
---
## Tests
No runtime unit tests (wasm-only). Verify the build:
```sh
cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Validation
```sh
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Commit
```
feat(quotesdb): show locked banner on /submit when submissions are closed
```

@ -0,0 +1,122 @@
+++
title = "quotesdb/ui: admin API client functions"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Admin API client functions (UI)
Add four async functions to the UI API client module that cover every admin and status endpoint introduced by the API tickets. These functions are consumed by the /admin page and the /submit page.
---
## Files to modify
- `src/bin/ui/api.rs` — add four new public async functions
---
## New types
Add to `src/bin/ui/api.rs` (or to a shared types module imported by api.rs):
```rust
/// Response from GET /api/status.
#[derive(Deserialize, Clone, PartialEq)]
pub struct StatusResponse {
pub submissions_locked: bool,
}
/// Response from POST /api/admin/reset-auth-code.
#[derive(Deserialize)]
struct ResetAuthCodeResponse {
pub auth_code: String,
}
/// Response from POST /api/admin/lock or /api/admin/unlock.
#[derive(Deserialize)]
struct LockResponse {
pub submissions_locked: bool,
}
```
---
## New functions
```rust
/// Fetch the current submission lock state from GET /api/status.
/// Returns Ok(StatusResponse) on success or ApiError on failure.
pub async fn get_status() -> Result<StatusResponse, ApiError> { ... }
/// Call POST /api/admin/reset-auth-code.
/// Sends X-Admin-Code: admin_code in the request header.
/// Body: { "new_code": new_code } (omit field if new_code is None).
/// Returns the new auth code string on success, or ApiError on failure.
pub async fn admin_reset_auth_code(
current: &str,
new_code: Option<&str>,
admin_code: &str,
) -> Result<String, ApiError> { ... }
/// Call POST /api/admin/lock.
/// Sends X-Admin-Code: admin_code in the request header.
/// Returns Ok(true) on success, or ApiError (including ApiError::Forbidden on 403).
pub async fn admin_lock(admin_code: &str) -> Result<bool, ApiError> { ... }
/// Call POST /api/admin/unlock.
/// Sends X-Admin-Code: admin_code in the request header.
/// Returns Ok(false) on success, or ApiError (including ApiError::Forbidden on 403).
pub async fn admin_unlock(admin_code: &str) -> Result<bool, ApiError> { ... }
```
Implementation notes:
- Use the same `gloo_net::http::Request` pattern already used in `api.rs` for other endpoints.
- Add an `ApiError::Forbidden` variant (or reuse an existing error variant) to communicate `403` responses back to the UI so pages can show "Wrong auth code." without a generic error.
- `admin_reset_auth_code`: serialize the body as `{ "new_code": "..." }` when `new_code` is `Some`, or as `{}` when `None`.
- `admin_lock` and `admin_unlock` send no request body (empty POST).
---
## ApiError extension
If `ApiError` does not already have a `Forbidden` variant, add one:
```rust
pub enum ApiError {
// ... existing variants ...
/// The server returned 403 Forbidden (wrong admin code).
Forbidden,
}
```
Map HTTP 403 → `ApiError::Forbidden` in each new function before returning.
---
## Tests
This module compiles only for `wasm32-unknown-unknown` so no `cargo test` unit tests are practical here. Instead, verify the build compiles cleanly:
```sh
cargo check --target wasm32-unknown-unknown --bin ui
```
Write a brief doc-comment on each function describing its endpoint, required header, and error conditions.
---
## Validation
```sh
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Commit
```
feat(quotesdb): admin API client functions in UI
```

@ -0,0 +1,100 @@
+++
title = "quotesdb/api: GET /api/status public endpoint"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## GET /api/status public endpoint
Add a public status endpoint that exposes whether submissions are currently locked. The UI calls this on mount for both the /submit and /admin pages.
---
## Files to modify
- `src/bin/api/db/mod.rs` — add `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait
- `src/bin/api/db/d1.rs` — implement the two new trait methods for the D1 backend; seed `submissions_locked = "0"` alongside `admin_auth_code` in the startup migration if not already present
- `src/bin/api/db/native.rs` — implement the two new trait methods for the native/SQLite backend
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/status.rs`) — add the `get_status` handler
- `src/bin/api/main.rs` — register the new route
---
## Database
The `admin_config` key/value table gains a second row. Seed it on startup (alongside `admin_auth_code`) if it does not already exist:
```sql
INSERT OR IGNORE INTO admin_config (key, value) VALUES ('submissions_locked', '0');
```
---
## New trait methods (src/bin/api/db/mod.rs)
Add to the `QuoteRepository` trait:
```rust
/// Return whether submissions are currently locked.
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
/// Set the submissions lock state.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
```
Both implementations read/write the `submissions_locked` key in `admin_config`, treating `"1"` as `true` and anything else as `false`.
---
## Handler
Add to the handlers module:
```rust
/// GET /api/status — returns current submission lock state; requires no auth.
pub async fn get_status(State(repo): State<Arc<dyn QuoteRepository>>) -> impl IntoResponse {
match repo.get_submissions_locked().await {
Ok(locked) => Json(json!({ "submissions_locked": locked })).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
```
---
## Route registration (src/bin/api/main.rs)
Register before the quotes router:
```rust
.route("/api/status", get(handlers::get_status))
```
---
## Tests
In `src/bin/api/handlers/` (or the relevant test module), add unit tests covering:
- `GET /api/status` returns `200` with `{ "submissions_locked": false }` when the DB value is `"0"`
- `GET /api/status` returns `200` with `{ "submissions_locked": true }` when the DB value is `"1"`
- `get_submissions_locked` returns `false` for missing key (graceful default)
Use a mock or in-memory SQLite repo for all handler tests.
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): GET /api/status public endpoint
```

@ -0,0 +1,83 @@
+++
title = "Submit form: author optional (default Anonymous), clarify auth code auto-generation"
priority = 5
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
Two related UX issues on the submit form (`src/bin/ui/pages/submit.rs`):
### 1. Author field should be optional
Currently, `author` is required — the validation at line 53 returns early with `"Text and author are required."` if it is empty, and the field has `required=true` at line 177. Not all quotes have a known author, so it should be optional. When left blank, the API should receive `"Anonymous"` as the author.
### 2. Auth code label should clarify auto-generation
The label currently reads `"Custom auth code (optional)"`. The label itself does not explain what happens if left blank. The placeholder does say `"word-word-word-word (auto-generated if empty)"`, but users may not notice placeholders. The label should make it explicit.
## Expected behaviour
- Author field: leaving it blank submits the quote with author = `"Anonymous"`. The field should not block form submission.
- Auth code label: reads something like `"Custom auth code (optional — one will be generated if left blank)"`.
## How to fix
In `src/bin/ui/pages/submit.rs`:
**Author optional:**
1. Change the validation at line 53:
```rust
// Before
if text_val.is_empty() || author_val.is_empty() {
error.set(Some("Text and author are required.".to_string()));
return;
}
// After
if text_val.is_empty() {
error.set(Some("Quote text is required.".to_string()));
return;
}
```
2. When building `CreateQuoteInput`, default the author to `"Anonymous"` if empty:
```rust
author: if author_val.is_empty() {
"Anonymous".to_string()
} else {
author_val
},
```
3. Remove `required=true` from the author `<input>` (~line 177).
4. Update the label from `"Author *"` to `"Author (optional, defaults to Anonymous)"` or similar.
5. Update the placeholder from `"e.g. Mark Twain"` to `"e.g. Mark Twain (leave blank for Anonymous)"`.
**Auth code label:**
6. Change the label at line 236 from:
```
"Custom auth code (optional)"
```
to:
```
"Auth code (optional — one will be generated if left blank)"
```
## File
- `src/bin/ui/pages/submit.rs` (lines 5356, 6466, 163178, 235237)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually test: submit a quote with author left blank — it should succeed and display `Anonymous` as the author.
## Commit scope
`fix(quotesdb): submit form author optional and auth code label`

@ -0,0 +1,45 @@
+++
title = "Add workers-rs WASM entry point to api binary"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Goal
Enable `cargo build --release --bin api --target wasm32-unknown-unknown` so the api binary deploys as a Cloudflare Worker (see `infra/worker.tf`).
## Changes Required
### 1. `Cargo.toml`
Add axum to `[target.'cfg(target_arch = "wasm32")'.dependencies]` with tokio disabled:
```toml
axum = { version = "0.8", default-features = false, features = ["json"] }
```
All axum types used in handlers (Router, Path, Query, State, Json, etc.) are available without the tokio feature.
### 2. `src/bin/api/main.rs`
- Wrap existing native code (`mod handlers;`, `#[tokio::main] async fn main()`) in `#[cfg(not(target_arch = "wasm32"))]`
- Add `#[cfg(target_arch = "wasm32")] mod handlers;` (no change to handlers themselves)
- Add `#[event(fetch)]` workers-rs entry point that:
1. Gets D1 binding from env: `env.d1("DB")`
2. Creates `D1Repository::new(db)` — see companion ticket for D1 implementation
3. Calls `repo.run_migrations()`
4. Wraps repo in `Arc<dyn QuoteRepository + Send + Sync>`
5. Builds Axum router via existing `handlers::router(repo)`
6. Converts `worker::Request``http::Request<axum::body::Body>` (method, uri, headers, body bytes)
7. Calls router via `tower_service::Service::call()`
8. Converts `http::Response<axum::body::Body>``worker::Response` (status, headers, body bytes)
`tower_service` is already a transitive dep of axum.
## Dependencies
- Companion ticket for D1Repository implementation must be done first (or in parallel).
D1Repository must have `unsafe impl Send` and `unsafe impl Sync` for the Arc<dyn ... + Send + Sync> wrapper to work.
## Validation
```sh
cargo build --release --bin api --target wasm32-unknown-unknown
cargo build --release --bin api # native must still work
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -0,0 +1,121 @@
+++
title = "quotesdb/api: POST /api/admin/reset-auth-code endpoint"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## POST /api/admin/reset-auth-code endpoint
Add the admin-protected endpoint that replaces the stored admin auth code. The caller must supply the current code via `X-Admin-Code`. A new code may be provided in the request body; if omitted, the server generates a fresh 4-word passphrase.
---
## Files to modify
- `src/bin/api/db/mod.rs` — add `update_admin_auth_code` to the `QuoteRepository` trait
- `src/bin/api/db/d1.rs` — implement `update_admin_auth_code` for D1
- `src/bin/api/db/native.rs` — implement `update_admin_auth_code` for native SQLite
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/admin.rs`) — add the `reset_auth_code` handler
- `src/bin/api/main.rs` — register the new route
---
## New trait method (src/bin/api/db/mod.rs)
Add to the `QuoteRepository` trait:
```rust
/// Replace the admin auth code if `current` matches the stored value.
/// If `new_code` is `None`, generates a fresh 4-word passphrase.
/// Returns the new auth code string on success, or `DbError::Unauthorized`
/// if `current` does not match.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError>;
```
Implementation steps:
1. Fetch the stored `admin_auth_code` from `admin_config`.
2. If it does not match `current`, return `DbError::Unauthorized` (or a dedicated variant).
3. Determine the new code: use `new_code` if provided, otherwise call the existing passphrase-generation utility.
4. Write the new value to `admin_config` with `UPDATE`.
5. Return the new code string.
---
## Request / response types
```rust
#[derive(Deserialize)]
struct ResetAuthCodeRequest {
new_code: Option<String>,
}
#[derive(Serialize)]
struct ResetAuthCodeResponse {
auth_code: String,
}
```
---
## Handler
```rust
/// POST /api/admin/reset-auth-code
/// Requires X-Admin-Code header matching the stored admin passphrase.
/// Body: { "new_code": "optional-string" }
/// Response: 200 { "auth_code": "new-code" } or 403 on mismatch.
pub async fn reset_auth_code(
State(repo): State<Arc<dyn QuoteRepository>>,
headers: HeaderMap,
Json(payload): Json<ResetAuthCodeRequest>,
) -> impl IntoResponse {
let admin_code = match headers.get("x-admin-code").and_then(|v| v.to_str().ok()) {
Some(c) => c.to_owned(),
None => return StatusCode::FORBIDDEN.into_response(),
};
match repo.update_admin_auth_code(&admin_code, payload.new_code.as_deref()).await {
Ok(new_code) => Json(ResetAuthCodeResponse { auth_code: new_code }).into_response(),
Err(DbError::Unauthorized) => StatusCode::FORBIDDEN.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
```
---
## Route registration (src/bin/api/main.rs)
```rust
.route("/api/admin/reset-auth-code", post(handlers::reset_auth_code))
```
---
## Tests
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no body `new_code``200`, response contains a non-empty `auth_code`
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and explicit `new_code``200`, `auth_code` equals the supplied value
- `POST /api/admin/reset-auth-code` with wrong `X-Admin-Code``403`
- `POST /api/admin/reset-auth-code` with missing `X-Admin-Code` header → `403`
- After a successful reset, subsequent calls with the old code return `403` and with the new code return `200`
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): POST /api/admin/reset-auth-code endpoint
```

@ -0,0 +1,64 @@
+++
title = "quotesdb/api: DB layer — add submissions_locked + update_admin_auth_code"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Goal
Extend the DB abstraction layer with three new trait methods and seed on startup.
## New trait methods (add to `src/bin/api/db/mod.rs`)
```rust
/// Replace the admin auth code if `current` matches.
/// If `new_code` is None, generates a fresh 4-word passphrase.
/// Returns the new auth code on success.
/// Returns Err(DbError::Forbidden) if `current` does not match.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError>;
/// Return whether submissions are currently locked.
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
/// Persist the submissions lock state.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
```
## Implementations
Implement in both:
- `src/bin/api/db/native.rs` (NativeRepository — rusqlite)
- `src/bin/api/db/d1.rs` (D1Repository — Cloudflare Workers WASM)
## Seeding (startup)
In `src/bin/api/main.rs` (both native and wasm32 paths), after seeding
`admin_auth_code`, also seed `submissions_locked = '0'` using
`INSERT OR IGNORE` (use `set_submissions_locked` only when the key is absent,
or add a dedicated `seed_submissions_locked` helper).
## Testing
Add unit/integration tests in `src/bin/api/handlers/mod.rs` test module
or `tests/` covering:
- get_submissions_locked returns false by default
- set_submissions_locked(true) then get_submissions_locked returns true
- update_admin_auth_code with correct current succeeds
- update_admin_auth_code with wrong current returns Forbidden
## Validation
Run from `quotesdb/`:
```
cargo fmt && cargo check && cargo clippy && cargo test
```
## Commit scope
`feat(quotesdb): ...`
## Design reference
`docs/plans/2026-03-04-admin-features-design.md`

@ -0,0 +1,75 @@
+++
title = "Submit form: remove 'Submit another' link from success screen"
priority = 5
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
After successfully submitting a quote, the success screen (`src/bin/ui/pages/submit.rs`, lines 111133) offers two actions:
1. "View your quote" (primary button → `/quotes/:id`)
2. "Submit another" (secondary button → `/submit`)
The "Submit another" link pushes users toward submitting more quotes immediately. This is undesirable — the success screen should celebrate the submission and direct the user to view what they just created or browse the collection, not encourage repeat submissions.
## Expected behaviour
Remove the "Submit another" link from the success screen. Replace it with a "Browse all quotes" link (`/browse`) so the user has a natural next step that doesn't push them back to the submit form.
Final success screen actions:
1. "View your quote" (primary, `/quotes/:id`)
2. "Browse all quotes" (secondary, `/browse`)
## How to fix
In `src/bin/ui/pages/submit.rs`, in the success render block (lines 120130):
```rust
// Before
<div class="page-submit__actions">
<Link<Route>
to={Route::QuoteDetail { id: quote_id.clone() }}
classes="btn btn--primary"
>
{ "View your quote" }
</Link<Route>>
<Link<Route> to={Route::Submit} classes="btn">
{ "Submit another" }
</Link<Route>>
</div>
// After
<div class="page-submit__actions">
<Link<Route>
to={Route::QuoteDetail { id: quote_id.clone() }}
classes="btn btn--primary"
>
{ "View your quote" }
</Link<Route>>
<Link<Route> to={Route::Browse} classes="btn">
{ "Browse all quotes" }
</Link<Route>>
</div>
```
No new imports needed — `Route::Browse` is already defined.
## File
- `src/bin/ui/pages/submit.rs` (lines 120130)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually verify: after submitting a quote, the success screen shows "View your quote" and "Browse all quotes" (not "Submit another").
## Commit scope
`fix(quotesdb): remove submit-another link from success screen`

@ -0,0 +1,168 @@
+++
title = "Admin super auth code: delete any quote regardless of per-quote auth"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Feature
Add an **admin super auth code** — a single global passphrase that can delete (and update) any quote, bypassing the per-quote `auth_code` check. This allows the operator to moderate content without needing the original submitter's code.
The admin code is:
- Generated once on first startup using the same 4-word passphrase generator (`generate_auth_code` in `src/lib.rs`).
- Stored in the database in a new `admin_config` table.
- Printed prominently to stderr on every startup so the operator can note it.
- Never exposed via the API.
---
## Part 1 — Database: new migration
Add a new migration constant in `src/bin/api/db/migrations.rs`:
```rust
/// Creates the admin_config key/value table for storing global configuration.
///
/// Stores a single row for the admin auth code under the key `admin_auth_code`.
pub const CREATE_ADMIN_CONFIG: &str = "\
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)";
```
Run this migration in `QuoteRepository::run_migrations` after the existing migrations. The implementation then seeds the admin auth code if absent (see Part 2).
Update `infra/schema.sql` to include:
```sql
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
---
## Part 2 — Repository trait: `db/mod.rs`
Add two new methods to `QuoteRepository`:
```rust
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the table is empty (should not happen after migrations).
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
/// Insert the admin auth code into `admin_config` if it is not already set.
///
/// Called once during startup, after `run_migrations`.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
```
The startup sequence (in `main.rs`) becomes:
```rust
repo.run_migrations().await?;
// Seed admin code on first run
if repo.get_admin_auth_code().await?.is_none() {
let code = quotesdb::generate_auth_code();
repo.seed_admin_auth_code(&code).await?;
}
// Always print the admin code at startup
let admin_code = repo.get_admin_auth_code().await?.unwrap();
eprintln!("╔══════════════════════════════════════════════╗");
eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}");
eprintln!("╚══════════════════════════════════════════════╝");
```
---
## Part 3 — Native implementation: `db/native.rs`
Implement `get_admin_auth_code` and `seed_admin_auth_code` using rusqlite.
**Extend `delete_quote`** to accept the admin code as a fallback:
```rust
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
// ... existing logic ...
// Before returning Forbidden, check admin auth code
let admin_code = self.get_admin_auth_code().await?;
if Some(auth_code) == admin_code.as_deref() {
// Admin code matches — delete unconditionally
// ... execute DELETE without checking quotes.auth_code ...
return Ok(DeleteResult::Deleted);
}
Ok(DeleteResult::Forbidden)
}
```
Similarly extend `update_quote` to allow admin override.
The cleanest approach is to refactor `delete_quote` and `update_quote` to first attempt the per-quote auth check, and if it fails, check against the admin code.
---
## Part 4 — D1 implementation: `db/d1.rs`
Apply the same changes as Part 3 for the WASM/Cloudflare D1 path.
---
## Part 5 — API startup: `src/bin/api/main.rs`
Update the startup sequence as shown in Part 2. The admin code print must be clearly visible in logs.
---
## Part 6 — Mock repo in tests: `handlers/mod.rs`
Add stub implementations of `get_admin_auth_code` and `seed_admin_auth_code` to `MockRepo`:
```rust
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
Ok(None) // no admin code in tests by default
}
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
Ok(())
}
```
---
## Design notes
- The admin code is **never returned by any API endpoint** — there is no way to discover it via HTTP.
- The admin code is stored plaintext in `admin_config`, consistent with per-quote auth codes. This is acceptable given the stated security model (simple passphrase, no user accounts).
- Only `delete_quote` and `update_quote` check the admin code. Read operations are unaffected.
- The admin code is **not rotatable** via the API — an operator who needs to rotate it must manually update the database row.
---
## Files touched
- `src/bin/api/db/migrations.rs``CREATE_ADMIN_CONFIG` constant
- `src/bin/api/db/mod.rs` — two new trait methods + updated docstrings for `delete_quote`/`update_quote`
- `src/bin/api/db/native.rs` — implementations + admin fallback logic
- `src/bin/api/db/d1.rs` — same for D1
- `src/bin/api/handlers/mod.rs``MockRepo` stubs
- `src/bin/api/main.rs` — seed + print admin code on startup
- `infra/schema.sql``admin_config` table
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
```
Manual test:
1. Start the server: `cargo run`
2. Observe admin code printed to stderr.
3. Create a quote: `curl -X PUT http://localhost:3000/api/quotes -H 'Content-Type: application/json' -d '{"text":"Test","author":"A","tags":[]}'`
4. Try deleting with wrong code: should return 403.
5. Try deleting with admin code: should return 204.
6. Restart the server: same admin code should be printed (not regenerated).
## Commit scope
`feat(quotesdb): admin super auth code for quote moderation`

@ -0,0 +1,46 @@
+++
title = "Home page: show friendly empty state when no quotes in database"
priority = 7
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
When the database is empty, `GET /api/quotes/random` returns a 404 response. The home page (`src/bin/ui/pages/home.rs`) currently treats all errors (including 404) the same way — it sets the `error` state and displays it via `<ErrorDisplay>`, which results in something like a raw JSON error message being shown to the user.
## Expected behaviour
When the API returns a 404 on `/api/quotes/random`, the home page should display a friendly empty-state message instead of a generic error:
> "Nothing here yet. Submit a quote!"
The message should include a link to `/submit`.
## How to fix
In `src/bin/ui/pages/home.rs`, in the `use_effect_with` block, inspect the `Err` value. The API client returns `ApiError::Server { status, .. }` for HTTP error codes. When `status == 404`, set a dedicated "empty" state (or detect it from the error) and render the friendly message instead of `<ErrorDisplay>`.
Relevant code: `src/bin/ui/pages/home.rs` — the `use_effect_with` error branch and the HTML render block.
## File
- `src/bin/ui/pages/home.rs`
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
```
Also manually test with an empty database:
```sh
rm -f quotesdb.sqlite
cargo run &
# navigate to http://localhost:3000 — should show the friendly message, not a raw error
```
## Commit scope
`fix(quotesdb): home page empty state`

@ -0,0 +1,68 @@
+++
title = "quotesdb/api: enforce submission lock on PUT /api/quotes"
priority = 6
status = "done"
ticket_type = "feature"
dependencies = ["69a2c5"]
+++
## Enforce submission lock on PUT /api/quotes
Modify the quote-creation handler to check the submissions lock before accepting a new quote. If locked, return `423 Locked` with a JSON error body.
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` to the `QuoteRepository` trait. Complete 35685a first.
---
## Files to modify
- `src/bin/api/handlers/mod.rs` — modify the `create_quote` handler (the handler for `PUT /api/quotes`) to add a lock pre-flight check
No new DB methods, routes, or types are needed.
---
## Change to create_quote handler
At the top of the handler body, before any other logic, add:
```rust
// Pre-flight: reject new submissions when locked.
match repo.get_submissions_locked().await {
Ok(true) => {
return (
StatusCode::LOCKED,
Json(json!({ "error": "submissions are closed" })),
).into_response();
}
Ok(false) => {}
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
```
HTTP 423 is `StatusCode::LOCKED` in axum/hyper.
---
## Tests
- `PUT /api/quotes` while `submissions_locked = false``201` (existing behaviour unchanged)
- `PUT /api/quotes` while `submissions_locked = true``423` with body `{ "error": "submissions are closed" }`
- After unlocking (`submissions_locked = false`), `PUT /api/quotes` succeeds again → `201`
Use the in-memory/mock repo already used by other handler tests; expose a method to toggle the lock state on the test double.
---
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Commit
```
feat(quotesdb): enforce submission lock on PUT /api/quotes
```

@ -0,0 +1,144 @@
+++
title = "Cloudflare Turnstile CAPTCHA on quote submission"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Feature
Add Cloudflare Turnstile CAPTCHA to protect the `PUT /api/quotes` endpoint (and the submit form in the UI) from bots and spam. This is a three-part change: infra, API, and UI.
---
## Part 1 — Infra: Turnstile widget resource
Create `infra/turnstile.tf` with a `cloudflare_turnstile_widget` resource.
```hcl
# Turnstile CAPTCHA widget protecting the quote submission form.
# Provides a site_key (public, embedded in the UI) and secret_key
# (private, used by the API to verify tokens server-side).
resource "cloudflare_turnstile_widget" "submit" {
account_id = var.cloudflare_account_id
name = "quotesdb-submit"
# "managed" mode: Turnstile decides whether to show a visible challenge.
mode = "managed"
# Restrict the widget to the production domain.
domains = [var.domain]
}
output "turnstile_site_key" {
description = "Turnstile site key — safe to embed in the UI."
value = cloudflare_turnstile_widget.submit.id
}
output "turnstile_secret_key" {
description = "Turnstile secret key — inject into Workers via wrangler secret."
value = cloudflare_turnstile_widget.submit.secret
sensitive = true
}
```
The `var.domain` variable should already exist or be added alongside `var.cloudflare_account_id` in `providers.tf` / `variables.tf`.
After `tofu apply`, inject the secret into the Worker:
```sh
wrangler secret put TURNSTILE_SECRET_KEY
# paste the value from `tofu output -raw turnstile_secret_key`
```
**Validate:**
```sh
# From infra/ directory
tofu validate
tofu plan
```
---
## Part 2 — API: Verify Turnstile token in create handler
The API must verify the Turnstile token before creating a quote.
### Changes
**`src/lib.rs` (or a new `turnstile` module in `src/bin/api/`):**
Add a `verify_turnstile(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool, Error>` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
**`quotesdb::CreateQuoteInput` in `src/lib.rs`:**
Add a `cf_turnstile_token: Option<String>` field. It is optional so that local/test environments can skip verification when no secret is configured.
**`src/bin/api/handlers/mod.rs` — `create_handler`:**
Before calling `repo.create_quote(input)`, check:
1. Read `TURNSTILE_SECRET_KEY` from the environment.
2. If the env var is set:
- Extract `cf_turnstile_token` from the request body.
- If the token is absent, return `400 Bad Request`.
- Call `verify_turnstile(token, secret, remote_ip)`.
- If verification fails, return `403 Forbidden` with `{"error": "CAPTCHA verification failed"}`.
3. If the env var is absent (local dev), skip verification.
**HTTP client:** Add `reqwest` (with `default-features = false, features = ["json"]`) as a non-wasm32 dependency for the Turnstile API call. On wasm32 the create handler does not exist, so no conflict.
**Important:** Strip `cf_turnstile_token` from the `CreateQuoteInput` before passing it to the repository — the DB doesn't store it.
**Validation:**
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
```
---
## Part 3 — UI: Embed Turnstile widget in submit form
### `index.html`
Add the Turnstile JS script tag to the `<head>`:
```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
```
### `src/bin/ui/pages/submit.rs`
1. Add a `turnstile_token: UseStateHandle<Option<String>>` state handle.
2. Add the Turnstile widget div in the form, before the submit button:
```html
<div class="cf-turnstile"
data-sitekey="TURNSTILE_SITE_KEY_HERE"
data-callback="turnstile_callback">
</div>
```
The `data-callback` JS function name must be registered in `window`. Use `web_sys::window()` and `js_sys::Function` to expose a Rust closure that sets `turnstile_token` state.
3. Include the token in the `CreateQuoteInput` sent to the API.
**Site key:** The Turnstile site key is public and safe to hardcode in the UI source. Retrieve it from `tofu output -raw turnstile_site_key` after applying infra. Add a note in `docs/LOCAL_DEV.md` that local dev skips CAPTCHA (no env var set).
**Validation:**
```sh
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually verify: the submit form shows the Turnstile widget and submission is blocked if the challenge is not completed.
---
## Files touched
- `infra/turnstile.tf` (new)
- `src/lib.rs``CreateQuoteInput` + `verify_turnstile`
- `src/bin/api/handlers/mod.rs``create_handler`
- `src/bin/ui/pages/submit.rs` — widget embed + token state
- `index.html` — Turnstile JS script
- `Cargo.toml``reqwest` dependency (non-wasm32)
- `api/openapi.yaml` — document `cf_turnstile_token` field
- `docs/LOCAL_DEV.md` — note on local dev CAPTCHA bypass
## Commit scope
`feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit`

@ -0,0 +1,62 @@
+++
title = "Fix compiler warnings in api and ui binaries"
priority = 7
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
Running `cargo build --target wasm32-unknown-unknown` (and `trunk build`) produces compiler warnings in both the `api` and `ui` binaries. All warnings should be resolved so the build is clean.
## Warnings
Reproduce with:
```sh
# From quotesdb/ root
cargo build --target wasm32-unknown-unknown 2>&1 | grep -E 'warning\[|warning: (fields|unused|duplicated)'
```
### UI binary (1 warning)
**`src/bin/ui/api.rs:21:9`** — `fields page and total_count are never read`
The `QuotesResponse` struct (or equivalent) has `page` and `total_count` fields that are deserialized from the API but never read by any UI code. Either:
- Remove the fields if they are genuinely unused, or
- Add `#[allow(dead_code)]` with a comment explaining they are reserved for future pagination UI, or
- Actually use them (e.g. pass `total_count` to the browse page for "X quotes total" display)
Preferred fix: use them or remove them. Avoid bare `#[allow(dead_code)]` without justification.
### API binary (2 warnings)
**`src/bin/api/db/d1.rs:9:8`** — `duplicated attribute`
A `#[cfg(...)]` or other attribute is duplicated on the same item. Remove the duplicate.
**`src/bin/api/db/mod.rs:27:9`** — `unused import: d1::D1Repository`
`D1Repository` is imported but never used in this module. Remove the import.
## Files
- `src/bin/ui/api.rs` (line 21)
- `src/bin/api/db/d1.rs` (line 9)
- `src/bin/api/db/mod.rs` (line 27)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
# Confirm zero warnings for ui binary
cargo build --target wasm32-unknown-unknown 2>&1 | grep 'warning:.*generated'
# Expected: no output (or "0 warnings")
```
Also run `trunk build` and confirm no warnings are emitted for the `quotesdb` crate (dependency warnings from third-party crates are acceptable).
## Commit scope
`fix(quotesdb): resolve compiler warnings in api and ui`

@ -0,0 +1,68 @@
+++
title = "Implement D1Repository for Cloudflare Workers"
priority = 7
status = "done"
ticket_type = "feature"
dependencies = []
+++
## Goal
Implement all 7 stub methods in `src/bin/api/db/d1.rs` so the API works against Cloudflare D1 in production.
## Changes Required
### `src/bin/api/db/d1.rs`
**1. Add unsafe Send/Sync** (wasm32 is single-threaded; safe in practice):
```rust
// SAFETY: wasm32-unknown-unknown is single-threaded.
unsafe impl Send for D1Repository {}
unsafe impl Sync for D1Repository {}
```
These are required so `D1Repository` satisfies the `Arc<dyn QuoteRepository + Send + Sync>` bound used in the Axum state.
**2. Helper row structs** (serde::Deserialize, field names = SQL column names):
- `QuoteRow { id, text, author, source: Option<String>, date: Option<String>, created_at, updated_at }`
- `AuthRow { auth_code: String }`
- `TagRow { tag: String }`
- `CountRow { count: u32 }`
**3. Helper method** `fetch_tags(&self, id: &str) -> Result<Vec<String>, DbError>`:
`SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag`, bind with `JsValue::from_str(id)`, deserialise as `Vec<TagRow>`.
**4. Implement each QuoteRepository method:**
- **run_migrations**: Call `self.db.exec()` for each migration constant from `migrations::` in sequence (CREATE_QUOTES, CREATE_QUOTE_TAGS, CREATE_TAG_INDEX, CREATE_AUTHOR_INDEX).
- **list_quotes(page, author, tag)**: Dynamic SQL with positional params (mirror native.rs logic). Run COUNT query for total_count, then data query with LIMIT 10 / OFFSET. Fetch tags per quote via helper. Page size = 10.
- **get_quote(id)**: `SELECT ... FROM quotes WHERE id = ?1`. Use `.first::<QuoteRow>(None)`. Fetch tags. Return `Ok(None)` if missing.
- **get_random_quote**: `SELECT ... FROM quotes ORDER BY RANDOM() LIMIT 1`. Use `.first::<QuoteRow>(None)`.
- **create_quote(input)**:
1. `generate_id()`, `auth_code = input.auth_code.unwrap_or_else(generate_auth_code)`
2. INSERT quotes row (bind NULL for optional source/date with `JsValue::NULL`)
3. Batch INSERT tags via `db.batch()`
4. SELECT back the row to get timestamps
5. Return `(quote, auth_code)`
- **update_quote(id, input, auth_code)**:
1. SELECT auth_code — return Forbidden on mismatch
2. Build dynamic SET clause for non-None fields + `updated_at = datetime('now')`
3. Execute UPDATE
4. If tags provided: DELETE existing, batch INSERT new
5. SELECT updated row; return it
- **delete_quote(id, auth_code)**:
1. SELECT auth_code — return NotFound if absent, Forbidden on mismatch
2. DELETE FROM quotes (tags cascade)
3. Return `DeleteResult::Deleted`
**JsValue bindings**: `JsValue::from_str(s)` for strings, `JsValue::from_f64(n as f64)` for integers, `JsValue::NULL` for SQL NULL.
## Validation
```sh
cargo build --release --bin api --target wasm32-unknown-unknown
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -0,0 +1,103 @@
+++
title = "quotesdb/ui: /admin page component"
priority = 5
status = "done"
ticket_type = "feature"
dependencies = ["161f32"]
+++
## /admin page component (UI)
Create the /admin route and page component. The page provides a persistent admin auth code input, an auth code reset section, and a submissions lock/unlock section.
Depends on ticket 161f32 (admin API client functions). Complete that ticket first.
---
## Files to create / modify
- `src/bin/ui/pages/admin.rs`**create** the new page component
- `src/bin/ui/pages/mod.rs` — add `pub mod admin;`
- `src/bin/ui/main.rs` — add the `/admin` route to the Yew Router switch and add an "Admin" link to the top nav (or wherever the nav links are defined)
---
## Component: AdminPage
The component holds the following local state:
| State field | Type | Purpose |
|---|---|---|
| `admin_code` | `String` | The persistent admin auth code input |
| `new_passphrase` | `String` | Optional input for reset section |
| `reset_result` | `Option<String>` | Newly returned auth code after reset |
| `reset_error` | `Option<String>` | Error message for the reset section |
| `submissions_locked` | `Option<bool>` | Current lock state, fetched on mount |
| `lock_error` | `Option<String>` | Error message for lock/unlock section |
| `loading` | `bool` | Disables buttons during in-flight requests |
### On mount
Call `api::get_status()` and set `submissions_locked` from the response.
### Layout
```
[ Admin ] (page heading)
Admin auth code: [__________________] (persistent text input)
--- Reset auth code ---
New passphrase (optional): [__________]
[ Reset ]
> (success) New code: ocean-table-purple-storm
> (error) Wrong auth code.
--- Submissions ---
Status: Open | Closed
[ Lock submissions ] / [ Unlock submissions ]
> (error) Wrong auth code.
```
### Behaviour
- **Reset button:** calls `api::admin_reset_auth_code(&admin_code, new_passphrase_opt, &admin_code)`. On success, shows the new code in `reset_result` and clears `reset_error`. On `ApiError::Forbidden`, sets `reset_error = "Wrong auth code."`. On other errors, sets a generic message.
- **Lock button** (shown when `submissions_locked == Some(false)`): calls `api::admin_lock(&admin_code)`. On success, sets `submissions_locked = Some(true)`. On `ApiError::Forbidden`, sets `lock_error`.
- **Unlock button** (shown when `submissions_locked == Some(true)`): calls `api::admin_unlock(&admin_code)`. On success, sets `submissions_locked = Some(false)`. On `ApiError::Forbidden`, sets `lock_error`.
- While `loading = true`, disable all buttons.
---
## Route registration (src/bin/ui/main.rs)
Add to the router switch:
```rust
Route { path: "/admin".to_string(), render: ... }
// or whichever router pattern is already in use
```
---
## Tests
No runtime unit tests (wasm-only). Verify the build:
```sh
cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Validation
```sh
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
```
---
## Commit
```
feat(quotesdb): /admin page component
```

@ -0,0 +1,49 @@
+++
title = "Submit form: date field should use type=date for calendar picker"
priority = 5
status = "done"
ticket_type = "bug"
dependencies = []
+++
## Bug
The date field on the submit form (`src/bin/ui/pages/submit.rs`, around line 200215) uses `type="text"` with a placeholder hinting at YYYY-MM-DD format. This means:
- No calendar UI — users must type the date manually
- No browser-native date validation
- No consistent format enforcement
## Expected behaviour
The date field should use `type="date"`, which:
- Provides a browser-native calendar picker on supported browsers
- Automatically enforces valid date format
- Returns the value as a YYYY-MM-DD string, which matches the API's `date` field format — no conversion needed
## How to fix
In `src/bin/ui/pages/submit.rs`:
1. Change the `type` attribute on the date `<input>` from `"text"` to `"date"` (~line 204).
2. Remove or update the label text — it currently says `"Date (optional, YYYY-MM-DD)"`. With `type="date"`, the format hint is no longer needed. Change to `"Date (optional)"`.
3. Remove the `placeholder` attribute — `type="date"` inputs do not display placeholder text in most browsers; it is redundant.
4. The `oninput` handler reads `.value()` from the `HtmlInputElement`, which is already correct — `type="date"` returns the value in YYYY-MM-DD format, so no changes needed to data handling.
**Note:** The existing `HtmlInputElement` import is already correct. No new imports needed.
## File
- `src/bin/ui/pages/submit.rs` (~lines 199215)
## Validation
```sh
# From quotesdb/ root
cargo fmt && cargo check && cargo clippy && cargo test
trunk build
```
Manually verify the date field shows a calendar picker in the browser.
## Commit scope
`fix(quotesdb): submit form date input type`

@ -0,0 +1,2 @@
* add an about page.
* add a footer to all pages that says something like "Contact quotes@elijah.run for any inquiries"

@ -17,16 +17,16 @@ OpenTofu configuration for deploying quotesdb to Cloudflare.
| Variable | Description |
|---|---|
| `TF_VAR_cloudflare_api_token` | Cloudflare API token (Workers, D1, Pages, DNS edit) |
| `TF_VAR_cloudflare_account_id` | Cloudflare account ID |
| `TF_VAR_cloudflare_zone_id` | Zone ID for `elijah.run` |
| `cloudflare_api_token` | Cloudflare API token (Workers, D1, Pages, DNS edit) |
| `cloudflare_account_id` | Cloudflare account ID |
| `cloudflare_zone_id` | Zone ID for `elijah.run` |
Export these before running `tofu`:
These are set in `terraform.tfvars` (gitignored)
```sh
export TF_VAR_cloudflare_api_token="..."
export TF_VAR_cloudflare_account_id="..."
export TF_VAR_cloudflare_zone_id="..."
cloudflare_api_token="..."
cloudflare_account_id="..."
cloudflare_zone_id="..."
```
## First-time setup (chicken-and-egg)

Loading…
Cancel
Save