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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.125.1"
|
||||
version = "0.126.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.125.1"
|
||||
version = "0.126.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user