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/
This commit is contained in:
Jon Seager 2026-02-05 19:04:52 +00:00
parent bb8fff2630
commit 58b551afac
No known key found for this signature in database
5 changed files with 199 additions and 116 deletions

View file

@ -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<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
pub(crate) async fn list_passkeys(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
) -> Result<Json<Vec<PasskeyResponse>>, 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<PasskeyResponse> = 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<AppState>,
auth_user: AuthenticatedUser,
Path(passkey_id): Path<PasskeyCredentialId>,
) -> Result<StatusCode, StatusCode> {
// 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)
}

View file

@ -5,41 +5,12 @@ use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError};
use crate::application::routes::render_html; use crate::application::routes::support::{FlexiblePayload, PayloadSource, is_datastar_request};
use crate::application::routes::support::{
FlexiblePayload, PayloadSource, is_datastar_request, load_cafe_options, load_roast_options,
};
use crate::application::server::AppState; use crate::application::server::AppState;
use crate::domain::cafes::NewCafe; use crate::domain::cafes::NewCafe;
use crate::domain::cups::NewCup; use crate::domain::cups::NewCup;
use crate::domain::ids::{CafeId, RoastId}; 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<AppState>,
cookies: tower_cookies::Cookies,
) -> Result<Response, StatusCode> {
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)] #[derive(Debug, Deserialize)]
pub(crate) struct CheckInSubmission { pub(crate) struct CheckInSubmission {
@ -116,8 +87,6 @@ pub(crate) async fn submit_checkin(
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
info!(cup_id = %cup.id, %cafe_id, "check-in recorded");
if is_datastar_request(&headers) { if is_datastar_request(&headers) {
crate::application::routes::support::render_signals_json(&[]).map_err(ApiError::from) crate::application::routes::support::render_signals_json(&[]).map_err(ApiError::from)
} else if matches!(source, PayloadSource::Form) { } else if matches!(source, PayloadSource::Form) {

View file

@ -1,19 +1,14 @@
use askama::Template; use askama::Template;
use axum::Json; use axum::extract::State;
use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use tower_cookies::Cookies; 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::routes::render_html;
use crate::application::server::AppState; use crate::application::server::AppState;
use crate::domain::ids::PasskeyCredentialId;
use super::auth::is_authenticated;
// --- View types --- // --- View types ---
@ -86,7 +81,7 @@ pub(crate) async fn account_page(
State(state): State<AppState>, State(state): State<AppState>,
cookies: Cookies, cookies: Cookies,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
if !is_authenticated(&state, &cookies).await { if !crate::application::routes::is_authenticated(&state, &cookies).await {
return Ok(Redirect::to("/login").into_response()); return Ok(Redirect::to("/login").into_response());
} }
@ -155,81 +150,6 @@ pub(crate) async fn account_page(
render_html(template).map(IntoResponse::into_response) render_html(template).map(IntoResponse::into_response)
} }
// --- Passkey API ---
#[derive(Serialize)]
pub struct PasskeyResponse {
pub id: i64,
pub name: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
pub(crate) async fn list_passkeys(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
) -> Result<Json<Vec<PasskeyResponse>>, 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<PasskeyResponse> = 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<AppState>,
auth_user: AuthenticatedUser,
Path(passkey_id): Path<PasskeyCredentialId>,
) -> Result<StatusCode, StatusCode> {
// 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 --- // --- Helpers ---
async fn extract_user_from_session( async fn extract_user_from_session(

View file

@ -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<AppState>,
cookies: tower_cookies::Cookies,
) -> Result<Response, StatusCode> {
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)
}

View file

@ -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<String>,
error: Option<String>,
}
// --- Registration page (bootstrap flow) ---
#[tracing::instrument(skip(state))]
pub(crate) async fn register_page(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Response, StatusCode> {
// 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<Response, StatusCode> {
let template = CliCallbackTemplate {
nav_active: "",
is_authenticated: false,
version_info: &crate::VERSION_INFO,
token: None,
error: None,
};
render_html(template).map(IntoResponse::into_response)
}