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::Json;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::application::auth::AuthenticatedUser;
|
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::application::server::AppState;
|
||||||
use crate::domain::ids::{TokenId, UserId};
|
use crate::domain::ids::{TokenId, UserId};
|
||||||
use crate::domain::tokens::{NewToken, Token};
|
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(
|
pub async fn create_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthenticatedUser,
|
auth_user: AuthenticatedUser,
|
||||||
Json(payload): Json<CreateTokenRequest>,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<CreateTokenResponse>, StatusCode> {
|
payload: FlexiblePayload<CreateTokenRequest>,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
let (payload, _source) = payload.into_parts();
|
||||||
|
|
||||||
let token_value = generate_token().map_err(|err| {
|
let token_value = generate_token().map_err(|err| {
|
||||||
error!(error = %err, "failed to generate token");
|
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);
|
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| {
|
let stored_token = state.token_repo.insert(new_token).await.map_err(|err| {
|
||||||
error!(error = %err, "failed to store token");
|
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");
|
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 {
|
Ok(Json(CreateTokenResponse {
|
||||||
id: stored_token.id,
|
id: stored_token.id,
|
||||||
name: stored_token.name,
|
name: stored_token.name,
|
||||||
token: token_value,
|
token: token_value,
|
||||||
}))
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, auth_user))]
|
#[tracing::instrument(skip(state, auth_user))]
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,13 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- API Tokens -->
|
<!-- 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>
|
<h2 class="text-lg font-semibold text-accent mb-3">API Tokens</h2>
|
||||||
|
|
||||||
{% if tokens.is_empty() %}
|
{% if tokens.is_empty() %}
|
||||||
|
|
@ -125,44 +131,53 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Create token form (hidden by default) -->
|
<!-- Create token form -->
|
||||||
<div id="create-token-form" class="mt-3 rounded-lg border bg-surface p-5"
|
<div class="mt-3 rounded-lg border bg-surface p-5"
|
||||||
data-show="$_showTokenForm" style="display: none">
|
data-show="$_showTokenForm && !$_tokenCreated" 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>
|
<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">
|
<div class="flex items-end gap-3">
|
||||||
<label class="flex-1 flex flex-col gap-1 text-sm">
|
<label class="flex-1 flex flex-col gap-1 text-sm">
|
||||||
<span class="text-text">Token Name</span>
|
<span class="text-text">Token Name</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="token-name"
|
name="name"
|
||||||
required
|
required
|
||||||
class="input-field"
|
class="input-field"
|
||||||
placeholder="e.g. laptop-cli, ci-server"
|
placeholder="e.g. laptop-cli, ci-server"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
id="create-token-btn"
|
type="submit"
|
||||||
type="button"
|
|
||||||
onclick="createToken()"
|
|
||||||
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"
|
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
|
Create
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
class="shrink-0 rounded-md px-3 py-2 text-sm font-medium text-text-muted transition hover:text-text"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- One-time token display (hidden by default) -->
|
<!-- One-time token display -->
|
||||||
<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">
|
<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>
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="copyToken(this)"
|
onclick="copyToken(this)"
|
||||||
|
|
@ -182,8 +197,8 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-show="!$_showTokenForm"
|
data-show="!$_showTokenForm && !$_tokenCreated"
|
||||||
data-on:click="$_showTokenForm = true; setTimeout(() => document.getElementById('token-name').focus(), 50)"
|
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"
|
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
|
Create Token
|
||||||
|
|
@ -270,44 +285,7 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Token form ---
|
// --- Token ---
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToken = (btn) => {
|
const copyToken = (btn) => {
|
||||||
const token = document.getElementById("token-value").textContent;
|
const token = document.getElementById("token-value").textContent;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue