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 auth;
|
||||
pub mod backup;
|
||||
|
|
@ -33,6 +34,7 @@ use crate::application::server::AppState;
|
|||
|
||||
use crate::presentation::web::templates::render_template;
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn app_router(state: AppState) -> axum::Router {
|
||||
let api_routes = axum::Router::new()
|
||||
// Public API routes
|
||||
|
|
@ -100,6 +102,11 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
post(tokens::create_token).get(tokens::list_tokens),
|
||||
)
|
||||
.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/restore",
|
||||
|
|
@ -110,12 +117,15 @@ pub fn app_router(state: AppState) -> axum::Router {
|
|||
.route("/register/start", post(webauthn::register_start))
|
||||
.route("/register/finish", post(webauthn::register_finish))
|
||||
.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()
|
||||
.route("/", get(home::home_page))
|
||||
.route("/login", get(auth::login_page))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/account", get(account::account_page))
|
||||
.route("/register/:token", get(webauthn::register_page))
|
||||
.route("/auth/cli-callback", get(webauthn::cli_callback_page))
|
||||
.route("/data", get(data::data_page))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use tracing::{error, info, warn};
|
|||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::*;
|
||||
|
||||
use crate::application::auth::AuthenticatedUser;
|
||||
use crate::application::routes::render_html;
|
||||
use crate::application::server::AppState;
|
||||
use crate::domain::passkey_credentials::NewPasskeyCredential;
|
||||
|
|
@ -398,6 +399,104 @@ pub(crate) async fn auth_finish(
|
|||
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 ---
|
||||
|
||||
pub(crate) async fn cli_callback_page() -> Result<Response, StatusCode> {
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ pub trait PasskeyCredentialRepository: Send + Sync {
|
|||
&self,
|
||||
credential: NewPasskeyCredential,
|
||||
) -> Result<PasskeyCredential, RepositoryError>;
|
||||
async fn get(&self, id: PasskeyCredentialId) -> Result<PasskeyCredential, RepositoryError>;
|
||||
async fn list_by_user(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,27 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
|
|||
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(
|
||||
&self,
|
||||
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">
|
||||
{% call icons::checkin("inline h-4 w-4") %}
|
||||
</a>
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<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::logout("inline h-4 w-4") %}
|
||||
</button>
|
||||
</form>
|
||||
<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">
|
||||
{% call icons::user("inline h-4 w-4") %}
|
||||
</a>
|
||||
{% 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">
|
||||
{% call icons::login("inline h-4 w-4") %}
|
||||
{% call icons::user("inline h-4 w-4") %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -38,15 +36,13 @@
|
|||
{% call icons::checkin("inline h-4 w-4") %}
|
||||
Check In
|
||||
</a>
|
||||
<form method="post" action="/logout" class="inline">
|
||||
<button type="submit" class="py-1 transition inline-flex items-center gap-1 text-stone-500 hover:text-amber-600 text-left">
|
||||
{% call icons::logout("inline h-4 w-4") %}
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
<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">
|
||||
{% call icons::user("inline h-4 w-4") %}
|
||||
Account
|
||||
</a>
|
||||
{% else %}
|
||||
<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
|
||||
</a>
|
||||
{% 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" />
|
||||
</svg>
|
||||
{% 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();
|
||||
}
|
||||
|
||||
// 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