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:
Jon Seager 2026-02-05 11:24:43 +00:00
parent 25705ea0c0
commit 16a4704fcf
No known key found for this signature in database
9 changed files with 757 additions and 14 deletions

View 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()
}

View file

@ -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))

View file

@ -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, &reg_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> {

View file

@ -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,

View file

@ -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
View 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 + ").");
}
}