brewlog/src/application/routes/account.rs
Jon Seager 16a4704fcf
feat(account): add account page with passkey and token management
- Add /account page with passkey list, token list, and sign-out
- Replace nav login/logout buttons with user icon linking to /account or /login
- Add passkey management: add new passkeys (WebAuthn ceremony), delete with confirmation
- Add token management: create, copy one-time value, revoke with confirmation
- Add user/key/clipboard icon macros
- Add PasskeyCredentialRepository::get() for ownership verification
- Add WebAuthn passkey/start and passkey/finish endpoints for adding passkeys
2026-02-05 11:24:43 +00:00

194 lines
4.8 KiB
Rust

use askama::Template;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use chrono::{DateTime, Utc};
use serde::Serialize;
use tower_cookies::Cookies;
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 ---
#[derive(Serialize)]
pub struct PasskeyView {
pub id: i64,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
#[derive(Serialize)]
pub struct TokenView {
pub id: i64,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
fn format_date(dt: DateTime<Utc>) -> String {
dt.format("%Y-%m-%d").to_string()
}
// --- Templates ---
#[derive(Template)]
#[template(path = "account.html")]
struct AccountTemplate {
nav_active: &'static str,
is_authenticated: bool,
passkeys: Vec<PasskeyView>,
tokens: Vec<TokenView>,
}
// --- Page handler ---
pub(crate) async fn account_page(
State(state): State<AppState>,
cookies: Cookies,
) -> Result<Response, StatusCode> {
if !is_authenticated(&state, &cookies).await {
return Ok(Redirect::to("/login").into_response());
}
// We need the authenticated user for repo queries — re-extract from session
let auth_user = extract_user_from_session(&state, &cookies)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let passkeys = state
.passkey_repo
.list_by_user(auth_user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.into_iter()
.map(|p| PasskeyView {
id: i64::from(p.id),
name: p.name,
created_at: format_date(p.created_at),
last_used_at: p.last_used_at.map(format_date),
})
.collect();
let tokens = state
.token_repo
.list_by_user(auth_user.id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.into_iter()
.filter(crate::domain::tokens::Token::is_active)
.map(|t| TokenView {
id: i64::from(t.id),
name: t.name,
created_at: format_date(t.created_at),
last_used_at: t.last_used_at.map(format_date),
})
.collect();
let template = AccountTemplate {
nav_active: "account",
is_authenticated: true,
passkeys,
tokens,
};
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(|_| 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(|_| 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(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if all_passkeys.len() <= 1 {
return Err(StatusCode::CONFLICT);
}
state
.passkey_repo
.delete(passkey_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
// --- Helpers ---
async fn extract_user_from_session(
state: &AppState,
cookies: &Cookies,
) -> Option<crate::domain::users::User> {
let cookie = cookies.get("brewlog_session")?;
let session_token = cookie.value();
let session_token_hash = crate::infrastructure::auth::hash_token(session_token);
let session = state
.session_repo
.get_by_token_hash(&session_token_hash)
.await
.ok()?;
if session.is_expired() {
return None;
}
state.user_repo.get(session.user_id).await.ok()
}