# Auth Foundation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add cookie-session + API-token authentication, a role bitmask system, a first-run setup wizard, and auth-protected routes to the Archivr server. **Architecture:** Auth state lives in a dedicated `archivr-auth.sqlite` file next to the server config, separate from the per-archive store DBs. `AppState` gains an `auth_db_path`. Route handlers call `database::open_auth_db()` to get a connection; a new `AuthUser` Axum extractor validates session cookies and Bearer tokens for every protected handler. **Tech Stack:** Rust/Axum, rusqlite (existing), argon2 (Argon2id hashing), rand (token generation), axum-extra (cookie extraction), React/Vite (existing frontend) **Spec:** `docs/superpowers/specs/2026-06-25-auth-foundation-design.md` --- ## File Map | File | Action | What changes | |---|---|---| | `Cargo.toml` | Modify | Add `argon2`, `rand`, `axum-extra` to workspace deps | | `crates/archivr-server/Cargo.toml` | Modify | Pull `argon2`, `rand`, `axum-extra` from workspace | | `crates/archivr-core/src/database.rs` | Modify | `initialize_auth_schema`, auth CRUD helpers, new record types | | `crates/archivr-server/src/auth.rs` | **Create** | `AuthUser` extractor, password helpers, token generation, role constants | | `crates/archivr-server/src/routes.rs` | Modify | `AppState` + `app()` gain `auth_db_path`; auth endpoints; route protection; `ApiError` gets JSON body + `unauthorized`/`forbidden` constructors | | `crates/archivr-server/src/registry.rs` | Modify | `ServerRegistry` gains optional `auth_db_path` | | `crates/archivr-server/src/main.rs` | Modify | Compute auth DB path; pass to `app()`; session cleanup task; remove non-loopback auth warning | | `frontend/src/App.jsx` | Modify | Setup check on mount; `AuthContext`; 401 handling | | `frontend/src/api.js` | Modify | 401 interceptor; auth helper calls | | `frontend/src/components/LoginPage.jsx` | **Create** | Login form | | `frontend/src/components/SetupPage.jsx` | **Create** | First-run owner creation wizard | | `frontend/src/components/Topbar.jsx` | Modify | User menu + logout button | --- ## Task 1: Add dependencies **Files:** - Modify: `Cargo.toml` - Modify: `crates/archivr-server/Cargo.toml` - [ ] **Step 1: Add workspace dependencies** Open `Cargo.toml`. In `[workspace.dependencies]`, add after the `base64` line: ```toml argon2 = { version = "0.5", features = ["std"] } rand = { version = "0.8", features = ["std"] } axum-extra = { version = "0.9", features = ["cookie"] } ``` - [ ] **Step 2: Pull into server crate** Open `crates/archivr-server/Cargo.toml`. Add to `[dependencies]`: ```toml argon2.workspace = true rand.workspace = true axum-extra.workspace = true ``` - [ ] **Step 3: Verify compilation** ```bash cd ~/personal/archivr && cargo check -p archivr-server ``` Expected: compiles with no errors. - [ ] **Step 4: Commit** ```bash git add Cargo.toml crates/archivr-server/Cargo.toml Cargo.lock git commit -m "feat(auth): add argon2, rand, axum-extra dependencies" ``` --- ## Task 2: Auth schema in `database.rs` Add `initialize_auth_schema` and update `instance_settings`. **Files:** - Modify: `crates/archivr-core/src/database.rs` - [ ] **Step 1: Write failing test for role seeding** At the bottom of the `#[cfg(test)]` block in `database.rs`, add: ```rust #[test] fn auth_schema_seeds_builtin_roles() { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 4); let owner_bits: i64 = conn .query_row("SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0)) .unwrap(); assert_eq!(owner_bits, 3); } #[test] fn auth_schema_is_idempotent() { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); initialize_auth_schema(&conn).unwrap(); // must not panic } ``` - [ ] **Step 2: Run to confirm they fail** ```bash cd ~/personal/archivr && cargo test -p archivr-core auth_schema 2>&1 | tail -5 ``` Expected: FAILED — `initialize_auth_schema` not found. - [ ] **Step 3: Add `initialize_auth_schema`** After the closing `}` of `initialize_schema` in `database.rs`, add: ```rust pub fn initialize_auth_schema(conn: &Connection) -> Result<()> { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY, role_uid TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, level INTEGER NOT NULL, bit_position INTEGER NOT NULL UNIQUE, is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1)) ); INSERT OR IGNORE INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES ('role-guest', 'guest', 'Guest', 0, 0, 1), ('role-user', 'user', 'User', 1, 1, 1), ('role-admin', 'admin', 'Admin', 3, 2, 1), ('role-owner', 'owner', 'Owner', 4, 3, 1); CREATE TABLE IF NOT EXISTS user_roles ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id INTEGER NOT NULL REFERENCES roles(id), assigned_at TEXT NOT NULL, assigned_by_user_id INTEGER REFERENCES users(id), PRIMARY KEY (user_id, role_id) ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, session_uid TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_bits INTEGER NOT NULL, created_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, expires_at TEXT NOT NULL, user_agent TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY, token_uid TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, name TEXT NOT NULL, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); CREATE TABLE IF NOT EXISTS instance_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)), public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)), public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)), default_entry_visibility INTEGER NOT NULL DEFAULT 2 ); INSERT OR IGNORE INTO instance_settings (id, public_index_enabled, public_entry_content_enabled, public_archive_submission_enabled, default_entry_visibility) VALUES (1, 0, 0, 0, 2); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, user_uid TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), role TEXT NOT NULL CHECK (role IN ('admin', 'user')), created_at TEXT NOT NULL, last_login_at TEXT ); "#, )?; Ok(()) } ``` Note: `users` is duplicated here with `CREATE TABLE IF NOT EXISTS` so the auth DB is self-contained without needing `initialize_schema`. - [ ] **Step 4: Run tests** ```bash cd ~/personal/archivr && cargo test -p archivr-core auth_schema 2>&1 | tail -5 ``` Expected: 2 tests pass. - [ ] **Step 5: Add `open_auth_db` function** After `open_or_initialize`, add: ```rust pub fn open_auth_db(auth_db_path: &Path) -> Result { if let Some(parent) = auth_db_path.parent() { std::fs::create_dir_all(parent).with_context(|| { format!("failed to create auth DB directory {}", parent.display()) })?; } let conn = Connection::open(auth_db_path).with_context(|| { format!("failed to open auth database at {}", auth_db_path.display()) })?; initialize_auth_schema(&conn)?; Ok(conn) } ``` - [ ] **Step 6: Verify compilation and commit** ```bash cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -3 ``` Expected: all existing tests pass. ```bash git add crates/archivr-core/src/database.rs git commit -m "feat(auth): add initialize_auth_schema and open_auth_db" ``` --- ## Task 3: User and role DB helpers **Files:** - Modify: `crates/archivr-core/src/database.rs` - [ ] **Step 1: Add record types** After the existing struct definitions (before `pub fn database_path`), add: ```rust #[derive(Debug, Clone)] pub struct AuthUserRecord { pub id: i64, pub user_uid: String, pub username: String, pub password_hash: String, pub status: String, } #[derive(Debug, Clone)] pub struct SessionRecord { pub user_id: i64, pub role_bits: u32, pub last_seen_at: String, pub session_uid: String, } #[derive(Debug, Clone)] pub struct ApiTokenRecord { pub token_uid: String, pub name: String, pub created_at: String, pub last_used_at: Option, } ``` - [ ] **Step 2: Write failing tests for user helpers** In the `#[cfg(test)]` block, add: ```rust fn make_auth_conn() -> Connection { let conn = Connection::open_in_memory().unwrap(); initialize_auth_schema(&conn).unwrap(); conn } #[test] fn ensure_owner_exists_returns_false_when_no_owner() { let conn = make_auth_conn(); assert!(!ensure_owner_exists(&conn).unwrap()); } #[test] fn create_owner_then_ensure_returns_true() { let conn = make_auth_conn(); create_owner(&conn, "alice", "hashed_pw").unwrap(); assert!(ensure_owner_exists(&conn).unwrap()); } #[test] fn create_owner_assigns_cumulative_roles() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "hashed_pw").unwrap(); let bits = compute_role_bits(&conn, user_id).unwrap(); // guest=1, user=2, admin=4, owner=8 → 15 assert_eq!(bits, 15u32); } #[test] fn get_user_by_username_returns_none_for_unknown() { let conn = make_auth_conn(); assert!(get_user_by_username(&conn, "nobody").unwrap().is_none()); } ``` - [ ] **Step 3: Run to confirm they fail** ```bash cd ~/personal/archivr && cargo test -p archivr-core ensure_owner 2>&1 | tail -5 ``` Expected: FAILED. - [ ] **Step 4: Implement user/role helpers** After `open_auth_db`, add: ```rust /// Returns true if an owner account exists. pub fn ensure_owner_exists(conn: &Connection) -> Result { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE r.slug = 'owner'", [], |row| row.get(0), )?; Ok(count > 0) } /// Creates a user and assigns all roles from `user` up to `owner` (cumulative). /// `password_hash` must already be hashed by the caller. pub fn create_owner(conn: &Connection, username: &str, password_hash: &str) -> Result { let user_uid = public_id("usr"); conn.execute( "INSERT INTO users (user_uid, username, email, password_hash, status, role, created_at) VALUES (?1, ?2, NULL, ?3, 'active', 'admin', ?4)", params![user_uid, username, password_hash, now_timestamp()], )?; let user_id = conn.last_insert_rowid(); // Assign user, admin, owner (cumulative) for slug in &["user", "admin", "owner"] { let role_id: i64 = conn.query_row( "SELECT id FROM roles WHERE slug = ?1", [slug], |row| row.get(0), )?; conn.execute( "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?1, ?2, ?3)", params![user_id, role_id, now_timestamp()], )?; } Ok(user_id) } pub fn get_user_by_username(conn: &Connection, username: &str) -> Result> { conn.query_row( "SELECT id, user_uid, username, password_hash, status FROM users WHERE username = ?1", [username], |row| { Ok(AuthUserRecord { id: row.get(0)?, user_uid: row.get(1)?, username: row.get(2)?, password_hash: row.get(3)?, status: row.get(4)?, }) }, ) .optional() .map_err(Into::into) } /// Computes role_bits = ROLE_GUEST | OR(assigned role bit values). /// ROLE_GUEST (bit 0, value 1) is always included as the implicit floor. pub fn compute_role_bits(conn: &Connection, user_id: i64) -> Result { let mut stmt = conn.prepare( "SELECT (1 << r.bit_position) FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?1", )?; let bits: u32 = stmt .query_map([user_id], |row| row.get::<_, i64>(0))? .try_fold(1u32, |acc, val| val.map(|v| acc | v as u32))?; Ok(bits) } ``` - [ ] **Step 5: Run tests** ```bash cd ~/personal/archivr && cargo test -p archivr-core ensure_owner create_owner get_user compute_role 2>&1 | tail -5 ``` Expected: all 4 new tests pass. - [ ] **Step 6: Commit** ```bash git add crates/archivr-core/src/database.rs git commit -m "feat(auth): user and role DB helpers (create_owner, compute_role_bits)" ``` --- ## Task 4: Session and token DB helpers **Files:** - Modify: `crates/archivr-core/src/database.rs` - [ ] **Step 1: Write failing session tests** ```rust #[test] fn create_and_get_session() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "pw").unwrap(); let uid = create_session(&conn, user_id, 15, None).unwrap(); let sess = get_session(&conn, &uid).unwrap().unwrap(); assert_eq!(sess.user_id, user_id); assert_eq!(sess.role_bits, 15); } #[test] fn get_session_returns_none_for_unknown() { let conn = make_auth_conn(); assert!(get_session(&conn, "nonexistent").unwrap().is_none()); } #[test] fn delete_session_removes_it() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "pw").unwrap(); let uid = create_session(&conn, user_id, 15, None).unwrap(); delete_session(&conn, &uid).unwrap(); assert!(get_session(&conn, &uid).unwrap().is_none()); } #[test] fn token_hash_round_trips() { let conn = make_auth_conn(); let user_id = create_owner(&conn, "alice", "pw").unwrap(); create_api_token(&conn, user_id, "hash_abc", "My Token").unwrap(); let found_id = get_user_for_token(&conn, "hash_abc").unwrap(); assert_eq!(found_id, Some(user_id)); } #[test] fn get_user_for_token_returns_none_for_unknown() { let conn = make_auth_conn(); assert!(get_user_for_token(&conn, "unknown").unwrap().is_none()); } ``` - [ ] **Step 2: Run to confirm failure** ```bash cd ~/personal/archivr && cargo test -p archivr-core create_and_get_session token_hash 2>&1 | tail -5 ``` - [ ] **Step 3: Implement session helpers** After `compute_role_bits`, add: ```rust /// Returns a new session_uid (UUID). pub fn create_session( conn: &Connection, user_id: i64, role_bits: u32, user_agent: Option<&str>, ) -> Result { let session_uid = public_id("sess"); let now = now_timestamp(); // expires_at = 30 days from now (approximate via string arithmetic is fragile; // compute with chrono instead) let expires_at = chrono::Utc::now() .checked_add_signed(chrono::Duration::days(30)) .unwrap() .format("%Y-%m-%dT%H-%M-%S%.3f") .to_string(); conn.execute( "INSERT INTO sessions (session_uid, user_id, role_bits, created_at, last_seen_at, expires_at, user_agent) VALUES (?1, ?2, ?3, ?4, ?4, ?5, ?6)", params![session_uid, user_id, role_bits as i64, now, expires_at, user_agent], )?; Ok(session_uid) } /// Returns session if it exists, the user is active, and it has not expired. pub fn get_session(conn: &Connection, session_uid: &str) -> Result> { let now = now_timestamp(); conn.query_row( "SELECT s.user_id, s.role_bits, s.last_seen_at, s.session_uid FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.session_uid = ?1 AND u.status = 'active' AND s.expires_at > ?2", params![session_uid, now], |row| { Ok(SessionRecord { user_id: row.get(0)?, role_bits: row.get::<_, i64>(1)? as u32, last_seen_at: row.get(2)?, session_uid: row.get(3)?, }) }, ) .optional() .map_err(Into::into) } pub fn delete_session(conn: &Connection, session_uid: &str) -> Result<()> { conn.execute("DELETE FROM sessions WHERE session_uid = ?1", [session_uid])?; Ok(()) } /// Updates last_seen_at and extends expires_at by 30 days. pub fn touch_session(conn: &Connection, session_uid: &str) -> Result<()> { let now = now_timestamp(); let new_expires = chrono::Utc::now() .checked_add_signed(chrono::Duration::days(30)) .unwrap() .format("%Y-%m-%dT%H-%M-%S%.3f") .to_string(); conn.execute( "UPDATE sessions SET last_seen_at = ?1, expires_at = ?2 WHERE session_uid = ?3", params![now, new_expires, session_uid], )?; Ok(()) } pub fn delete_expired_sessions(conn: &Connection) -> Result { let now = now_timestamp(); let n = conn.execute("DELETE FROM sessions WHERE expires_at <= ?1", [now])?; Ok(n) } ``` - [ ] **Step 4: Implement token helpers** ```rust /// Creates an API token. `token_hash` is SHA3-256 hex of the raw token. /// Returns the token_uid. pub fn create_api_token( conn: &Connection, user_id: i64, token_hash: &str, name: &str, ) -> Result { let token_uid = public_id("tok"); conn.execute( "INSERT INTO api_tokens (token_uid, user_id, token_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![token_uid, user_id, token_hash, name, now_timestamp()], )?; Ok(token_uid) } /// Returns the user_id for a given token hash, if the token is valid and user is active. pub fn get_user_for_token(conn: &Connection, token_hash: &str) -> Result> { let now = now_timestamp(); conn.query_row( "SELECT t.user_id FROM api_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?1 AND u.status = 'active' AND (t.expires_at IS NULL OR t.expires_at > ?2)", params![token_hash, now], |row| row.get(0), ) .optional() .map_err(Into::into) } pub fn touch_token(conn: &Connection, token_uid: &str) -> Result<()> { conn.execute( "UPDATE api_tokens SET last_used_at = ?1 WHERE token_uid = ?2", params![now_timestamp(), token_uid], )?; Ok(()) } /// Returns true if the token was found and deleted (user_id must match). pub fn delete_api_token(conn: &Connection, token_uid: &str, user_id: i64) -> Result { let n = conn.execute( "DELETE FROM api_tokens WHERE token_uid = ?1 AND user_id = ?2", params![token_uid, user_id], )?; Ok(n > 0) } pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result> { let mut stmt = conn.prepare( "SELECT token_uid, name, created_at, last_used_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC", )?; let records = stmt .query_map([user_id], |row| { Ok(ApiTokenRecord { token_uid: row.get(0)?, name: row.get(1)?, created_at: row.get(2)?, last_used_at: row.get(3)?, }) })? .collect::, _>>()?; Ok(records) } ``` - [ ] **Step 5: Run all new tests** ```bash cd ~/personal/archivr && cargo test -p archivr-core 2>&1 | tail -5 ``` Expected: all tests pass (including all previously passing ones). - [ ] **Step 6: Commit** ```bash git add crates/archivr-core/src/database.rs git commit -m "feat(auth): session and token DB helpers" ``` --- ## Task 5: Auth DB path in AppState, registry, and main.rs **Files:** - Modify: `crates/archivr-server/src/registry.rs` - Modify: `crates/archivr-server/src/routes.rs` - Modify: `crates/archivr-server/src/main.rs` - [ ] **Step 1: Add `auth_db_path` to `ServerRegistry`** In `registry.rs`, update the `ServerRegistry` struct: ```rust #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct ServerRegistry { #[serde(default)] pub archives: Vec, /// Optional bind address. Defaults to `127.0.0.1:8080`. #[serde(default)] pub bind: Option, /// Path to the server-level auth database. /// Defaults to `archivr-auth.sqlite` in the same directory as the config file. #[serde(default)] pub auth_db_path: Option, } ``` - [ ] **Step 2: Update `AppState` in `routes.rs`** Change `AppState`: ```rust #[derive(Clone)] pub struct AppState { registry: Arc, pub auth_db_path: Arc, } ``` Update `app()` signature and body: ```rust pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router { let state = AppState { registry: Arc::new(registry), auth_db_path: Arc::new(auth_db_path), }; // ... rest unchanged ``` - [ ] **Step 3: Update all tests in `routes.rs` that call `app(registry)`** Every `app(registry)` in the test module must become `app(registry, std::path::PathBuf::from("/tmp/test-auth.sqlite"))`. Search for all occurrences: ```bash grep -n "app(registry" ~/personal/archivr/crates/archivr-server/src/routes.rs | head -20 ``` Update each one to `app(registry, tempfile::tempdir().unwrap().path().join("auth.sqlite"))`. Add `use tempfile;` if not present. (The `tempfile` crate is already a workspace dependency.) - [ ] **Step 4: Update `main.rs`** ```rust mod registry; mod routes; use anyhow::{Context, Result}; use std::{net::SocketAddr, path::PathBuf}; const DEFAULT_BIND: &str = "127.0.0.1:8080"; #[tokio::main] async fn main() -> Result<()> { let config_path = std::env::args() .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("archivr-server.toml")); let registry = registry::load_registry(&config_path)?; // Auth DB lives next to the config file unless overridden. let auth_db_path = registry.auth_db_path.clone().unwrap_or_else(|| { config_path .parent() .unwrap_or_else(|| std::path::Path::new(".")) .join("archivr-auth.sqlite") }); let app = routes::app(registry.clone(), auth_db_path.clone()); let bind_str = std::env::var("ARCHIVR_BIND") .ok() .or_else(|| registry.bind.clone()) .unwrap_or_else(|| DEFAULT_BIND.to_string()); let addr: SocketAddr = bind_str .parse() .with_context(|| format!("invalid bind address: {bind_str}"))?; let listener = tokio::net::TcpListener::bind(addr).await?; println!("archivr-server listening on http://{addr}"); axum::serve(listener, app).await?; Ok(()) } ``` - [ ] **Step 5: Run full test suite** ```bash cd ~/personal/archivr && cargo test 2>&1 | tail -5 ``` Expected: all tests pass. - [ ] **Step 6: Commit** ```bash git add crates/archivr-server/src/routes.rs \ crates/archivr-server/src/registry.rs \ crates/archivr-server/src/main.rs git commit -m "feat(auth): add auth_db_path to AppState, registry, and main.rs" ``` --- ## Task 6: Create `auth.rs` — AuthUser extractor **Files:** - Create: `crates/archivr-server/src/auth.rs` - Modify: `crates/archivr-server/src/routes.rs` (add `mod auth; use auth::AuthUser;`) - [ ] **Step 1: Create `auth.rs`** Create `crates/archivr-server/src/auth.rs` with the following content: ```rust use anyhow::Result; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use argon2::password_hash::{SaltString, rand_core::OsRng}; use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum_extra::extract::CookieJar; use rand::RngCore; use crate::routes::{ApiError, AppState}; use archivr_core::database; // ── Role bit constants ──────────────────────────────────────────────────────── pub const ROLE_GUEST: u32 = 1; // bit 0 pub const ROLE_USER: u32 = 2; // bit 1 pub const ROLE_ADMIN: u32 = 4; // bit 2 pub const ROLE_OWNER: u32 = 8; // bit 3 // ── AuthUser ───────────────────────────────────────────────────────────────── #[derive(Clone, Debug)] pub enum AuthUser { Guest, Authenticated { user_id: i64, role_bits: u32 }, } impl AuthUser { /// Returns (user_id, role_bits) or 401 if Guest. pub fn require_auth(&self) -> Result<(i64, u32), ApiError> { match self { AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)), AuthUser::Guest => Err(ApiError::unauthorized("login required")), } } /// Returns Ok(()) if the user has the given role bit set, else 401/403. pub fn require_role(&self, bit: u32) -> Result<(), ApiError> { match self { AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()), AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")), AuthUser::Guest => Err(ApiError::unauthorized("login required")), } } pub fn has_role(&self, bit: u32) -> bool { matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0) } } #[async_trait] impl FromRequestParts for AuthUser { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let auth_db_path = state.auth_db_path.as_ref(); // 1. Try session cookie let jar = CookieJar::from_headers(&parts.headers); if let Some(cookie) = jar.get("session") { let session_uid = cookie.value().to_string(); if let Ok(conn) = database::open_auth_db(auth_db_path) { if let Ok(Some(session)) = database::get_session(&conn, &session_uid) { // Conditional touch: only update last_seen_at if more than 60s have elapsed. // The session row is already in memory, so no extra query needed. let should_touch = chrono::NaiveDateTime::parse_from_str( &session.last_seen_at, "%Y-%m-%dT%H-%M-%S%.3f", ) .map(|last| { let last_utc = chrono::DateTime::::from_naive_utc_and_offset( last, chrono::Utc, ); chrono::Utc::now() - last_utc > chrono::Duration::seconds(60) }) .unwrap_or(true); // if parse fails, touch anyway if should_touch { let _ = database::touch_session(&conn, &session_uid); } return Ok(AuthUser::Authenticated { user_id: session.user_id, role_bits: session.role_bits, }); } } } // 2. Try Bearer token if let Some(auth_header) = parts.headers.get("Authorization") { if let Ok(header_str) = auth_header.to_str() { if let Some(raw_token) = header_str.strip_prefix("Bearer ") { let token_hash = hash_token(raw_token); if let Ok(conn) = database::open_auth_db(auth_db_path) { if let Ok(Some(user_id)) = database::get_user_for_token(&conn, &token_hash) { // Get token_uid for touch (find by hash) // Compute role_bits live for tokens if let Ok(role_bits) = database::compute_role_bits(&conn, user_id) { return Ok(AuthUser::Authenticated { user_id, role_bits }); } } } } } } Ok(AuthUser::Guest) } } // ── Password helpers ────────────────────────────────────────────────────────── pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default() .hash_password(password.as_bytes(), &salt) .map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))? .to_string(); Ok(hash) } pub fn verify_password(password: &str, hash: &str) -> Result { let parsed = PasswordHash::new(hash) .map_err(|e| anyhow::anyhow!("invalid password hash: {e}"))?; Ok(Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok()) } // ── Token helpers ───────────────────────────────────────────────────────────── /// Generates a cryptographically random 32-byte token, base64url-encoded. pub fn generate_token() -> String { let mut bytes = [0u8; 32]; rand::thread_rng().fill_bytes(&mut bytes); base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) } /// SHA3-256 hex hash of a raw token string. Used for storage and lookup. pub fn hash_token(raw: &str) -> String { archivr_core::hash::hash_bytes(raw.as_bytes()) } ``` - [ ] **Step 2: Wire into `routes.rs`** At the top of `routes.rs`, add after `mod` declarations: ```rust mod auth; pub use auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER}; ``` Also add `unauthorized` and `forbidden` constructors to `ApiError`, and update `IntoResponse` to return JSON: ```rust impl ApiError { // ... existing constructors ... pub fn unauthorized(message: &str) -> Self { Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() } } pub fn forbidden(message: &str) -> Self { Self { status: StatusCode::FORBIDDEN, message: message.to_string() } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let body = serde_json::json!({ "error": self.message }); (self.status, axum::Json(body)).into_response() } } ``` - [ ] **Step 3: Compile check** ```bash cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -10 ``` Fix any import errors. Common fix: add `use std::path::Path;` or check `axum_extra` cookie import. - [ ] **Step 4: Write extractor tests in `routes.rs`** In the `#[cfg(test)]` block in `routes.rs`, add helpers and tests: ```rust fn make_test_app() -> (Router, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let auth_db_path = dir.path().join("auth.sqlite"); let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; (app(registry, auth_db_path), dir) } #[tokio::test] async fn health_check_returns_ok() { let (app, _dir) = make_test_app(); let response = app .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } ``` Update the existing `archives_endpoint_lists_mounted_archives` test to use `make_test_app()`: ```rust #[tokio::test] async fn archives_endpoint_lists_mounted_archives() { let dir = tempfile::tempdir().unwrap(); let auth_db_path = dir.path().join("auth.sqlite"); let registry = ServerRegistry { archives: vec![MountedArchive { id: "personal".to_string(), label: "Personal".to_string(), archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"), }], bind: None, auth_db_path: None, }; let response = app(registry, auth_db_path) .oneshot(Request::builder().uri("/api/archives").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } ``` Apply the same pattern to **every other test** in the module that calls `app(registry)`. - [ ] **Step 5: Run all tests** ```bash cd ~/personal/archivr && cargo test 2>&1 | tail -5 ``` Expected: all tests pass. - [ ] **Step 6: Commit** ```bash git add crates/archivr-server/src/auth.rs crates/archivr-server/src/routes.rs git commit -m "feat(auth): AuthUser extractor, password helpers, token generation" ``` --- ## Task 7: Auth endpoints — login, logout, /me, setup **Files:** - Modify: `crates/archivr-server/src/routes.rs` Add the following to `routes.rs`. - [ ] **Step 1: Write failing tests for auth endpoints** ```rust #[tokio::test] async fn setup_required_before_owner_created() { let (app, _dir) = make_test_app(); let response = app .oneshot( Request::builder() .uri("/api/auth/setup") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(json["setup_required"], true); } #[tokio::test] async fn login_with_wrong_password_returns_401() { let dir = tempfile::tempdir().unwrap(); let auth_db_path = dir.path().join("auth.sqlite"); // Seed owner { let conn = archivr_core::database::open_auth_db(&auth_db_path).unwrap(); let hash = crate::auth::hash_password("correct").unwrap(); archivr_core::database::create_owner(&conn, "owner", &hash).unwrap(); } let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; let response = app(registry, auth_db_path) .oneshot( Request::builder() .method("POST") .uri("/api/auth/login") .header("content-type", "application/json") .body(Body::from(r#"{"username":"owner","password":"wrong"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn setup_post_creates_owner_and_returns_409_on_repeat() { let (app_once, dir) = make_test_app(); let auth_db_path = dir.path().join("auth.sqlite"); let registry = ServerRegistry { archives: vec![], bind: None, auth_db_path: None }; // First POST creates the owner let response = app_once .oneshot( Request::builder() .method("POST") .uri("/api/auth/setup") .header("content-type", "application/json") .body(Body::from(r#"{"username":"owner","password":"hunter2"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::CREATED); // Second POST must return 409 let app2 = app(registry, auth_db_path); let response2 = app2 .oneshot( Request::builder() .method("POST") .uri("/api/auth/setup") .header("content-type", "application/json") .body(Body::from(r#"{"username":"owner2","password":"hunter2"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response2.status(), StatusCode::CONFLICT); } ``` - [ ] **Step 2: Run to confirm failure** ```bash cd ~/personal/archivr && cargo test -p archivr-server setup_required login_with_wrong 2>&1 | tail -10 ``` Expected: FAILED. - [ ] **Step 3: Add auth routes to `app()` in `routes.rs`** In the `app()` function, add these routes before `.with_state(state)`: ```rust .route("/api/auth/setup", get(auth_setup_status).post(auth_setup)) .route("/api/auth/login", post(auth_login)) .route("/api/auth/logout", post(auth_logout)) .route("/api/auth/me", get(auth_me)) // Setup guard must come AFTER routes are defined and BEFORE .with_state .layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) ``` - [ ] **Step 3b: Implement `setup_guard` middleware** Add this function before the `app()` definition: ```rust /// Tower middleware: returns 503 on all non-exempt routes if setup hasn't been completed. async fn setup_guard( State(state): State, req: axum::extract::Request, next: axum::middleware::Next, ) -> Response { let path = req.uri().path(); let exempt = path == "/api/auth/setup" || path == "/api/auth/login" || path.starts_with("/assets") || path == "/" || path == "/health"; if !exempt { if let Ok(conn) = database::open_auth_db(&state.auth_db_path) { if matches!(database::ensure_owner_exists(&conn), Ok(false)) { return ( StatusCode::SERVICE_UNAVAILABLE, axum::Json(serde_json::json!({ "error": "setup_required" })), ) .into_response(); } } } next.run(req).await } ``` - [ ] **Step 4: Add request/response types** After the existing `#[derive(serde::Deserialize)]` structs, add: ```rust #[derive(Debug, serde::Deserialize)] struct LoginBody { username: String, password: String, } #[derive(Debug, serde::Deserialize)] struct SetupBody { username: String, password: String, } ``` - [ ] **Step 5: Implement handler functions** Add after the existing handlers: ```rust async fn auth_setup_status( State(state): State, ) -> Result, ApiError> { let conn = database::open_auth_db(&state.auth_db_path)?; let required = !database::ensure_owner_exists(&conn)?; Ok(Json(serde_json::json!({ "setup_required": required }))) } async fn auth_setup( State(state): State, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let conn = database::open_auth_db(&state.auth_db_path)?; if database::ensure_owner_exists(&conn)? { return Err(ApiError { status: StatusCode::CONFLICT, message: "already_configured".to_string(), }); } if body.username.trim().is_empty() || body.password.len() < 8 { return Err(ApiError::bad_request("username required and password must be at least 8 characters")); } let hash = auth::hash_password(&body.password).map_err(ApiError::from)?; let user_id = database::create_owner(&conn, &body.username, &hash)?; let user = database::get_user_by_username(&conn, &body.username)? .ok_or_else(|| ApiError::internal("user not found after creation"))?; Ok((StatusCode::CREATED, Json(serde_json::json!({ "user_uid": user.user_uid, "username": user.username, })))) } async fn auth_login( State(state): State, headers: axum::http::HeaderMap, Json(body): Json, ) -> Result<(StatusCode, axum::http::HeaderMap, Json), ApiError> { let conn = database::open_auth_db(&state.auth_db_path)?; let user = database::get_user_by_username(&conn, &body.username)? .filter(|u| u.status == "active") .ok_or_else(|| ApiError::unauthorized("invalid_credentials"))?; if !auth::verify_password(&body.password, &user.password_hash) .map_err(ApiError::from)? { return Err(ApiError::unauthorized("invalid_credentials")); } let role_bits = database::compute_role_bits(&conn, user.id)?; let user_agent = headers .get("user-agent") .and_then(|v| v.to_str().ok()); let session_uid = database::create_session(&conn, user.id, role_bits, user_agent)?; // Build Set-Cookie header let secure = headers .get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) .map(|v| v == "https") .unwrap_or(false); let cookie_value = format!( "session={}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000{}", session_uid, if secure { "; Secure" } else { "" } ); let mut resp_headers = axum::http::HeaderMap::new(); resp_headers.insert( axum::http::header::SET_COOKIE, cookie_value.parse().map_err(|_| ApiError::internal("cookie serialization failed"))?, ); Ok((StatusCode::OK, resp_headers, Json(serde_json::json!({ "user_uid": user.user_uid, "username": user.username, "role_bits": role_bits, })))) } async fn auth_logout( State(state): State, jar: CookieJar, ) -> Result<(StatusCode, axum::http::HeaderMap), ApiError> { if let Some(cookie) = jar.get("session") { let conn = database::open_auth_db(&state.auth_db_path)?; database::delete_session(&conn, cookie.value())?; } let mut resp_headers = axum::http::HeaderMap::new(); resp_headers.insert( axum::http::header::SET_COOKIE, "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" .parse() .unwrap(), ); Ok((StatusCode::NO_CONTENT, resp_headers)) } async fn auth_me( State(state): State, auth_user: AuthUser, ) -> Result, ApiError> { let (user_id, role_bits) = auth_user.require_auth()?; let conn = database::open_auth_db(&state.auth_db_path)?; // Look up username by user_id for response let username: String = conn .query_row("SELECT username FROM users WHERE id = ?1", [user_id], |r| r.get(0)) .map_err(ApiError::from)?; Ok(Json(serde_json::json!({ "role_bits": role_bits, "username": username, }))) } ``` Add `use axum_extra::extract::CookieJar;` at the top of `routes.rs` imports. - [ ] **Step 6: Run tests** ```bash cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5 ``` Expected: all tests pass. - [ ] **Step 7: Commit** ```bash git add crates/archivr-server/src/routes.rs git commit -m "feat(auth): login, logout, /me, setup endpoints" ``` --- ## Task 8: API token endpoints **Files:** - Modify: `crates/archivr-server/src/routes.rs` - [ ] **Step 1: Write failing test** ```rust #[tokio::test] async fn create_token_requires_auth() { let (app, _dir) = make_test_app(); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/auth/tokens") .header("content-type", "application/json") .body(Body::from(r#"{"name":"my token"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } ``` - [ ] **Step 2: Add token routes to `app()`** ```rust .route("/api/auth/tokens", get(list_tokens).post(create_token)) .route("/api/auth/tokens/:token_uid", delete(delete_token)) ``` - [ ] **Step 3: Add request type** ```rust #[derive(Debug, serde::Deserialize)] struct CreateTokenBody { name: String, } ``` - [ ] **Step 4: Implement token handlers** ```rust async fn create_token( State(state): State, auth_user: AuthUser, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let (user_id, _) = auth_user.require_auth()?; if body.name.trim().is_empty() { return Err(ApiError::bad_request("token name is required")); } let raw_token = auth::generate_token(); let token_hash = auth::hash_token(&raw_token); let conn = database::open_auth_db(&state.auth_db_path)?; let token_uid = database::create_api_token(&conn, user_id, &token_hash, &body.name)?; Ok((StatusCode::CREATED, Json(serde_json::json!({ "token_uid": token_uid, "raw_token": raw_token, "name": body.name, })))) } async fn list_tokens( State(state): State, auth_user: AuthUser, ) -> Result>, ApiError> { let (user_id, _) = auth_user.require_auth()?; let conn = database::open_auth_db(&state.auth_db_path)?; Ok(Json(database::list_user_tokens(&conn, user_id)?)) } async fn delete_token( State(state): State, auth_user: AuthUser, Path(token_uid): Path, ) -> Result { let (user_id, _) = auth_user.require_auth()?; let conn = database::open_auth_db(&state.auth_db_path)?; if database::delete_api_token(&conn, &token_uid, user_id)? { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::not_found("token not found")) } } ``` - [ ] **Step 5: Derive `serde::Serialize` on `ApiTokenRecord`** In `database.rs`, update the struct: ```rust #[derive(Debug, Clone, serde::Serialize)] pub struct ApiTokenRecord { ... } ``` - [ ] **Step 6: Run tests** ```bash cd ~/personal/archivr && cargo test -p archivr-server 2>&1 | tail -5 ``` Expected: all pass including `create_token_requires_auth`. - [ ] **Step 7: Commit** ```bash git add crates/archivr-server/src/routes.rs crates/archivr-core/src/database.rs git commit -m "feat(auth): API token endpoints (create, list, delete)" ``` --- ## Task 9: Route protection for existing routes **Files:** - Modify: `crates/archivr-server/src/routes.rs` - [ ] **Step 1: Write failing tests** ```rust #[tokio::test] async fn capture_returns_401_for_unauthenticated() { let (app, _dir) = make_test_app(); let response = app .oneshot( Request::builder() .method("POST") .uri("/api/archives/test/captures") .header("content-type", "application/json") .body(Body::from(r#"{"locator":"https://example.com"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } ``` - [ ] **Step 2: Run to confirm failure** ```bash cd ~/personal/archivr && cargo test -p archivr-server capture_returns_401 2>&1 | tail -5 ``` Expected: FAILED (currently returns 200 or 404). - [ ] **Step 3: Add `AuthUser` to WRITE handlers** Update `capture_handler` to require auth: ```rust async fn capture_handler( State(state): State, auth_user: AuthUser, Path(archive_id): Path, Json(body): Json, ) -> Result, ApiError> { auth_user.require_role(ROLE_USER)?; // ... rest unchanged } ``` Update `create_tag_handler`, `assign_entry_tag_handler`, `remove_entry_tag_handler` with the same `auth_user: AuthUser` parameter and `auth_user.require_role(ROLE_USER)?` guard. - [ ] **Step 4: Update security-boundary comment in `routes.rs`** Find the comment block at the top of `routes.rs` (lines 1–23). Replace the route classification list to reflect the new protection: ```rust // ── Security Boundary ────────────────────────────────────────────────────── // // Route protection tiers: // STATIC — no auth: GET /, GET /assets/* // PUBLIC_READ — no auth (visibility filtering deferred to Track 6): // GET /api/archives, GET /api/archives/:id/entries, etc. // AUTH — requires login (ROLE_USER bit): // POST /api/archives/:id/captures // POST/PUT/DELETE /api/archives/:id/tags // POST/DELETE /api/archives/:id/entries/:uid/tags // ADMIN — requires ROLE_ADMIN: // GET /api/admin/archives (future) // OWNER — requires ROLE_OWNER: // instance settings (future) // AUTH_SELF — no role guard (own resources): // GET/POST/DELETE /api/auth/tokens // POST /api/auth/logout // GET /api/auth/me // ──────────────────────────────────────────────────────────────────────────── ``` - [ ] **Step 5: Run all tests** ```bash cd ~/personal/archivr && cargo test 2>&1 | tail -5 ``` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add crates/archivr-server/src/routes.rs git commit -m "feat(auth): apply ROLE_USER guard to WRITE routes" ``` --- ## Task 10: Session cleanup background task **Files:** - Modify: `crates/archivr-server/src/main.rs` - [ ] **Step 1: Add cleanup task to `main.rs`** After the `app` binding and before `let listener`, add: ```rust // Spawn session cleanup background task: delete expired sessions at startup // and every 24 hours. let cleanup_auth_path = auth_db_path.clone(); tokio::spawn(async move { loop { if let Ok(conn) = archivr_core::database::open_auth_db(&cleanup_auth_path) { match archivr_core::database::delete_expired_sessions(&conn) { Ok(n) if n > 0 => eprintln!("info: cleaned up {n} expired sessions"), Err(e) => eprintln!("warn: session cleanup failed: {e:#}"), _ => {} } } tokio::time::sleep(tokio::time::Duration::from_secs(24 * 60 * 60)).await; } }); ``` Add `use archivr_core;` at the top if not already present (it's brought in transitively via routes). - [ ] **Step 2: Verify compilation** ```bash cd ~/personal/archivr && cargo check -p archivr-server 2>&1 | tail -5 ``` Expected: compiles cleanly. - [ ] **Step 3: Commit** ```bash git add crates/archivr-server/src/main.rs git commit -m "feat(auth): session cleanup background task (24h interval)" ``` --- ## Task 11: Frontend — auth state, api.js, LoginPage, SetupPage **Files:** - Modify: `frontend/src/App.jsx` - Modify: `frontend/src/api.js` - Create: `frontend/src/components/LoginPage.jsx` - Create: `frontend/src/components/SetupPage.jsx` - [ ] **Step 1: Update `api.js`** Open `frontend/src/api.js`. Add auth helper functions and the 401 interceptor at the bottom of the file (replace or append to the existing `export` structure): ```js // ── Auth helpers ───────────────────────────────────────────────────────────── export async function checkSetup() { const r = await fetch('/api/auth/setup'); const data = await r.json(); return data.setup_required === true; } export async function doSetup(username, password) { const r = await fetch('/api/auth/setup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (!r.ok) throw new Error((await r.json()).error || 'Setup failed'); return r.json(); } export async function login(username, password) { const r = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, password }), }); if (!r.ok) throw new Error((await r.json()).error || 'Login failed'); return r.json(); // { user_uid, username, role_bits } } export async function logout() { await fetch('/api/auth/logout', { method: 'POST' }); } export async function fetchMe() { const r = await fetch('/api/auth/me'); if (r.status === 401) return null; return r.json(); } // ── 401 interceptor ─────────────────────────────────────────────────────────── // Wrap fetch so any 401 dispatches a custom event for App.jsx to handle. const _origFetch = window.fetch; window.fetch = async (...args) => { const r = await _origFetch(...args); if (r.status === 401) { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? ''; // Don't intercept auth endpoints themselves if (!url.includes('/api/auth/')) { window.dispatchEvent(new CustomEvent('auth:expired')); } } return r; }; ``` - [ ] **Step 2: Create `LoginPage.jsx`** ```jsx import { useState } from 'react'; import { login } from '../api.js'; export default function LoginPage({ onLogin }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function handleSubmit(e) { e.preventDefault(); setError(null); setLoading(true); try { const user = await login(username, password); onLogin(user); } catch (err) { setError(err.message); } finally { setLoading(false); } } return (

