From 832fbda04db861634c9aedec3e4d09319ddc3d2d Mon Sep 17 00:00:00 2001 From: DaZuo0122 <1085701449@qq.com> Date: Sun, 15 Feb 2026 12:20:00 +0800 Subject: [PATCH] Add: multi token management --- Cargo.lock | 1 + crates/sprimo-api/src/lib.rs | 78 ++++++- crates/sprimo-config/src/lib.rs | 38 +++- crates/sprimo-runtime-core/Cargo.toml | 1 + crates/sprimo-runtime-core/src/lib.rs | 280 +++++++++++++++++++++++++- crates/sprimo-tauri/src/main.rs | 75 +++++++ frontend/tauri-ui/src/main.tsx | 179 +++++++++++++++- frontend/tauri-ui/src/styles.css | 77 ++++++- 8 files changed, 713 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35d5317..6de6347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6089,6 +6089,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "uuid", ] [[package]] diff --git a/crates/sprimo-api/src/lib.rs b/crates/sprimo-api/src/lib.rs index b83ef33..d0a7a59 100644 --- a/crates/sprimo-api/src/lib.rs +++ b/crates/sprimo-api/src/lib.rs @@ -7,7 +7,7 @@ use axum::Router; use sprimo_protocol::v1::{ CommandEnvelope, ErrorResponse, FrontendStateSnapshot, HealthResponse, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::net::SocketAddr; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant}; @@ -19,7 +19,7 @@ use uuid::Uuid; #[derive(Debug, Clone)] pub struct ApiConfig { pub bind_addr: SocketAddr, - pub auth_token: String, + pub auth_tokens: Vec, pub app_version: String, pub app_build: String, pub dedupe_capacity: usize, @@ -29,9 +29,14 @@ pub struct ApiConfig { impl ApiConfig { #[must_use] pub fn default_with_token(auth_token: String) -> Self { + Self::default_with_tokens(vec![auth_token]) + } + + #[must_use] + pub fn default_with_tokens(auth_tokens: Vec) -> Self { Self { bind_addr: SocketAddr::from(([127, 0, 0, 1], 32_145)), - auth_token, + auth_tokens, app_version: env!("CARGO_PKG_VERSION").to_string(), app_build: "dev".to_string(), dedupe_capacity: 5_000, @@ -43,7 +48,7 @@ impl ApiConfig { #[derive(Debug)] pub struct ApiState { start_at: Instant, - auth_token: String, + auth_tokens: Arc>>, app_version: String, app_build: String, dedupe_capacity: usize, @@ -59,10 +64,14 @@ impl ApiState { config: ApiConfig, snapshot: Arc>, command_tx: mpsc::Sender, + auth_tokens: Arc>>, ) -> Self { + if let Ok(mut guard) = auth_tokens.write() { + *guard = config.auth_tokens.into_iter().collect(); + } Self { start_at: Instant::now(), - auth_token: config.auth_token, + auth_tokens, app_version: config.app_version, app_build: config.app_build, dedupe_capacity: config.dedupe_capacity, @@ -188,11 +197,18 @@ fn require_auth(headers: &HeaderMap, state: &ApiState) -> Result<(), ApiError> { .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .ok_or(ApiError::Unauthorized)?; - let expected = format!("Bearer {}", state.auth_token); - if raw == expected { - return Ok(()); + let Some(token) = raw.strip_prefix("Bearer ") else { + return Err(ApiError::Unauthorized); + }; + let guard = state + .auth_tokens + .read() + .map_err(|_| ApiError::Internal("auth token lock poisoned".to_string()))?; + if guard.contains(token) { + Ok(()) + } else { + Err(ApiError::Unauthorized) } - Err(ApiError::Unauthorized) } enum ApiError { @@ -240,6 +256,7 @@ mod tests { use sprimo_protocol::v1::{ CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot, }; + use std::collections::HashSet; use std::sync::{Arc, RwLock}; use tokio::sync::mpsc; use tower::ServiceExt; @@ -255,6 +272,7 @@ mod tests { ApiConfig::default_with_token("token".to_string()), snapshot, tx, + Arc::new(RwLock::new(HashSet::new())), )), rx, ) @@ -335,6 +353,48 @@ mod tests { assert_eq!(received.id, command.id); } + #[tokio::test] + async fn command_accepts_with_any_configured_token() { + let snapshot = + FrontendStateSnapshot::idle(CapabilityFlags::default()); + let snapshot = Arc::new(RwLock::new(snapshot)); + let (tx, mut rx) = mpsc::channel(8); + let state = Arc::new(ApiState::new( + ApiConfig::default_with_tokens(vec![ + "token-a".to_string(), + "token-b".to_string(), + ]), + snapshot, + tx, + Arc::new(RwLock::new(HashSet::new())), + )); + let app = app_router(state); + let command = CommandEnvelope { + id: Uuid::new_v4(), + ts_ms: 1, + command: FrontendCommand::Toast { + text: "hi".to_string(), + ttl_ms: None, + }, + }; + let body = serde_json::to_vec(&command).expect("json"); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/command") + .header("content-type", "application/json") + .header("authorization", "Bearer token-b") + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + let received = rx.recv().await.expect("forwarded command"); + assert_eq!(received.id, command.id); + } + #[tokio::test] async fn malformed_json_returns_bad_request() { let (state, _) = build_state(); diff --git a/crates/sprimo-config/src/lib.rs b/crates/sprimo-config/src/lib.rs index 62a34ad..b08188c 100644 --- a/crates/sprimo-config/src/lib.rs +++ b/crates/sprimo-config/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use thiserror::Error; +use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; #[derive(Debug, Error)] @@ -106,13 +107,41 @@ impl Default for SpriteConfig { pub struct ApiConfig { pub port: u16, pub auth_token: String, + pub auth_tokens: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ApiTokenEntry { + pub id: String, + pub label: String, + pub token: String, + pub created_at_ms: u64, +} + +impl Default for ApiTokenEntry { + fn default() -> Self { + Self { + id: Uuid::new_v4().to_string(), + label: "default".to_string(), + token: Uuid::new_v4().to_string(), + created_at_ms: now_unix_ms(), + } + } } impl Default for ApiConfig { fn default() -> Self { + let token = Uuid::new_v4().to_string(); Self { port: 32_145, - auth_token: Uuid::new_v4().to_string(), + auth_token: token.clone(), + auth_tokens: vec![ApiTokenEntry { + id: Uuid::new_v4().to_string(), + label: "default".to_string(), + token, + created_at_ms: now_unix_ms(), + }], } } } @@ -206,6 +235,13 @@ pub fn save(path: &Path, config: &AppConfig) -> Result<(), ConfigError> { Ok(()) } +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|v| v.as_millis() as u64) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::{load_or_create_at, save, AppConfig}; diff --git a/crates/sprimo-runtime-core/Cargo.toml b/crates/sprimo-runtime-core/Cargo.toml index f6082aa..a4f7050 100644 --- a/crates/sprimo-runtime-core/Cargo.toml +++ b/crates/sprimo-runtime-core/Cargo.toml @@ -14,6 +14,7 @@ sprimo-protocol = { path = "../sprimo-protocol" } thiserror.workspace = true tokio.workspace = true tracing.workspace = true +uuid.workspace = true [dev-dependencies] tempfile = "3.12.0" diff --git a/crates/sprimo-runtime-core/src/lib.rs b/crates/sprimo-runtime-core/src/lib.rs index d1d7a4d..c4de15b 100644 --- a/crates/sprimo-runtime-core/src/lib.rs +++ b/crates/sprimo-runtime-core/src/lib.rs @@ -1,8 +1,10 @@ use sprimo_api::{ApiConfig, ApiServerError, ApiState}; -use sprimo_config::{save, AppConfig, ConfigError}; +use sprimo_config::{save, ApiTokenEntry, AppConfig, ConfigError}; use sprimo_protocol::v1::{CapabilityFlags, CommandEnvelope, FrontendCommand, FrontendStateSnapshot}; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; use tokio::runtime::Runtime; use tokio::sync::{mpsc, Mutex}; @@ -19,6 +21,10 @@ pub enum RuntimeCoreError { SnapshotPoisoned, #[error("config lock poisoned")] ConfigPoisoned, + #[error("api token not found: {0}")] + ApiTokenNotFound(String), + #[error("cannot revoke the last API token")] + LastApiToken, } pub struct RuntimeCore { @@ -26,6 +32,7 @@ pub struct RuntimeCore { config: Arc>, snapshot: Arc>, api_config: ApiConfig, + auth_store: Arc>>, command_tx: mpsc::Sender, command_rx: Arc>>, } @@ -43,6 +50,7 @@ impl RuntimeCore { ) -> Result { let click_through_was_enabled = config_value.window.click_through; config_value.window.click_through = false; + let tokens_changed = normalize_api_tokens(&mut config_value); let mut snapshot = FrontendStateSnapshot::idle(capabilities); snapshot.x = config_value.window.x; snapshot.y = config_value.window.y; @@ -52,18 +60,34 @@ impl RuntimeCore { snapshot.flags.visible = config_value.window.visible; snapshot.active_sprite_pack = config_value.sprite.selected_pack.clone(); - let api_config = ApiConfig::default_with_token(config_value.api.auth_token.clone()); + let api_config = ApiConfig::default_with_tokens( + config_value + .api + .auth_tokens + .iter() + .map(|entry| entry.token.clone()) + .collect(), + ); let (command_tx, command_rx) = mpsc::channel(1_024); + let auth_store = Arc::new(RwLock::new( + config_value + .api + .auth_tokens + .iter() + .map(|entry| entry.token.clone()) + .collect(), + )); let core = Self { config_path, config: Arc::new(RwLock::new(config_value)), snapshot: Arc::new(RwLock::new(snapshot)), api_config, + auth_store, command_tx, command_rx: Arc::new(Mutex::new(command_rx)), }; - if click_through_was_enabled { + if click_through_was_enabled || tokens_changed { core.persist_config()?; } Ok(core) @@ -137,15 +161,108 @@ impl RuntimeCore { self.api_config.clone() } + pub fn list_api_tokens(&self) -> Result, RuntimeCoreError> { + let guard = self + .config + .read() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + Ok(guard.api.auth_tokens.clone()) + } + + pub fn create_api_token( + &self, + label: Option, + ) -> Result { + let entry = ApiTokenEntry { + id: uuid::Uuid::new_v4().to_string(), + label: normalize_token_label(label.as_deref().unwrap_or("")), + token: uuid::Uuid::new_v4().to_string(), + created_at_ms: now_unix_ms(), + }; + { + let mut guard = self + .config + .write() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + guard.api.auth_tokens.push(entry.clone()); + guard.api.auth_token = guard + .api + .auth_tokens + .first() + .map(|token| token.token.clone()) + .unwrap_or_default(); + } + self.persist_config()?; + self.refresh_auth_store_from_config()?; + Ok(entry) + } + + pub fn rename_api_token(&self, id: &str, label: &str) -> Result<(), RuntimeCoreError> { + let mut found = false; + { + let mut guard = self + .config + .write() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + for entry in &mut guard.api.auth_tokens { + if entry.id == id { + entry.label = normalize_token_label(label); + found = true; + break; + } + } + } + if !found { + return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string())); + } + self.persist_config()?; + Ok(()) + } + + pub fn revoke_api_token(&self, id: &str) -> Result<(), RuntimeCoreError> { + let removed = { + let mut guard = self + .config + .write() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + if guard.api.auth_tokens.len() <= 1 { + return Err(RuntimeCoreError::LastApiToken); + } + let before = guard.api.auth_tokens.len(); + guard.api.auth_tokens.retain(|entry| entry.id != id); + let after = guard.api.auth_tokens.len(); + guard.api.auth_token = guard + .api + .auth_tokens + .first() + .map(|token| token.token.clone()) + .unwrap_or_default(); + before != after + }; + if !removed { + return Err(RuntimeCoreError::ApiTokenNotFound(id.to_string())); + } + self.persist_config()?; + self.refresh_auth_store_from_config()?; + Ok(()) + } + pub fn spawn_api(&self, runtime: &Runtime) { let mut cfg = self.api_config.clone(); if let Ok(guard) = self.config.read() { cfg.bind_addr = ([127, 0, 0, 1], guard.api.port).into(); + cfg.auth_tokens = guard + .api + .auth_tokens + .iter() + .map(|entry| entry.token.clone()) + .collect(); } let state = Arc::new(ApiState::new( cfg.clone(), Arc::clone(&self.snapshot), self.command_tx.clone(), + Arc::clone(&self.auth_store), )); runtime.spawn(async move { if let Err(err) = sprimo_api::run_server(cfg, state).await { @@ -273,6 +390,27 @@ impl RuntimeCore { save(&self.config_path, &guard)?; Ok(()) } + + fn refresh_auth_store_from_config(&self) -> Result<(), RuntimeCoreError> { + let tokens = { + let guard = self + .config + .read() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + guard + .api + .auth_tokens + .iter() + .map(|entry| entry.token.clone()) + .collect::>() + }; + let mut auth = self + .auth_store + .write() + .map_err(|_| RuntimeCoreError::ConfigPoisoned)?; + *auth = tokens; + Ok(()) + } } fn default_animation_for_state(state: sprimo_protocol::v1::FrontendState) -> &'static str { @@ -297,6 +435,98 @@ fn log_api_error(err: ApiServerError) { warn!(%err, "runtime core api server exited"); } +fn normalize_api_tokens(config: &mut AppConfig) -> bool { + let mut changed = false; + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + let legacy = config.api.auth_token.trim().to_string(); + if legacy != config.api.auth_token { + config.api.auth_token = legacy.clone(); + changed = true; + } + + for mut entry in config.api.auth_tokens.clone() { + let original_id = entry.id.clone(); + let original_label = entry.label.clone(); + let original_token = entry.token.clone(); + let original_created = entry.created_at_ms; + + entry.id = entry.id.trim().to_string(); + if entry.id.is_empty() { + entry.id = uuid::Uuid::new_v4().to_string(); + } + entry.label = normalize_token_label(&entry.label); + entry.token = entry.token.trim().to_string(); + if entry.created_at_ms == 0 { + entry.created_at_ms = now_unix_ms(); + } + + let field_changed = entry.id != original_id + || entry.label != original_label + || entry.token != original_token + || entry.created_at_ms != original_created; + if field_changed { + changed = true; + } + if entry.token.is_empty() { + changed = true; + continue; + } + if !seen.insert(entry.token.clone()) { + changed = true; + continue; + } + normalized.push(entry); + } + + if normalized.is_empty() { + let token = if legacy.is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + legacy + }; + normalized.push(ApiTokenEntry { + id: uuid::Uuid::new_v4().to_string(), + label: "default".to_string(), + token, + created_at_ms: now_unix_ms(), + }); + changed = true; + } + + let mirror = normalized + .first() + .map(|entry| entry.token.clone()) + .unwrap_or_default(); + if config.api.auth_token != mirror { + config.api.auth_token = mirror; + changed = true; + } + if config.api.auth_tokens != normalized { + config.api.auth_tokens = normalized; + changed = true; + } + + changed +} + +fn normalize_token_label(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + "token".to_string() + } else { + trimmed.to_string() + } +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|v| v.as_millis() as u64) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::RuntimeCore; @@ -399,4 +629,48 @@ mod tests { 1 ); } + + #[test] + fn api_token_create_rename_revoke_roundtrip() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("config.toml"); + let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) + .expect("core init"); + + let initial_len = core.list_api_tokens().expect("tokens").len(); + let created = core + .create_api_token(Some("backend-ci".to_string())) + .expect("create token"); + let after_create = core.list_api_tokens().expect("tokens"); + assert_eq!(after_create.len(), initial_len + 1); + assert!(after_create.iter().any(|entry| entry.id == created.id)); + + core.rename_api_token(&created.id, "automation") + .expect("rename token"); + let after_rename = core.list_api_tokens().expect("tokens"); + assert!( + after_rename + .iter() + .any(|entry| entry.id == created.id && entry.label == "automation") + ); + + core.revoke_api_token(&created.id).expect("revoke token"); + let after_revoke = core.list_api_tokens().expect("tokens"); + assert_eq!(after_revoke.len(), initial_len); + assert!(!after_revoke.iter().any(|entry| entry.id == created.id)); + } + + #[test] + fn cannot_revoke_last_api_token() { + let temp = TempDir::new().expect("tempdir"); + let path = temp.path().join("config.toml"); + let core = RuntimeCore::new_with_config(path, AppConfig::default(), CapabilityFlags::default()) + .expect("core init"); + let only = core.list_api_tokens().expect("tokens"); + assert_eq!(only.len(), 1); + let err = core + .revoke_api_token(&only[0].id) + .expect_err("last token revoke must fail"); + assert!(format!("{err}").contains("last API token")); + } } diff --git a/crates/sprimo-tauri/src/main.rs b/crates/sprimo-tauri/src/main.rs index b6235d2..2666c1d 100644 --- a/crates/sprimo-tauri/src/main.rs +++ b/crates/sprimo-tauri/src/main.rs @@ -90,6 +90,14 @@ struct UiSpritePackOption { pack_id_or_path: String, } +#[derive(Debug, Clone, serde::Serialize)] +struct UiApiToken { + id: String, + label: String, + token: String, + created_at_ms: u64, +} + #[derive(Debug, Error)] enum AppError { #[error("{0}")] @@ -243,6 +251,60 @@ fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result) -> Result, String> { + let entries = state + .runtime_core + .list_api_tokens() + .map_err(|err| err.to_string())?; + Ok(entries.into_iter().map(to_ui_api_token).collect()) +} + +#[tauri::command] +fn create_api_token( + state: tauri::State<'_, AppState>, + label: Option, +) -> Result { + let entry = state + .runtime_core + .create_api_token(label) + .map_err(|err| err.to_string())?; + Ok(to_ui_api_token(entry)) +} + +#[tauri::command] +fn rename_api_token( + state: tauri::State<'_, AppState>, + id: String, + label: String, +) -> Result, String> { + state + .runtime_core + .rename_api_token(&id, &label) + .map_err(|err| err.to_string())?; + let entries = state + .runtime_core + .list_api_tokens() + .map_err(|err| err.to_string())?; + Ok(entries.into_iter().map(to_ui_api_token).collect()) +} + +#[tauri::command] +fn revoke_api_token( + state: tauri::State<'_, AppState>, + id: String, +) -> Result, String> { + state + .runtime_core + .revoke_api_token(&id) + .map_err(|err| err.to_string())?; + let entries = state + .runtime_core + .list_api_tokens() + .map_err(|err| err.to_string())?; + Ok(entries.into_iter().map(to_ui_api_token).collect()) +} + #[tauri::command] fn set_sprite_pack( app_handle: tauri::AppHandle, @@ -382,6 +444,10 @@ fn main() -> Result<(), AppError> { tauri_animation_slowdown_factor, set_tauri_animation_slowdown_factor, list_sprite_packs, + list_api_tokens, + create_api_token, + rename_api_token, + revoke_api_token, set_sprite_pack, set_scale, set_visibility, @@ -502,6 +568,15 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot { } } +fn to_ui_api_token(entry: sprimo_config::ApiTokenEntry) -> UiApiToken { + UiApiToken { + id: entry.id, + label: entry.label, + token: entry.token, + created_at_ms: entry.created_at_ms, + } +} + fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result { let sprite_packs_dir = runtime_core .config() diff --git a/frontend/tauri-ui/src/main.tsx b/frontend/tauri-ui/src/main.tsx index 50fc990..e521c59 100644 --- a/frontend/tauri-ui/src/main.tsx +++ b/frontend/tauri-ui/src/main.tsx @@ -25,6 +25,13 @@ type UiSpritePackOption = { pack_id_or_path: string; }; +type UiApiToken = { + id: string; + label: string; + token: string; + created_at_ms: number; +}; + const WINDOW_PADDING = 16; const WINDOW_WORKAREA_MARGIN = 80; const MIN_WINDOW_SIZE = 64; @@ -62,6 +69,22 @@ async function invokeSetAnimationSlowdownFactor(factor: number): Promise return invoke("set_tauri_animation_slowdown_factor", { factor }); } +async function invokeListApiTokens(): Promise { + return invoke("list_api_tokens"); +} + +async function invokeCreateApiToken(label?: string): Promise { + return invoke("create_api_token", { label }); +} + +async function invokeRenameApiToken(id: string, label: string): Promise { + return invoke("rename_api_token", { id, label }); +} + +async function invokeRevokeApiToken(id: string): Promise { + return invoke("revoke_api_token", { id }); +} + function fittedWindowSize( scale: number ): { width: number; height: number } { @@ -396,6 +419,9 @@ function MainOverlayWindow(): JSX.Element { function SettingsWindow(): JSX.Element { const [settings, setSettings] = React.useState(null); const [packs, setPacks] = React.useState([]); + const [tokens, setTokens] = React.useState([]); + const [tokenDrafts, setTokenDrafts] = React.useState>({}); + const [newTokenLabel, setNewTokenLabel] = React.useState(""); const [activePack, setActivePack] = React.useState(null); const [error, setError] = React.useState(null); const [pending, setPending] = React.useState(false); @@ -406,15 +432,23 @@ function SettingsWindow(): JSX.Element { Promise.all([ invoke("settings_snapshot"), invoke("list_sprite_packs"), - invoke("load_active_sprite_pack") + invoke("load_active_sprite_pack"), + invokeListApiTokens() ]) - .then(async ([snapshot, options, pack]) => { + .then(async ([snapshot, options, pack, authTokens]) => { if (!mounted) { return; } setSettings(snapshot); setPacks(options); setActivePack(pack); + setTokens(authTokens); + setTokenDrafts( + authTokens.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); unlisten = await listen("runtime:snapshot", (event) => { if (!mounted) { return; @@ -574,6 +608,85 @@ function SettingsWindow(): JSX.Element { [withPending] ); + const onNewTokenLabelChange = React.useCallback( + (event: React.ChangeEvent) => { + setNewTokenLabel(event.target.value); + }, + [] + ); + + const onCreateToken = React.useCallback(async () => { + const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined)); + if (created === null) { + return; + } + const refreshed = await withPending(() => invokeListApiTokens()); + if (refreshed === null) { + return; + } + setTokens(refreshed); + setTokenDrafts( + refreshed.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); + setNewTokenLabel(""); + }, [newTokenLabel, withPending]); + + const onTokenDraftChange = React.useCallback((id: string, value: string) => { + setTokenDrafts((prev) => ({ + ...prev, + [id]: value + })); + }, []); + + const onRenameToken = React.useCallback( + async (id: string) => { + const nextLabel = tokenDrafts[id] ?? ""; + const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel)); + if (refreshed === null) { + return; + } + setTokens(refreshed); + setTokenDrafts( + refreshed.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); + }, + [tokenDrafts, withPending] + ); + + const onCopyToken = React.useCallback(async (token: string) => { + try { + await navigator.clipboard.writeText(token); + } catch (err) { + setError(String(err)); + } + }, []); + + const onRevokeToken = React.useCallback( + async (id: string) => { + if (!window.confirm("Revoke this API token?")) { + return; + } + const refreshed = await withPending(() => invokeRevokeApiToken(id)); + if (refreshed === null) { + return; + } + setTokens(refreshed); + setTokenDrafts( + refreshed.reduce>((acc, token) => { + acc[token.id] = token.label; + return acc; + }, {}) + ); + }, + [withPending] + ); + return (
@@ -642,6 +755,68 @@ function SettingsWindow(): JSX.Element { /> Always on top +
+

API Tokens

+

+ Use any listed token as `Authorization: Bearer <token>`. +

+
+ + +
+
+ {tokens.map((entry) => ( +
+ + +
+ + + +
+
+ ))} +
+
)}
diff --git a/frontend/tauri-ui/src/styles.css b/frontend/tauri-ui/src/styles.css index 7dd326e..da855b4 100644 --- a/frontend/tauri-ui/src/styles.css +++ b/frontend/tauri-ui/src/styles.css @@ -85,6 +85,7 @@ dd { } .settings-root { + height: 100vh; min-height: 100vh; display: flex; align-items: stretch; @@ -92,6 +93,8 @@ dd { background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%); color: #0f172a; user-select: none; + overflow-y: auto; + overflow-x: hidden; } .settings-card { @@ -102,6 +105,7 @@ dd { display: grid; gap: 14px; align-content: start; + box-sizing: border-box; } .settings-card h1 { @@ -137,7 +141,8 @@ dd { } .field select, -.field input[type="range"] { +.field input[type="range"], +.field input[type="text"] { width: 100%; } @@ -149,6 +154,15 @@ dd { color: #0f172a; } +.field input[type="text"] { + border: 1px solid #94a3b8; + border-radius: 8px; + padding: 8px 10px; + background: #ffffff; + color: #0f172a; + box-sizing: border-box; +} + .toggle { display: flex; align-items: center; @@ -156,3 +170,64 @@ dd { font-size: 14px; color: #1e293b; } + +.token-section { + margin-top: 8px; + border-top: 1px solid #cbd5e1; + padding-top: 12px; + display: grid; + gap: 10px; +} + +.token-section h2 { + margin: 0; + font-size: 18px; +} + +.token-help { + margin: 0; + font-size: 12px; + color: #475569; +} + +.token-create { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.token-create button, +.token-actions button { + border: 1px solid #94a3b8; + border-radius: 8px; + padding: 7px 10px; + background: #ffffff; + color: #0f172a; + cursor: pointer; +} + +.token-create button:disabled, +.token-actions button:disabled { + opacity: 0.5; + cursor: default; +} + +.token-list { + display: grid; + gap: 10px; +} + +.token-item { + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px; + display: grid; + gap: 8px; + background: #f8fafc; +} + +.token-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +}