feat: bot API token management page

The account page pointed users at a "bot-token list" that didn't exist: there
was no GET endpoint, no UI route, and the client type lacked expiry. Add
GET /v1/auth/tokens (caller-scoped, token_hash never serialised), extend the
auth client with listTokens/expires_at and createToken expiry, and add a
/profile/tokens page to list, create (revealing the raw bearer once), and
revoke tokens. Link the account-page copy to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 14:55:24 +02:00
parent 32d0a7e13b
commit 1ed1a134ea
13 changed files with 561 additions and 8 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.125.1"
version = "0.126.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.125.1"
version = "0.126.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -38,7 +38,7 @@ pub fn routes() -> Router<AppState> {
"/auth/me/preferences",
get(get_preferences).patch(update_preferences),
)
.route("/auth/tokens", post(create_token))
.route("/auth/tokens", get(list_tokens).post(create_token))
.route("/auth/tokens/:id", delete(delete_token))
}
@@ -299,6 +299,22 @@ async fn update_preferences(
Ok(Json(saved))
}
/// `GET /auth/tokens` — the caller's bot tokens (newest first). The raw bearer
/// is only ever shown once at creation, so this list carries just the metadata
/// (name, created/last-used, expiry); `token_hash` is `#[serde(skip)]`.
#[derive(Debug, Serialize)]
struct TokenListResponse {
items: Vec<ApiToken>,
}
async fn list_tokens(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> AppResult<Json<TokenListResponse>> {
let items = repo::api_token::list_for_user(&state.db, user.id).await?;
Ok(Json(TokenListResponse { items }))
}
async fn create_token(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,

View File

@@ -32,6 +32,23 @@ pub async fn create(
Ok(row)
}
/// The caller's tokens, newest first. `token_hash` is `#[serde(skip)]` on the
/// domain type, so returning the full row never leaks the secret.
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<ApiToken>> {
let rows = sqlx::query_as::<_, ApiToken>(
r#"
SELECT id, user_id, name, token_hash, created_at, last_used_at, expires_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<ApiToken>> {
let row = sqlx::query_as::<_, ApiToken>(
r#"

View File

@@ -541,6 +541,62 @@ async fn create_and_use_bot_token(pool: PgPool) {
assert_eq!(resp.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_tokens_returns_callers_tokens_scoped_and_without_hash(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Mint two tokens for this user, one with an expiry.
for body in [
json!({ "name": "no-expiry" }),
json!({ "name": "expiring", "expires_in_days": 30 }),
] {
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
body,
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
// A second user's token must NOT appear in the first user's list.
let (_, other) = common::register_user(&h.app).await;
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
json!({ "name": "someone-elses" }),
&other,
))
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/tokens", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 2, "only the caller's two tokens");
let names: Vec<&str> = items.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"no-expiry") && names.contains(&"expiring"));
// Raw secret / hash must never appear, but expiry metadata must.
for t in items {
assert!(t.get("token_hash").is_none(), "token_hash must be absent");
assert!(t.get("bearer").is_none(), "raw bearer only shown at creation");
assert!(t.get("expires_at").is_some(), "expiry metadata present");
}
}
#[sqlx::test(migrations = "./migrations")]
async fn bot_token_with_future_expiry_authenticates(pool: PgPool) {
// A token minted with expires_in_days is still active before its