Archivr

{error &&

{error}

}
); } ``` - [ ] **Step 3: Create `SetupPage.jsx`** ```jsx import { useState } from 'react'; import { doSetup } from '../api.js'; export default function SetupPage({ onComplete }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function handleSubmit(e) { e.preventDefault(); if (password !== confirm) { setError('Passwords do not match'); return; } if (password.length < 8) { setError('Password must be at least 8 characters'); return; } setError(null); setLoading(true); try { await doSetup(username, password); onComplete(); } catch (err) { setError(err.message); } finally { setLoading(false); } } return (

Welcome to Archivr

Create your owner account to get started.

{error &&

{error}

}
); } ``` - [ ] **Step 4: Update `App.jsx`** Open `frontend/src/App.jsx`. Add the following at the top (after existing imports): ```jsx import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { checkSetup, fetchMe, logout as apiLogout } from './api.js'; import LoginPage from './components/LoginPage.jsx'; import SetupPage from './components/SetupPage.jsx'; export const AuthContext = createContext(null); ``` Inside the `App` function, add state and effects at the top: ```jsx const [authState, setAuthState] = useState('loading'); // 'loading' | 'setup' | 'login' | 'authenticated' const [currentUser, setCurrentUser] = useState(null); useEffect(() => { (async () => { const needsSetup = await checkSetup(); if (needsSetup) { setAuthState('setup'); return; } const user = await fetchMe(); if (!user) { setAuthState('login'); return; } setCurrentUser(user); setAuthState('authenticated'); })(); }, []); // Listen for 401s from the fetch interceptor in api.js useEffect(() => { const handler = () => { setCurrentUser(null); setAuthState('login'); }; window.addEventListener('auth:expired', handler); return () => window.removeEventListener('auth:expired', handler); }, []); ``` In the `App` return, add guards at the very top of the JSX: ```jsx if (authState === 'loading') return
Loading…
; if (authState === 'setup') return setAuthState('login')} />; if (authState === 'login') return { setCurrentUser(user); setAuthState('authenticated'); }} />; ``` Wrap the existing return content in: ```jsx return ( {/* existing app JSX */} ); ``` - [ ] **Step 5: Build to check for errors** ```bash cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -10 ``` Expected: builds successfully, no type errors. - [ ] **Step 6: Commit** ```bash cd ~/personal/archivr git add frontend/src/App.jsx frontend/src/api.js \ frontend/src/components/LoginPage.jsx \ frontend/src/components/SetupPage.jsx git commit -m "feat(auth): frontend login/setup pages + auth state in App.jsx" ``` --- ## Task 12: Topbar — user menu and logout button **Files:** - Modify: `frontend/src/components/Topbar.jsx` - [ ] **Step 1: Update `Topbar.jsx`** Open `frontend/src/components/Topbar.jsx`. Import `AuthContext` and `logout`: ```jsx import { useContext, useState } from 'react'; import { AuthContext } from '../App.jsx'; import { logout as apiLogout } from '../api.js'; ``` Inside the component, add: ```jsx const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}; const [loggingOut, setLoggingOut] = useState(false); async function handleLogout() { setLoggingOut(true); await apiLogout(); setCurrentUser(null); window.location.reload(); // simplest way to reset all state } ``` In the JSX, add a user menu at the end of the topbar: ```jsx {currentUser && (
{currentUser.username}
)} ``` - [ ] **Step 2: Build to check for errors** ```bash cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5 ``` Expected: clean build. - [ ] **Step 3: Commit** ```bash cd ~/personal/archivr git add frontend/src/components/Topbar.jsx git commit -m "feat(auth): user menu and logout button in Topbar" ``` --- ## Task 13: Update NEXT.md **Files:** - Modify: `NEXT.md` - [ ] **Step 1: Add Track 4 as done, renumber remaining tracks** Open `NEXT.md`. Add a new Track 4 section (done) after Track 3, following the same ~~strikethrough~~ ✅ pattern as Tracks 1 and 2. Rename the old Track 4 (Cloud backup) to Track 9 and Track 5 (Cloud storage) to Track 10. Add stub sections for new Tracks 5, 6, 7, 8 with brief descriptions and "pending" status. Use this numbering: | # | Track | Status | |---|---|---| | 1 | Generic URL capture | Done | | 2 | Web page archiving | Done | | 3 | Async capture jobs | Pending | | **4** | **Auth foundation** | **Done (this track)** | | 5 | User management | Pending | | 6 | Permissions & visibility | Pending | | 7 | Settings | Pending | | 8 | Collections UI | Pending | | 9 | Cloud backup | Pending (was 4) | | 10 | Cloud storage | Pending (was 5) | - [ ] **Step 2: Commit** ```bash git add NEXT.md git commit -m "docs: mark Track 4 auth foundation done, renumber roadmap tracks" ``` --- ## Final verification - [ ] **Run the full test suite** ```bash cd ~/personal/archivr && cargo test 2>&1 | tail -5 ``` Expected: all tests pass (count will be higher than the pre-implementation baseline due to new tests). - [ ] **Build the frontend** ```bash cd ~/personal/archivr/frontend && bun run build 2>&1 | tail -5 ``` Expected: builds cleanly.