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:
parent
325e4b611f
commit
4e4ac30332
2 changed files with 84 additions and 86 deletions
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue