refactor: require auth for local api

This commit is contained in:
zhom
2025-08-19 13:31:28 +04:00
parent 1e5664e3b2
commit f8a4ec3277
9 changed files with 1412 additions and 39 deletions
+1
View File
@@ -31,6 +31,7 @@
"dataclasses",
"datareporting",
"datas",
"DBAPI",
"dconf",
"devedition",
"distro",
+902 -12
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -29,6 +29,9 @@ tauri-plugin-shell = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-stronghold = "2"
directories = "6"
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["full", "sync"] }
@@ -51,6 +54,8 @@ axum = "0.8.4"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
argon2 = "0.5"
aes-gcm = "0.10"
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
+8
View File
@@ -26,5 +26,13 @@ fn main() {
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
}
// Inject vault password at build time
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
} else {
// Use default password if environment variable is not set
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
}
tauri_build::build()
}
+2 -1
View File
@@ -29,6 +29,7 @@
"macos-permissions:allow-request-microphone-permission",
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission"
"macos-permissions:allow-check-camera-permission",
"stronghold:default"
]
}
+179 -15
View File
@@ -3,14 +3,16 @@ use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{Json, Response},
routing::{delete, get, post, put},
Router,
};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpListener;
@@ -110,6 +112,19 @@ struct UpdateProxyRequest {
proxy_settings: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct DownloadBrowserRequest {
browser: String,
version: String,
}
#[derive(Debug, Serialize)]
struct DownloadBrowserResponse {
browser: String,
version: String,
status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToastPayload {
pub message: String,
@@ -118,6 +133,13 @@ pub struct ToastPayload {
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
struct RunProfileResponse {
profile_id: String,
remote_debugging_port: u16,
headless: bool,
}
pub struct ApiServer {
port: Option<u16>,
shutdown_tx: Option<mpsc::Sender<()>>,
@@ -174,13 +196,14 @@ impl ApiServer {
.map_err(|e| format!("Failed to get local address: {e}"))?
.port();
// Create router with CORS
let app = Router::new()
// Create router with CORS, authentication, and versioning
let v1_routes = Router::new()
.route("/profiles", get(get_profiles))
.route("/profiles", post(create_profile))
.route("/profiles/{id}", get(get_profile))
.route("/profiles/{id}", put(update_profile))
.route("/profiles/{id}", delete(delete_profile))
.route("/profiles/{id}/run", post(run_profile))
.route("/groups", get(get_groups).post(create_group))
.route(
"/groups/{id}",
@@ -192,6 +215,19 @@ impl ApiServer {
"/proxies/{id}",
get(get_proxy).put(update_proxy).delete(delete_proxy),
)
.route("/browsers/download", post(download_browser_api))
.route("/browsers/{browser}/versions", get(get_browser_versions))
.route(
"/browsers/{browser}/versions/{version}/downloaded",
get(check_browser_downloaded),
)
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.nest("/v1", v1_routes)
.layer(CorsLayer::permissive())
.with_state(state);
@@ -225,6 +261,41 @@ impl ApiServer {
}
}
// Authentication middleware
async fn auth_middleware(
State(state): State<ApiServerState>,
headers: HeaderMap,
request: axum::extract::Request,
next: Next,
) -> Result<Response, StatusCode> {
// Get the Authorization header
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "));
let token = match auth_header {
Some(token) => token,
None => return Err(StatusCode::UNAUTHORIZED),
};
// Get the stored token
let settings_manager = crate::settings_manager::SettingsManager::instance();
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
Ok(Some(stored_token)) => stored_token,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
// Compare tokens
if token != stored_token {
return Err(StatusCode::UNAUTHORIZED);
}
// Token is valid, continue with the request
Ok(next.run(request).await)
}
// Global API server instance
lazy_static! {
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
@@ -283,7 +354,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: false, // For now, set to false - can add running status later
is_running: profile.process_id.is_some(), // Simple check based on process_id
})
.collect();
@@ -320,7 +391,7 @@ async fn get_profile(
.and_then(|c| serde_json::to_value(c).ok()),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: false, // Simplified for now to avoid async complexity
is_running: profile.process_id.is_some(), // Simple check based on process_id
},
}))
} else {
@@ -402,7 +473,7 @@ async fn create_profile(
}
async fn update_profile(
Path(name): Path<String>,
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
@@ -411,7 +482,7 @@ async fn update_profile(
// Update profile fields
if let Some(new_name) = request.name {
if profile_manager
.rename_profile(&state.app_handle, &name, &new_name)
.rename_profile(&state.app_handle, &id, &new_name)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -420,7 +491,7 @@ async fn update_profile(
if let Some(version) = request.version {
if profile_manager
.update_profile_version(&state.app_handle, &name, &version)
.update_profile_version(&state.app_handle, &id, &version)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -429,7 +500,7 @@ async fn update_profile(
if let Some(proxy_id) = request.proxy_id {
if profile_manager
.update_profile_proxy(state.app_handle.clone(), &name, Some(proxy_id))
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
.await
.is_err()
{
@@ -443,7 +514,7 @@ async fn update_profile(
match config {
Ok(config) => {
if profile_manager
.update_camoufox_config(state.app_handle.clone(), &name, config)
.update_camoufox_config(state.app_handle.clone(), &id, config)
.await
.is_err()
{
@@ -456,7 +527,7 @@ async fn update_profile(
if let Some(group_id) = request.group_id {
if profile_manager
.assign_profiles_to_group(&state.app_handle, vec![name.clone()], Some(group_id))
.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -465,7 +536,7 @@ async fn update_profile(
if let Some(tags) = request.tags {
if profile_manager
.update_profile_tags(&state.app_handle, &name, tags)
.update_profile_tags(&state.app_handle, &id, tags)
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
@@ -480,7 +551,7 @@ async fn update_profile(
}
// Return updated profile
get_profile(Path(name), State(state)).await
get_profile(Path(id), State(state)).await
}
async fn delete_profile(
@@ -710,3 +781,96 @@ async fn delete_proxy(
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
// API Handler - Run Profile with Remote Debugging
async fn run_profile(
Path(id): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<ApiServerState>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
let headless = params
.get("headless")
.and_then(|v| v.parse::<bool>().ok())
.unwrap_or(false);
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or(StatusCode::NOT_FOUND)?;
// Generate a random port for remote debugging
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
state.app_handle.clone(),
profile.clone(),
None,
Some(remote_debugging_port),
headless,
)
.await
{
Ok(updated_profile) => Ok(Json(RunProfileResponse {
profile_id: updated_profile.id.to_string(),
remote_debugging_port,
headless,
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Download Browser
async fn download_browser_api(
State(state): State<ApiServerState>,
Json(request): Json<DownloadBrowserRequest>,
) -> Result<Json<DownloadBrowserResponse>, StatusCode> {
let browser_runner = crate::browser_runner::BrowserRunner::instance();
match browser_runner
.download_browser_impl(
state.app_handle.clone(),
request.browser.clone(),
request.version.clone(),
)
.await
{
Ok(_) => Ok(Json(DownloadBrowserResponse {
browser: request.browser,
version: request.version,
status: "downloaded".to_string(),
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Get Browser Versions
async fn get_browser_versions(
Path(browser): Path<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<String>>, StatusCode> {
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
match version_manager
.fetch_browser_versions_with_count(&browser, false)
.await
{
Ok(result) => Ok(Json(result.versions)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
// API Handler - Check if Browser is Downloaded
async fn check_browser_downloaded(
Path((browser, version)): Path<(String, String)>,
State(_state): State<ApiServerState>,
) -> Result<Json<bool>, StatusCode> {
let browser_runner = crate::browser_runner::BrowserRunner::instance();
let is_downloaded = browser_runner.is_browser_downloaded(&browser, &version);
Ok(Json(is_downloaded))
}
+2 -1
View File
@@ -1,4 +1,5 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use directories::BaseDirs;
use std::env;
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
@@ -573,7 +574,7 @@ pub fn run() {
// Start API server if enabled in settings
let app_handle_api = app.handle().clone();
tauri::async_runtime::spawn(async move {
match crate::settings_manager::get_app_settings().await {
match crate::settings_manager::get_app_settings(app_handle_api.clone()).await {
Ok(settings) => {
if settings.api_enabled {
println!("API is enabled in settings, starting API server...");
+240 -6
View File
@@ -6,6 +6,12 @@ use std::path::PathBuf;
use crate::api_client::ApiClient;
use crate::version_updater;
use aes_gcm::{
aead::{Aead, AeadCore, KeyInit, OsRng},
Aes256Gcm, Key, Nonce
};
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TableSortingSettings {
pub column: String, // Column to sort by: "name", "browser", "status"
@@ -33,6 +39,8 @@ pub struct AppSettings {
pub api_enabled: bool,
#[serde(default = "default_api_port")]
pub api_port: u16,
#[serde(default)]
pub api_token: Option<String>, // Displayed token for user to copy
}
fn default_theme() -> String {
@@ -51,6 +59,7 @@ impl Default for AppSettings {
custom_theme: None,
api_enabled: false,
api_port: 10108,
api_token: None,
}
}
}
@@ -164,22 +173,244 @@ impl SettingsManager {
// Always return false - we don't show settings on startup anymore
Ok(false)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
pub async fn generate_api_token(
&self,
app_handle: &tauri::AppHandle,
) -> Result<String, Box<dyn std::error::Error>> {
// Generate a secure random token (base64 encoded for URL safety)
let token_bytes: [u8; 32] = {
use rand::RngCore;
let mut rng = rand::rng();
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
bytes
};
use base64::{engine::general_purpose, Engine as _};
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
// Store token securely
self.store_api_token(app_handle, &token).await?;
Ok(token)
}
pub async fn store_api_token(
&self,
_app_handle: &tauri::AppHandle,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Store token in an encrypted file using Argon2 + AES-GCM
let token_file = self.get_settings_dir().join("api_token.dat");
// Create directory if it doesn't exist
if let Some(parent) = token_file.parent() {
std::fs::create_dir_all(parent)?;
}
let vault_password = Self::get_vault_password();
// Generate a random salt for Argon2
let salt = SaltString::generate(&mut OsRng);
// Use Argon2 to derive a 32-byte key from the vault password
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {}", e))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
// Take first 32 bytes for AES-256 key
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
let cipher = Aes256Gcm::new(key);
// Generate a random nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
// Encrypt the token
let ciphertext = cipher.encrypt(&nonce, token.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?;
// Create file data with header, salt, nonce, and encrypted data
let mut file_data = Vec::new();
file_data.extend_from_slice(b"DBAPI"); // 5-byte header
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
// Store salt length and salt
let salt_str = salt.as_str();
file_data.push(salt_str.len() as u8);
file_data.extend_from_slice(salt_str.as_bytes());
// Store nonce (12 bytes for AES-GCM)
file_data.extend_from_slice(&nonce);
// Store ciphertext length and ciphertext
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
file_data.extend_from_slice(&ciphertext);
std::fs::write(token_file, file_data)?;
Ok(())
}
pub async fn get_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if !token_file.exists() {
return Ok(None);
}
let file_data = std::fs::read(token_file)?;
// Validate header
if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" {
return Ok(None);
}
let version = file_data[5];
// Only support Argon2 + AES-GCM (version 2)
if version != 2 {
return Ok(None);
}
// Argon2 + AES-GCM decryption
let mut offset = 6;
// Read salt
if offset >= file_data.len() {
return Ok(None);
}
let salt_len = file_data[offset] as usize;
offset += 1;
if offset + salt_len > file_data.len() {
return Ok(None);
}
let salt_bytes = &file_data[offset..offset + salt_len];
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
offset += salt_len;
// Read nonce (12 bytes)
if offset + 12 > file_data.len() {
return Ok(None);
}
let nonce_bytes = &file_data[offset..offset + 12];
let nonce = Nonce::from_slice(nonce_bytes);
offset += 12;
// Read ciphertext
if offset + 4 > file_data.len() {
return Ok(None);
}
let ciphertext_len = u32::from_le_bytes([
file_data[offset], file_data[offset + 1], file_data[offset + 2], file_data[offset + 3]
]) as usize;
offset += 4;
if offset + ciphertext_len > file_data.len() {
return Ok(None);
}
let ciphertext = &file_data[offset..offset + ciphertext_len];
// Derive key using Argon2
let vault_password = Self::get_vault_password();
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(vault_password.as_bytes(), &salt)
.map_err(|e| format!("Argon2 key derivation failed: {}", e))?;
let hash_value = password_hash.hash.unwrap();
let hash_bytes = hash_value.as_bytes();
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
let cipher = Aes256Gcm::new(key);
// Decrypt the token
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| "Decryption failed")?;
match String::from_utf8(plaintext) {
Ok(token) => Ok(Some(token)),
Err(_) => Ok(None),
}
}
pub async fn remove_api_token(
&self,
_app_handle: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let token_file = self.get_settings_dir().join("api_token.dat");
if token_file.exists() {
std::fs::remove_file(token_file)?;
}
Ok(())
}
}
#[tauri::command]
pub async fn get_app_settings() -> Result<AppSettings, String> {
pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
manager
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))
.map_err(|e| format!("Failed to load settings: {e}"))?;
// Always load token for display purposes if it exists
settings.api_token = manager
.get_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to load API token: {e}"))?;
Ok(settings)
}
#[tauri::command]
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
pub async fn save_app_settings(
app_handle: tauri::AppHandle,
mut settings: AppSettings,
) -> Result<AppSettings, String> {
let manager = SettingsManager::instance();
// If API is being enabled ensure token is stored in Stronghold.
if settings.api_enabled {
if let Some(ref token) = settings.api_token {
manager
.store_api_token(&app_handle, token)
.await
.map_err(|e| format!("Failed to store API token: {e}"))?;
} else {
let token = manager
.generate_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to generate API token: {e}"))?;
settings.api_token = Some(token);
}
}
// If API is being disabled, remove the token
if !settings.api_enabled {
manager
.remove_api_token(&app_handle)
.await
.map_err(|e| format!("Failed to remove API token: {e}"))?;
settings.api_token = None;
}
// Do not persist api_token in settings file (kept in Stronghold). Save a copy without the token.
let mut persist_settings = settings.clone();
persist_settings.api_token = None;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
.save_settings(&persist_settings)
.map_err(|e| format!("Failed to save settings: {e}"))?;
Ok(settings)
}
#[tauri::command]
@@ -254,6 +485,8 @@ pub async fn clear_all_version_cache_and_refetch(
Ok(())
}
// No standalone stronghold commands needed; token ops handled via settings commands
// Global singleton instance
lazy_static::lazy_static! {
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
@@ -337,6 +570,7 @@ mod tests {
custom_theme: None,
api_enabled: false,
api_port: 10108,
api_token: None,
};
// Save settings
+73 -4
View File
@@ -54,6 +54,7 @@ interface AppSettings {
custom_theme?: Record<string, string>;
api_enabled: boolean;
api_port: number;
api_token?: string;
}
interface CustomThemeState {
@@ -81,6 +82,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
custom_theme: undefined,
api_enabled: false,
api_port: 10108,
api_token: undefined,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -88,6 +90,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
custom_theme: undefined,
api_enabled: false,
api_port: 10108,
api_token: undefined,
});
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
selectedThemeId: null,
@@ -298,7 +301,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
setIsSaving(true);
try {
// Update settings with current custom theme state
const settingsToSave = {
let settingsToSave: AppSettings = {
...settings,
custom_theme:
settings.theme === "custom"
@@ -306,7 +309,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
: settings.custom_theme,
};
await invoke("save_app_settings", { settings: settingsToSave });
const savedSettings = await invoke<AppSettings>("save_app_settings", {
settings: settingsToSave,
});
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
// Apply or clear custom variables only on Save
@@ -355,7 +363,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
});
// Revert the API enabled setting if start failed
settingsToSave.api_enabled = false;
await invoke("save_app_settings", { settings: settingsToSave });
const revertedSettings = await invoke<AppSettings>(
"save_app_settings",
{ settings: settingsToSave },
);
setSettings(revertedSettings);
settingsToSave = revertedSettings;
}
} else if (!isApiEnabled && wasApiEnabled) {
// Stop API server
@@ -764,8 +777,34 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<Checkbox
id="api-enabled"
checked={settings.api_enabled}
onCheckedChange={(checked: boolean) => {
onCheckedChange={async (checked: boolean) => {
updateSetting("api_enabled", checked);
try {
if (checked) {
// Ask backend to enable API and return settings with token (token stored in Stronghold)
const next = await invoke<AppSettings>(
"save_app_settings",
{
settings: { ...settings, api_enabled: true },
},
);
setSettings(next);
} else {
const next = await invoke<AppSettings>(
"save_app_settings",
{
settings: {
...settings,
api_enabled: false,
api_token: null,
},
},
);
setSettings(next);
}
} catch (e) {
console.error("Failed to toggle API:", e);
}
}}
/>
<div className="grid gap-1.5 leading-none">
@@ -787,6 +826,36 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</p>
</div>
</div>
{settings.api_enabled && settings.api_token && (
<div className="space-y-2">
<Label className="text-sm font-medium">
API Authentication Token
</Label>
<div className="flex items-center space-x-2">
<input
type="text"
value={settings.api_token}
readOnly
className="flex-1 px-3 py-2 text-sm bg-muted border rounded-md font-mono"
/>
<RippleButton
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(settings.api_token || "");
showSuccessToast("API token copied to clipboard");
}}
>
Copy
</RippleButton>
</div>
<p className="text-xs text-muted-foreground">
Include this token in the Authorization header as "Bearer{" "}
{settings.api_token}" for all API requests.
</p>
</div>
)}
</div>
{/* Advanced Section */}