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:
parent
bb8fff2630
commit
58b551afac
5 changed files with 199 additions and 116 deletions
81
src/application/routes/api/account.rs
Normal file
81
src/application/routes/api/account.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -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(
|
||||||
33
src/application/routes/app/checkin.rs
Normal file
33
src/application/routes/app/checkin.rs
Normal 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)
|
||||||
|
}
|
||||||
80
src/application/routes/app/webauthn.rs
Normal file
80
src/application/routes/app/webauthn.rs
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue