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
This commit is contained in:
parent
25705ea0c0
commit
16a4704fcf
9 changed files with 757 additions and 14 deletions
194
src/application/routes/account.rs
Normal file
194
src/application/routes/account.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod account;
|
||||||
pub mod add;
|
pub mod add;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
|
@ -33,6 +34,7 @@ use crate::application::server::AppState;
|
||||||
|
|
||||||
use crate::presentation::web::templates::render_template;
|
use crate::presentation::web::templates::render_template;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn app_router(state: AppState) -> axum::Router {
|
pub fn app_router(state: AppState) -> axum::Router {
|
||||||
let api_routes = axum::Router::new()
|
let api_routes = axum::Router::new()
|
||||||
// Public API routes
|
// Public API routes
|
||||||
|
|
@ -100,6 +102,11 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
post(tokens::create_token).get(tokens::list_tokens),
|
post(tokens::create_token).get(tokens::list_tokens),
|
||||||
)
|
)
|
||||||
.route("/tokens/:id/revoke", post(tokens::revoke_token))
|
.route("/tokens/:id/revoke", post(tokens::revoke_token))
|
||||||
|
.route("/passkeys", get(account::list_passkeys))
|
||||||
|
.route(
|
||||||
|
"/passkeys/:id",
|
||||||
|
axum::routing::delete(account::delete_passkey),
|
||||||
|
)
|
||||||
.route("/backup", get(backup::export_backup))
|
.route("/backup", get(backup::export_backup))
|
||||||
.route(
|
.route(
|
||||||
"/backup/restore",
|
"/backup/restore",
|
||||||
|
|
@ -110,12 +117,15 @@ pub fn app_router(state: AppState) -> axum::Router {
|
||||||
.route("/register/start", post(webauthn::register_start))
|
.route("/register/start", post(webauthn::register_start))
|
||||||
.route("/register/finish", post(webauthn::register_finish))
|
.route("/register/finish", post(webauthn::register_finish))
|
||||||
.route("/auth/start", get(webauthn::auth_start))
|
.route("/auth/start", get(webauthn::auth_start))
|
||||||
.route("/auth/finish", post(webauthn::auth_finish));
|
.route("/auth/finish", post(webauthn::auth_finish))
|
||||||
|
.route("/passkey/start", post(webauthn::passkey_add_start))
|
||||||
|
.route("/passkey/finish", post(webauthn::passkey_add_finish));
|
||||||
|
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/", get(home::home_page))
|
.route("/", get(home::home_page))
|
||||||
.route("/login", get(auth::login_page))
|
.route("/login", get(auth::login_page))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
|
.route("/account", get(account::account_page))
|
||||||
.route("/register/:token", get(webauthn::register_page))
|
.route("/register/:token", get(webauthn::register_page))
|
||||||
.route("/auth/cli-callback", get(webauthn::cli_callback_page))
|
.route("/auth/cli-callback", get(webauthn::cli_callback_page))
|
||||||
.route("/data", get(data::data_page))
|
.route("/data", get(data::data_page))
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use webauthn_rs::prelude::*;
|
use webauthn_rs::prelude::*;
|
||||||
|
|
||||||
|
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::passkey_credentials::NewPasskeyCredential;
|
use crate::domain::passkey_credentials::NewPasskeyCredential;
|
||||||
|
|
@ -398,6 +399,104 @@ pub(crate) async fn auth_finish(
|
||||||
Ok(Json(AuthFinishResponse { redirect: None }))
|
Ok(Json(AuthFinishResponse { redirect: None }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Add passkey to existing account ---
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PasskeyAddStartRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PasskeyAddFinishRequest {
|
||||||
|
pub challenge_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub credential: RegisterPublicKeyCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, auth_user, payload), fields(passkey_name = %payload.name))]
|
||||||
|
pub(crate) async fn passkey_add_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthenticatedUser,
|
||||||
|
Json(payload): Json<PasskeyAddStartRequest>,
|
||||||
|
) -> Result<Json<ChallengeResponse<CreationChallengeResponse>>, StatusCode> {
|
||||||
|
let user = auth_user.0;
|
||||||
|
let webauthn_uuid =
|
||||||
|
Uuid::parse_str(&user.uuid).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
// Load existing credentials to exclude (prevents re-registering same authenticator)
|
||||||
|
let existing = state
|
||||||
|
.passkey_repo
|
||||||
|
.list_by_user(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let exclude_credentials = existing
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| serde_json::from_str::<Passkey>(&c.credential_json).ok())
|
||||||
|
.map(|p| p.cred_id().clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let (ccr, reg_state) = state
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_registration(
|
||||||
|
webauthn_uuid,
|
||||||
|
&user.username,
|
||||||
|
&user.username,
|
||||||
|
Some(exclude_credentials),
|
||||||
|
)
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(error = %err, "failed to start passkey registration for existing user");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let challenge_id = generate_session_token();
|
||||||
|
state
|
||||||
|
.challenge_store
|
||||||
|
.store_registration(challenge_id.clone(), user.id, reg_state)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(ChallengeResponse {
|
||||||
|
challenge_id,
|
||||||
|
options: ccr,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, payload))]
|
||||||
|
pub(crate) async fn passkey_add_finish(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<PasskeyAddFinishRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let (user_id, reg_state) = state
|
||||||
|
.challenge_store
|
||||||
|
.take_registration(&payload.challenge_id)
|
||||||
|
.await
|
||||||
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let passkey = state
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_registration(&payload.credential, ®_state)
|
||||||
|
.map_err(|err| {
|
||||||
|
warn!(error = %err, "passkey add registration failed");
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let credential_json =
|
||||||
|
serde_json::to_string(&passkey).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let new_credential = NewPasskeyCredential::new(user_id, credential_json, payload.name);
|
||||||
|
state
|
||||||
|
.passkey_repo
|
||||||
|
.insert(new_credential)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
error!(error = %err, "failed to store new passkey credential");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(user_id = %user_id, "additional passkey registered successfully");
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
// --- CLI callback page ---
|
// --- CLI callback page ---
|
||||||
|
|
||||||
pub(crate) async fn cli_callback_page() -> Result<Response, StatusCode> {
|
pub(crate) async fn cli_callback_page() -> Result<Response, StatusCode> {
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ pub trait PasskeyCredentialRepository: Send + Sync {
|
||||||
&self,
|
&self,
|
||||||
credential: NewPasskeyCredential,
|
credential: NewPasskeyCredential,
|
||||||
) -> Result<PasskeyCredential, RepositoryError>;
|
) -> Result<PasskeyCredential, RepositoryError>;
|
||||||
|
async fn get(&self, id: PasskeyCredentialId) -> Result<PasskeyCredential, RepositoryError>;
|
||||||
async fn list_by_user(
|
async fn list_by_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,27 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
|
||||||
Ok(Self::to_domain(record))
|
Ok(Self::to_domain(record))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: PasskeyCredentialId) -> Result<PasskeyCredential, RepositoryError> {
|
||||||
|
let sql = r"
|
||||||
|
SELECT id, user_id, credential_json, name, created_at, last_used_at
|
||||||
|
FROM passkey_credentials
|
||||||
|
WHERE id = ?
|
||||||
|
";
|
||||||
|
|
||||||
|
let record = query_as::<_, PasskeyCredentialRecord>(sql)
|
||||||
|
.bind(i64::from(id))
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
sqlx::Error::RowNotFound => RepositoryError::NotFound,
|
||||||
|
err => {
|
||||||
|
RepositoryError::unexpected(format!("failed to get passkey credential: {err}"))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::to_domain(record))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_by_user(
|
async fn list_by_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
|
|
|
||||||
368
templates/account.html
Normal file
368
templates/account.html
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
{% extends "base.html" %} {% import "partials/icons.html" as icons %}
|
||||||
|
{% block title %}Brewlog · Account{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script src="/webauthn.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<!-- Passkeys -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700 mb-3">Passkeys</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{% for passkey in passkeys %}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm flex flex-col justify-between">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="block font-semibold text-amber-800">{% call icons::key("inline h-4 w-4 mr-1 text-amber-600") %}{{ passkey.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% if passkeys.len() > 1 %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md p-1 text-stone-400 transition hover:text-red-600"
|
||||||
|
onclick="deletePasskey({{ passkey.id }}, '{{ passkey.name }}')"
|
||||||
|
aria-label="Delete passkey"
|
||||||
|
>
|
||||||
|
{% call icons::delete("h-4 w-4") %}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-0.5 text-sm text-stone-500">
|
||||||
|
<p>Added {{ passkey.created_at }}</p>
|
||||||
|
{% if let Some(last_used) = passkey.last_used_at %}
|
||||||
|
<p>Last used {{ last_used }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Never used</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if passkeys.len() <= 1 %}
|
||||||
|
<p class="mt-2 text-xs text-stone-400">Add another passkey before removing your only one.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Add passkey form (hidden by default) -->
|
||||||
|
<div id="add-passkey-form" class="mt-3 hidden rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<div id="add-passkey-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Passkey Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="passkey-name"
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
placeholder="e.g. MacBook Touch ID, iPhone"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="add-passkey-btn"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="cancel-add-passkey"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-stone-500 transition hover:text-stone-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="add-passkey-loading" class="mt-3 hidden flex items-center gap-3 text-sm text-amber-700">
|
||||||
|
{% call icons::spinner("h-5 w-5") %}
|
||||||
|
Follow the prompts from your browser or device...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="show-add-passkey"
|
||||||
|
type="button"
|
||||||
|
class="mt-3 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
Add Passkey
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- API Tokens -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-amber-700 mb-3">API Tokens</h2>
|
||||||
|
|
||||||
|
{% if tokens.is_empty() %}
|
||||||
|
<p class="text-sm text-stone-500">No active tokens. Create one to use the CLI or API.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{% for token in tokens %}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 pt-4 pb-3 shadow-sm flex flex-col justify-between">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="block font-semibold text-amber-800">{{ token.name }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md p-1 text-stone-400 transition hover:text-red-600"
|
||||||
|
onclick="revokeToken({{ token.id }}, '{{ token.name }}')"
|
||||||
|
aria-label="Revoke token"
|
||||||
|
>
|
||||||
|
{% call icons::delete("h-4 w-4") %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-0.5 text-sm text-stone-500">
|
||||||
|
<p>Created {{ token.created_at }}</p>
|
||||||
|
{% if let Some(last_used) = token.last_used_at %}
|
||||||
|
<p>Last used {{ last_used }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Never used</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create token form (hidden by default) -->
|
||||||
|
<div id="create-token-form" class="mt-3 hidden rounded-lg border border-amber-300 bg-amber-100/80 p-5 shadow-sm">
|
||||||
|
<div id="create-token-error" class="mb-3 hidden rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></div>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||||
|
<span class="text-stone-700">Token Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="token-name"
|
||||||
|
required
|
||||||
|
class="input-field"
|
||||||
|
placeholder="e.g. laptop-cli, ci-server"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="create-token-btn"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="cancel-create-token"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-stone-500 transition hover:text-stone-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One-time token display (hidden by default) -->
|
||||||
|
<div id="token-created" class="mt-3 hidden rounded-lg border border-green-300 bg-green-50 p-5 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-green-800">Token created! Copy it now — you won't see it again.</p>
|
||||||
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
<code id="token-value" class="flex-1 rounded bg-white px-3 py-2 text-sm font-mono text-stone-800 border border-green-200 break-all select-all"></code>
|
||||||
|
<button
|
||||||
|
id="copy-token-btn"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-green-50 transition hover:bg-green-500"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="dismiss-token"
|
||||||
|
type="button"
|
||||||
|
class="mt-2 text-sm text-stone-500 hover:text-stone-700"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="show-create-token"
|
||||||
|
type="button"
|
||||||
|
class="mt-3 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sign Out -->
|
||||||
|
<section>
|
||||||
|
<form method="post" action="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-50 hover:text-stone-800"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
// --- Passkey management ---
|
||||||
|
const showAddPasskey = document.getElementById("show-add-passkey");
|
||||||
|
const addPasskeyForm = document.getElementById("add-passkey-form");
|
||||||
|
const cancelAddPasskey = document.getElementById("cancel-add-passkey");
|
||||||
|
const addPasskeyBtn = document.getElementById("add-passkey-btn");
|
||||||
|
const passkeyNameInput = document.getElementById("passkey-name");
|
||||||
|
const addPasskeyError = document.getElementById("add-passkey-error");
|
||||||
|
const addPasskeyLoading = document.getElementById("add-passkey-loading");
|
||||||
|
|
||||||
|
showAddPasskey.addEventListener("click", function () {
|
||||||
|
addPasskeyForm.classList.remove("hidden");
|
||||||
|
showAddPasskey.classList.add("hidden");
|
||||||
|
passkeyNameInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelAddPasskey.addEventListener("click", function () {
|
||||||
|
addPasskeyForm.classList.add("hidden");
|
||||||
|
showAddPasskey.classList.remove("hidden");
|
||||||
|
addPasskeyError.classList.add("hidden");
|
||||||
|
passkeyNameInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
addPasskeyBtn.addEventListener("click", async function () {
|
||||||
|
const name = passkeyNameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
addPasskeyError.textContent = "Please enter a name for this passkey.";
|
||||||
|
addPasskeyError.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPasskeyError.classList.add("hidden");
|
||||||
|
addPasskeyLoading.classList.remove("hidden");
|
||||||
|
addPasskeyBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addPasskey(name);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
addPasskeyError.textContent = err.message;
|
||||||
|
addPasskeyError.classList.remove("hidden");
|
||||||
|
addPasskeyLoading.classList.add("hidden");
|
||||||
|
addPasskeyBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Token management ---
|
||||||
|
const showCreateToken = document.getElementById("show-create-token");
|
||||||
|
const createTokenForm = document.getElementById("create-token-form");
|
||||||
|
const cancelCreateToken = document.getElementById("cancel-create-token");
|
||||||
|
const createTokenBtn = document.getElementById("create-token-btn");
|
||||||
|
const tokenNameInput = document.getElementById("token-name");
|
||||||
|
const createTokenError = document.getElementById("create-token-error");
|
||||||
|
const tokenCreated = document.getElementById("token-created");
|
||||||
|
const tokenValue = document.getElementById("token-value");
|
||||||
|
const copyTokenBtn = document.getElementById("copy-token-btn");
|
||||||
|
const dismissToken = document.getElementById("dismiss-token");
|
||||||
|
|
||||||
|
showCreateToken.addEventListener("click", function () {
|
||||||
|
createTokenForm.classList.remove("hidden");
|
||||||
|
showCreateToken.classList.add("hidden");
|
||||||
|
tokenNameInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelCreateToken.addEventListener("click", function () {
|
||||||
|
createTokenForm.classList.add("hidden");
|
||||||
|
showCreateToken.classList.remove("hidden");
|
||||||
|
createTokenError.classList.add("hidden");
|
||||||
|
tokenNameInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
createTokenBtn.addEventListener("click", async function () {
|
||||||
|
const name = tokenNameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
createTokenError.textContent = "Please enter a name for this token.";
|
||||||
|
createTokenError.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createTokenError.classList.add("hidden");
|
||||||
|
createTokenBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/tokens", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to create token (HTTP " + response.status + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Show the one-time token value
|
||||||
|
createTokenForm.classList.add("hidden");
|
||||||
|
tokenValue.textContent = data.token;
|
||||||
|
tokenCreated.classList.remove("hidden");
|
||||||
|
} catch (err) {
|
||||||
|
createTokenError.textContent = err.message;
|
||||||
|
createTokenError.classList.remove("hidden");
|
||||||
|
createTokenBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyTokenBtn.addEventListener("click", function () {
|
||||||
|
const token = tokenValue.textContent;
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(token).then(function () {
|
||||||
|
copyTokenBtn.textContent = "Copied!";
|
||||||
|
setTimeout(function () {
|
||||||
|
copyTokenBtn.textContent = "Copy";
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: select the text
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(tokenValue);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dismissToken.addEventListener("click", function () {
|
||||||
|
tokenCreated.classList.add("hidden");
|
||||||
|
showCreateToken.classList.remove("hidden");
|
||||||
|
tokenNameInput.value = "";
|
||||||
|
createTokenBtn.disabled = false;
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Global functions for inline onclick handlers ---
|
||||||
|
|
||||||
|
async function deletePasskey(id, name) {
|
||||||
|
if (!confirm('Delete passkey "' + name + '"? This cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/passkeys/" + id, { method: "DELETE" });
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else if (response.status === 409) {
|
||||||
|
alert("Cannot delete your only passkey. Add another passkey first.");
|
||||||
|
} else {
|
||||||
|
alert("Failed to delete passkey.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to delete passkey: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeToken(id, name) {
|
||||||
|
if (!confirm('Revoke token "' + name + '"? This cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/tokens/" + id + "/revoke", { method: "POST" });
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert("Failed to revoke token.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to revoke token: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -11,14 +11,12 @@
|
||||||
<a class="border-b-2 pb-1 transition {% if nav_active == "checkin" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/check-in" aria-label="Check In">
|
<a class="border-b-2 pb-1 transition {% if nav_active == "checkin" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/check-in" aria-label="Check In">
|
||||||
{% call icons::checkin("inline h-4 w-4") %}
|
{% call icons::checkin("inline h-4 w-4") %}
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/logout" class="inline">
|
<a class="border-b-2 pb-1 transition {% if nav_active == "account" %}text-amber-700 border-amber-500{% else %}text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400{% endif %}" href="/account" aria-label="Account">
|
||||||
<button type="submit" class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400" aria-label="Logout">
|
{% call icons::user("inline h-4 w-4") %}
|
||||||
{% call icons::logout("inline h-4 w-4") %}
|
</a>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400" href="/login" aria-label="Login">
|
<a class="border-b-2 pb-1 transition text-stone-500 border-transparent hover:text-amber-600 hover:border-amber-400" href="/login" aria-label="Login">
|
||||||
{% call icons::login("inline h-4 w-4") %}
|
{% call icons::user("inline h-4 w-4") %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,15 +36,13 @@
|
||||||
{% call icons::checkin("inline h-4 w-4") %}
|
{% call icons::checkin("inline h-4 w-4") %}
|
||||||
Check In
|
Check In
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/logout" class="inline">
|
<a class="py-1 transition inline-flex items-center gap-1 {% if nav_active == "account" %}text-amber-700 font-medium{% else %}text-stone-500 hover:text-amber-600{% endif %}" href="/account">
|
||||||
<button type="submit" class="py-1 transition inline-flex items-center gap-1 text-stone-500 hover:text-amber-600 text-left">
|
{% call icons::user("inline h-4 w-4") %}
|
||||||
{% call icons::logout("inline h-4 w-4") %}
|
Account
|
||||||
Logout
|
</a>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="py-1 transition inline-flex items-center gap-1 text-stone-500 hover:text-amber-600" href="/login">
|
<a class="py-1 transition inline-flex items-center gap-1 text-stone-500 hover:text-amber-600" href="/login">
|
||||||
{% call icons::login("inline h-4 w-4") %}
|
{% call icons::user("inline h-4 w-4") %}
|
||||||
Login
|
Login
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -165,3 +165,22 @@
|
||||||
<path fill-rule="evenodd" d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M14.78 11.78a.75.75 0 0 1-1.06 0L10 8.06l-3.72 3.72a.75.75 0 1 1-1.06-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06Z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro user(class) %}
|
||||||
|
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro clipboard(class) %}
|
||||||
|
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M15.988 3.012A2.25 2.25 0 0 1 18 5.25v6.5A2.25 2.25 0 0 1 15.75 14H13.5V7A2.5 2.5 0 0 0 11 4.5H8.128a2.252 2.252 0 0 1 1.884-1.488A2.25 2.25 0 0 1 12.25 1h1.5a2.25 2.25 0 0 1 2.238 2.012ZM11.5 3.25a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 .75.75v.25h-3v-.25Z" clip-rule="evenodd" />
|
||||||
|
<path fill-rule="evenodd" d="M2 7a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7Zm2 3.25a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Zm0 3.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro key(class) %}
|
||||||
|
<svg class="{{ class }}" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8 7a5 5 0 1 1 3.61 4.804l-1.903 1.903A1 1 0 0 1 9 14H8v1a1 1 0 0 1-1 1H6v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-2a1 1 0 0 1 .293-.707L8.196 8.39A5.002 5.002 0 0 1 8 7Zm5-3a.75.75 0 0 0 0 1.5A1.5 1.5 0 0 1 14.5 7 .75.75 0 0 0 16 7a3 3 0 0 0-3-3Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|
|
||||||
|
|
@ -145,3 +145,38 @@ async function startPasskeyAuthentication(queryParams) {
|
||||||
|
|
||||||
return finishResponse.json();
|
return finishResponse.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a passkey to an existing authenticated account
|
||||||
|
async function addPasskey(name) {
|
||||||
|
// 1. Get challenge from server
|
||||||
|
const startResponse = await fetch("/api/v1/webauthn/passkey/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startResponse.ok) {
|
||||||
|
throw new Error("Failed to start passkey registration (HTTP " + startResponse.status + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { challenge_id, options } = await startResponse.json();
|
||||||
|
|
||||||
|
// 2. Create credential via browser WebAuthn API
|
||||||
|
const creationOptions = prepareCreationOptions(options);
|
||||||
|
const credential = await navigator.credentials.create(creationOptions);
|
||||||
|
|
||||||
|
// 3. Send credential to server
|
||||||
|
const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge_id,
|
||||||
|
name,
|
||||||
|
credential: serializeRegistrationCredential(credential),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finishResponse.ok) {
|
||||||
|
throw new Error("Failed to complete passkey registration (HTTP " + finishResponse.status + ").");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue