refactor(account): convert token creation from imperative JS to Datastar

Replace fetch/classList DOM manipulation with Datastar signals and @post.
Server now returns signal patches for Datastar requests while preserving
the JSON API for CLI usage.
This commit is contained in:
Jon Seager 2026-02-05 21:58:57 +00:00
parent 325e4b611f
commit 4e4ac30332
No known key found for this signature in database
2 changed files with 84 additions and 86 deletions

View file

@ -1,11 +1,16 @@
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError};
use crate::application::routes::support::{
FlexiblePayload, is_datastar_request, render_signals_json,
};
use crate::application::server::AppState;
use crate::domain::ids::{TokenId, UserId};
use crate::domain::tokens::{NewToken, Token};
@ -46,15 +51,18 @@ impl From<Token> for TokenResponse {
}
}
#[tracing::instrument(skip(state, auth_user, payload), fields(token_name = %payload.name))]
#[tracing::instrument(skip(state, auth_user, headers, payload))]
pub async fn create_token(
State(state): State<AppState>,
auth_user: AuthenticatedUser,
Json(payload): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, StatusCode> {
headers: HeaderMap,
payload: FlexiblePayload<CreateTokenRequest>,
) -> Result<Response, ApiError> {
let (payload, _source) = payload.into_parts();
let token_value = generate_token().map_err(|err| {
error!(error = %err, "failed to generate token");
StatusCode::INTERNAL_SERVER_ERROR
ApiError::from(AppError::unexpected("failed to generate token"))
})?;
let token_hash_value = hash_token(&token_value);
@ -62,16 +70,28 @@ pub async fn create_token(
let stored_token = state.token_repo.insert(new_token).await.map_err(|err| {
error!(error = %err, "failed to store token");
StatusCode::INTERNAL_SERVER_ERROR
ApiError::from(AppError::unexpected("failed to store token"))
})?;
info!(token_id = %stored_token.id, token_name = %stored_token.name, user_id = %auth_user.0.id, "API token created");
if is_datastar_request(&headers) {
use serde_json::Value;
let signals = vec![
("_token-value", Value::String(token_value)),
("_token-created", Value::Bool(true)),
("_creating-token", Value::Bool(false)),
("_show-token-form", Value::Bool(false)),
];
render_signals_json(&signals).map_err(ApiError::from)
} else {
Ok(Json(CreateTokenResponse {
id: stored_token.id,
name: stored_token.name,
token: token_value,
}))
})
.into_response())
}
}
#[tracing::instrument(skip(state, auth_user))]

View file

@ -90,7 +90,13 @@
</section>
<!-- API Tokens -->
<section data-signals:_show-token-form="false">
<section
data-signals:_show-token-form="false"
data-signals:_creating-token="false"
data-signals:_token-created="false"
data-signals:_token-value="''"
data-signals:_token-error="''"
>
<h2 class="text-lg font-semibold text-accent mb-3">API Tokens</h2>
{% if tokens.is_empty() %}
@ -125,44 +131,53 @@
</div>
{% endif %}
<!-- Create token form (hidden by default) -->
<div id="create-token-form" class="mt-3 rounded-lg border bg-surface p-5"
data-show="$_showTokenForm" style="display: none">
<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>
<!-- Create token form -->
<div class="mt-3 rounded-lg border bg-surface p-5"
data-show="$_showTokenForm && !$_tokenCreated" style="display: none">
<p data-show="$_tokenError" data-text="$_tokenError" style="display: none"
class="mb-3 rounded-md bg-red-100 border border-red-300 p-2 text-sm text-red-800"></p>
<form
data-on:submit="$_creatingToken = true; $_tokenError = ''; @post('/api/v1/tokens', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_creatingToken) return;
if (evt.detail.type === 'finished') { $_creatingToken = false }
else if (evt.detail.type === 'error') { $_creatingToken = false; $_tokenError = 'Failed to create token.' }"
>
<div class="flex items-end gap-3">
<label class="flex-1 flex flex-col gap-1 text-sm">
<span class="text-text">Token Name</span>
<input
type="text"
id="token-name"
name="name"
required
class="input-field"
placeholder="e.g. laptop-cli, ci-server"
/>
</label>
<button
id="create-token-btn"
type="button"
onclick="createToken()"
type="submit"
class="shrink-0 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
data-attr:disabled="$_creatingToken"
>
Create
</button>
<button
type="button"
data-on:click="$_showTokenForm = false; document.getElementById('create-token-error').classList.add('hidden'); document.getElementById('token-name').value = ''"
data-on:click="$_showTokenForm = false; $_tokenError = ''"
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text"
>
Cancel
</button>
</div>
</form>
</div>
<!-- One-time token display (hidden by default) -->
<div id="token-created" class="mt-3 hidden rounded-lg border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-5">
<!-- One-time token display -->
<div data-show="$_tokenCreated" style="display: none"
class="mt-3 rounded-lg border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/40 p-5">
<p class="text-sm font-medium text-green-800 dark:text-green-300">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-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"></code>
<code id="token-value" data-text="$_tokenValue"
class="flex-1 rounded bg-surface px-3 py-2 text-sm font-mono text-text border border-green-200 dark:border-green-800 break-all select-all"></code>
<button
type="button"
onclick="copyToken(this)"
@ -182,8 +197,8 @@
<button
type="button"
data-show="!$_showTokenForm"
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
data-show="!$_showTokenForm && !$_tokenCreated"
data-on:click="$_showTokenForm = true"
class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover"
>
Create Token
@ -270,44 +285,7 @@
}
};
// --- Token form ---
const createToken = async () => {
const name = document.getElementById("token-name").value.trim();
const errorEl = document.getElementById("create-token-error");
const btn = document.getElementById("create-token-btn");
if (!name) {
errorEl.textContent = "Please enter a name for this token.";
errorEl.classList.remove("hidden");
return;
}
errorEl.classList.add("hidden");
btn.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();
document.getElementById("create-token-form").style.display = "none";
document.getElementById("token-value").textContent = data.token;
document.getElementById("token-created").classList.remove("hidden");
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove("hidden");
btn.disabled = false;
}
};
// --- Token ---
const copyToken = (btn) => {
const token = document.getElementById("token-value").textContent;