- 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
194 lines
4.8 KiB
Rust
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()
|
|
}
|