From 58b551afac8675ee1c903b66958e5f282600e8bf Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 5 Feb 2026 19:04:52 +0000 Subject: [PATCH] refactor(routes): split account, checkin, and webauthn into api/app Split each mixed file along the api/page boundary: - account.rs: page handler + view types to app/, API handlers to api/ - checkin.rs: checkin_page to app/, submit_checkin to api/ - webauthn.rs: register_page + cli_callback_page to app/ --- src/application/routes/api/account.rs | 81 +++++++++++++++++++ src/application/routes/{ => api}/checkin.rs | 35 +-------- src/application/routes/{ => app}/account.rs | 86 +-------------------- src/application/routes/app/checkin.rs | 33 ++++++++ src/application/routes/app/webauthn.rs | 80 +++++++++++++++++++ 5 files changed, 199 insertions(+), 116 deletions(-) create mode 100644 src/application/routes/api/account.rs rename src/application/routes/{ => api}/checkin.rs (70%) rename src/application/routes/{ => app}/account.rs (66%) create mode 100644 src/application/routes/app/checkin.rs create mode 100644 src/application/routes/app/webauthn.rs diff --git a/src/application/routes/api/account.rs b/src/application/routes/api/account.rs new file mode 100644 index 0000000..e303f42 --- /dev/null +++ b/src/application/routes/api/account.rs @@ -0,0 +1,81 @@ +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use tracing::error; + +use crate::application::auth::AuthenticatedUser; +use crate::application::server::AppState; +use crate::domain::ids::PasskeyCredentialId; + +#[derive(Serialize)] +pub struct PasskeyResponse { + pub id: i64, + pub name: String, + pub created_at: DateTime, + pub last_used_at: Option>, +} + +pub(crate) async fn list_passkeys( + State(state): State, + auth_user: AuthenticatedUser, +) -> Result>, StatusCode> { + let passkeys = state + .passkey_repo + .list_by_user(auth_user.0.id) + .await + .map_err(|err| { + error!(error = %err, "failed to list passkeys"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let responses: Vec = passkeys + .into_iter() + .map(|p| PasskeyResponse { + id: i64::from(p.id), + name: p.name, + created_at: p.created_at, + last_used_at: p.last_used_at, + }) + .collect(); + + Ok(Json(responses)) +} + +pub(crate) async fn delete_passkey( + State(state): State, + auth_user: AuthenticatedUser, + Path(passkey_id): Path, +) -> Result { + // Verify the passkey belongs to the user + let passkey = state.passkey_repo.get(passkey_id).await.map_err(|err| { + error!(error = %err, %passkey_id, "failed to get passkey for deletion"); + StatusCode::NOT_FOUND + })?; + + if passkey.user_id != auth_user.0.id { + return Err(StatusCode::FORBIDDEN); + } + + // Ensure the user has more than one passkey + let all_passkeys = state + .passkey_repo + .list_by_user(auth_user.0.id) + .await + .map_err(|err| { + error!(error = %err, "failed to list passkeys for deletion check"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if all_passkeys.len() <= 1 { + return Err(StatusCode::CONFLICT); + } + + state.passkey_repo.delete(passkey_id).await.map_err(|err| { + error!(error = %err, %passkey_id, "failed to delete passkey"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/application/routes/checkin.rs b/src/application/routes/api/checkin.rs similarity index 70% rename from src/application/routes/checkin.rs rename to src/application/routes/api/checkin.rs index e8006ce..dda1658 100644 --- a/src/application/routes/checkin.rs +++ b/src/application/routes/api/checkin.rs @@ -5,41 +5,12 @@ use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; use crate::application::auth::AuthenticatedUser; -use crate::application::errors::{ApiError, AppError, map_app_error}; -use crate::application::routes::render_html; -use crate::application::routes::support::{ - FlexiblePayload, PayloadSource, is_datastar_request, load_cafe_options, load_roast_options, -}; +use crate::application::errors::{ApiError, AppError}; +use crate::application::routes::support::{FlexiblePayload, PayloadSource, is_datastar_request}; use crate::application::server::AppState; use crate::domain::cafes::NewCafe; use crate::domain::cups::NewCup; use crate::domain::ids::{CafeId, RoastId}; -use crate::presentation::web::templates::CheckInTemplate; -use tracing::info; - -#[tracing::instrument(skip(state, cookies))] -pub(crate) async fn checkin_page( - State(state): State, - cookies: tower_cookies::Cookies, -) -> Result { - let is_authenticated = super::is_authenticated(&state, &cookies).await; - if !is_authenticated { - return Ok(Redirect::to("/login").into_response()); - } - - let roast_options = load_roast_options(&state).await.map_err(map_app_error)?; - let cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?; - - let template = CheckInTemplate { - nav_active: "checkin", - is_authenticated: true, - version_info: &crate::VERSION_INFO, - roast_options, - cafe_options, - }; - - render_html(template).map(IntoResponse::into_response) -} #[derive(Debug, Deserialize)] pub(crate) struct CheckInSubmission { @@ -116,8 +87,6 @@ pub(crate) async fn submit_checkin( .await .map_err(AppError::from)?; - info!(cup_id = %cup.id, %cafe_id, "check-in recorded"); - if is_datastar_request(&headers) { crate::application::routes::support::render_signals_json(&[]).map_err(ApiError::from) } else if matches!(source, PayloadSource::Form) { diff --git a/src/application/routes/account.rs b/src/application/routes/app/account.rs similarity index 66% rename from src/application/routes/account.rs rename to src/application/routes/app/account.rs index 5afa2b7..4a041ef 100644 --- a/src/application/routes/account.rs +++ b/src/application/routes/app/account.rs @@ -1,19 +1,14 @@ use askama::Template; -use axum::Json; -use axum::extract::{Path, State}; +use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; use chrono::{DateTime, Utc}; use serde::Serialize; use tower_cookies::Cookies; -use tracing::{error, info, warn}; +use tracing::{error, warn}; -use crate::application::auth::AuthenticatedUser; use crate::application::routes::render_html; use crate::application::server::AppState; -use crate::domain::ids::PasskeyCredentialId; - -use super::auth::is_authenticated; // --- View types --- @@ -86,7 +81,7 @@ pub(crate) async fn account_page( State(state): State, cookies: Cookies, ) -> Result { - if !is_authenticated(&state, &cookies).await { + if !crate::application::routes::is_authenticated(&state, &cookies).await { return Ok(Redirect::to("/login").into_response()); } @@ -155,81 +150,6 @@ pub(crate) async fn account_page( render_html(template).map(IntoResponse::into_response) } -// --- Passkey API --- - -#[derive(Serialize)] -pub struct PasskeyResponse { - pub id: i64, - pub name: String, - pub created_at: DateTime, - pub last_used_at: Option>, -} - -pub(crate) async fn list_passkeys( - State(state): State, - auth_user: AuthenticatedUser, -) -> Result>, StatusCode> { - let passkeys = state - .passkey_repo - .list_by_user(auth_user.0.id) - .await - .map_err(|err| { - error!(error = %err, "failed to list passkeys"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - let responses: Vec = passkeys - .into_iter() - .map(|p| PasskeyResponse { - id: i64::from(p.id), - name: p.name, - created_at: p.created_at, - last_used_at: p.last_used_at, - }) - .collect(); - - Ok(Json(responses)) -} - -pub(crate) async fn delete_passkey( - State(state): State, - auth_user: AuthenticatedUser, - Path(passkey_id): Path, -) -> Result { - // Verify the passkey belongs to the user - let passkey = state.passkey_repo.get(passkey_id).await.map_err(|err| { - error!(error = %err, %passkey_id, "failed to get passkey for deletion"); - StatusCode::NOT_FOUND - })?; - - if passkey.user_id != auth_user.0.id { - return Err(StatusCode::FORBIDDEN); - } - - // Ensure the user has more than one passkey - let all_passkeys = state - .passkey_repo - .list_by_user(auth_user.0.id) - .await - .map_err(|err| { - error!(error = %err, "failed to list passkeys for deletion check"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - if all_passkeys.len() <= 1 { - return Err(StatusCode::CONFLICT); - } - - state.passkey_repo.delete(passkey_id).await.map_err(|err| { - error!(error = %err, %passkey_id, "failed to delete passkey"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - info!(%passkey_id, user_id = %auth_user.0.id, "passkey deleted"); - - Ok(StatusCode::NO_CONTENT) -} - // --- Helpers --- async fn extract_user_from_session( diff --git a/src/application/routes/app/checkin.rs b/src/application/routes/app/checkin.rs new file mode 100644 index 0000000..a4c2cf5 --- /dev/null +++ b/src/application/routes/app/checkin.rs @@ -0,0 +1,33 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; + +use crate::application::errors::map_app_error; +use crate::application::routes::render_html; +use crate::application::routes::support::{load_cafe_options, load_roast_options}; +use crate::application::server::AppState; +use crate::presentation::web::templates::CheckInTemplate; + +#[tracing::instrument(skip(state, cookies))] +pub(crate) async fn checkin_page( + State(state): State, + cookies: tower_cookies::Cookies, +) -> Result { + let is_authenticated = crate::application::routes::is_authenticated(&state, &cookies).await; + if !is_authenticated { + return Ok(Redirect::to("/login").into_response()); + } + + let roast_options = load_roast_options(&state).await.map_err(map_app_error)?; + let cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?; + + let template = CheckInTemplate { + nav_active: "checkin", + is_authenticated: true, + version_info: &crate::VERSION_INFO, + roast_options, + cafe_options, + }; + + render_html(template).map(IntoResponse::into_response) +} diff --git a/src/application/routes/app/webauthn.rs b/src/application/routes/app/webauthn.rs new file mode 100644 index 0000000..d3e3366 --- /dev/null +++ b/src/application/routes/app/webauthn.rs @@ -0,0 +1,80 @@ +use askama::Template; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use tracing::warn; + +use crate::application::routes::render_html; +use crate::application::server::AppState; +use crate::infrastructure::auth::hash_token; + +// --- Templates --- + +#[derive(Template)] +#[template(path = "pages/register.html")] +struct RegisterTemplate { + nav_active: &'static str, + is_authenticated: bool, + version_info: &'static crate::VersionInfo, + token: String, +} + +#[derive(Template)] +#[template(path = "pages/cli_callback.html")] +struct CliCallbackTemplate { + nav_active: &'static str, + is_authenticated: bool, + version_info: &'static crate::VersionInfo, + token: Option, + error: Option, +} + +// --- Registration page (bootstrap flow) --- + +#[tracing::instrument(skip(state))] +pub(crate) async fn register_page( + State(state): State, + Path(token): Path, +) -> Result { + // Validate the token exists and is usable + let token_hash = hash_token(&token); + let reg_token = state + .registration_token_repo + .get_by_token_hash(&token_hash) + .await + .map_err(|err| { + warn!( + %err, + token_hash_prefix = &token_hash[..8], + "registration token lookup failed" + ); + StatusCode::NOT_FOUND + })?; + + if !reg_token.is_valid() { + return Err(StatusCode::GONE); + } + + let template = RegisterTemplate { + nav_active: "", + is_authenticated: false, + version_info: &crate::VERSION_INFO, + token, + }; + + render_html(template).map(IntoResponse::into_response) +} + +// --- CLI callback page --- + +pub(crate) async fn cli_callback_page() -> Result { + let template = CliCallbackTemplate { + nav_active: "", + is_authenticated: false, + version_info: &crate::VERSION_INFO, + token: None, + error: None, + }; + + render_html(template).map(IntoResponse::into_response) +}