API Keys and Docs
This commit is contained in:
+12
-10
@@ -14,30 +14,32 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^7.0.1",
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.56.1",
|
"@sveltejs/kit": "^2.60.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"svelte": "^5.55.1",
|
"svelte": "^5.55.9",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.8",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.3.3",
|
||||||
"vite-plugin-top-level-await": "^1.6.0",
|
"vite-plugin-top-level-await": "^1.6.0",
|
||||||
"vite-plugin-wasm": "^3.6.0"
|
"vite-plugin-wasm": "^3.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
"@codemirror/autocomplete": "^6.20.2",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-rust": "^6.0.2",
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
"@codemirror/lint": "^6.9.5",
|
"@codemirror/lint": "^6.9.6",
|
||||||
"@codemirror/lsp-client": "^6.2.2",
|
"@codemirror/lsp-client": "^6.2.4",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.43.0",
|
||||||
"@iconify/svelte": "^5.2.1",
|
"@iconify/svelte": "^5.2.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-typst": "^0.4.0",
|
"codemirror-lang-typst": "^0.4.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"y-codemirror.next": "^0.3.5",
|
||||||
"y-websocket": "^3.0.0",
|
"y-websocket": "^3.0.0",
|
||||||
"yjs": "^13.6.30"
|
"yjs": "^13.6.30"
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ yrs-axum = "0.8"
|
|||||||
typst-assets = { version = "0.14.2", features = ["fonts"] }
|
typst-assets = { version = "0.14.2", features = ["fonts"] }
|
||||||
tokio-stream = "0.1.18"
|
tokio-stream = "0.1.18"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
sha2 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use axum_extra::extract::cookie::SignedCookieJar;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{ApiKeyView, CreateApiKeyRequest, UsagePoint},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn get_user_id(jar: &SignedCookieJar) -> Option<String> {
|
||||||
|
jar.get("session_user_id").map(|c| c.value().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_key(key: &str) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(key.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_api_key() -> String {
|
||||||
|
format!(
|
||||||
|
"td_{}{}",
|
||||||
|
Uuid::new_v4().to_string().replace("-", ""),
|
||||||
|
Uuid::new_v4().to_string().replace("-", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_keys(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
) -> Result<Json<Vec<ApiKeyView>>, (StatusCode, String)> {
|
||||||
|
let user_id = get_user_id(&jar)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let keys = sqlx::query_as::<_, ApiKeyView>(
|
||||||
|
"SELECT id, name, key_prefix, created_at, last_used_at, rate_limit FROM api_keys WHERE user_id = ? ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Json(payload): Json<CreateApiKeyRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
|
||||||
|
let user_id = get_user_id(&jar)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
if payload.name.trim().is_empty() {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Key name cannot be empty".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: Option<(i64,)> = sqlx::query_as("SELECT COUNT(*) FROM api_keys WHERE user_id = ?")
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some((count,)) = existing {
|
||||||
|
if count >= 10 {
|
||||||
|
return Err((StatusCode::CONFLICT, "Maximum of 10 API keys per account".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = generate_api_key();
|
||||||
|
let hash = hash_key(&key);
|
||||||
|
let prefix = key[..11].to_string(); // "td_" + 8 hex chars
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&payload.name)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&prefix)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"name": payload.name,
|
||||||
|
"key": key,
|
||||||
|
"prefix": prefix,
|
||||||
|
"created_at": now,
|
||||||
|
"rate_limit": 60,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_aggregate_usage(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<Json<Vec<UsagePoint>>, (StatusCode, String)> {
|
||||||
|
let user_id = get_user_id(&jar)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let period = params.get("period").map(|s| s.as_str()).unwrap_or("1week");
|
||||||
|
|
||||||
|
let usage = match period {
|
||||||
|
"1hr" => {
|
||||||
|
let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(1))
|
||||||
|
.format("%Y-%m-%d %H:%M")
|
||||||
|
.to_string();
|
||||||
|
sqlx::query_as::<_, UsagePoint>(
|
||||||
|
"SELECT minute as date, SUM(count) as count
|
||||||
|
FROM api_key_usage_detail
|
||||||
|
WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND minute >= ?
|
||||||
|
GROUP BY minute
|
||||||
|
ORDER BY minute ASC"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&cutoff)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
}
|
||||||
|
"1day" => {
|
||||||
|
let cutoff = (chrono::Utc::now() - chrono::TimeDelta::hours(24))
|
||||||
|
.format("%Y-%m-%d %H:%M")
|
||||||
|
.to_string();
|
||||||
|
sqlx::query_as::<_, UsagePoint>(
|
||||||
|
"SELECT SUBSTR(minute, 1, 13) as date, SUM(count) as count
|
||||||
|
FROM api_key_usage_detail
|
||||||
|
WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND minute >= ?
|
||||||
|
GROUP BY SUBSTR(minute, 1, 13)
|
||||||
|
ORDER BY date ASC"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&cutoff)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let cutoff = (chrono::Utc::now() - chrono::TimeDelta::days(6))
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string();
|
||||||
|
sqlx::query_as::<_, UsagePoint>(
|
||||||
|
"SELECT date, SUM(count) as count
|
||||||
|
FROM api_key_usage
|
||||||
|
WHERE key_id IN (SELECT id FROM api_keys WHERE user_id = ?) AND date >= ?
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&cutoff)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(usage))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn regenerate_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Path(key_id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
let user_id = get_user_id(&jar)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT name FROM api_keys WHERE id = ? AND user_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&key_id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let (name,) = row.ok_or((StatusCode::NOT_FOUND, "API key not found".to_string()))?;
|
||||||
|
|
||||||
|
let new_key = generate_api_key();
|
||||||
|
let new_hash = hash_key(&new_key);
|
||||||
|
let new_prefix = new_key[..11].to_string();
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE api_keys SET key_hash = ?, key_prefix = ?, created_at = ?, last_used_at = NULL WHERE id = ? AND user_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&new_hash)
|
||||||
|
.bind(&new_prefix)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&key_id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"id": key_id,
|
||||||
|
"name": name,
|
||||||
|
"key": new_key,
|
||||||
|
"prefix": new_prefix,
|
||||||
|
"created_at": now,
|
||||||
|
"rate_limit": 60,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: SignedCookieJar,
|
||||||
|
Path(key_id): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
let user_id = get_user_id(&jar)
|
||||||
|
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM api_keys WHERE id = ? AND user_id = ?")
|
||||||
|
.bind(&key_id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "API key not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -76,6 +76,35 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
created_at TEXT DEFAULT to_char(NOW(), 'YYYY-MM-DD HH24:MI:SS')
|
||||||
)",
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
rate_limit INTEGER NOT NULL DEFAULT 60
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_render_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
content_hash TEXT NOT NULL UNIQUE,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_key_usage (
|
||||||
|
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY(key_id, date)
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_key_usage_detail (
|
||||||
|
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||||
|
minute TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY(key_id, minute)
|
||||||
|
)",
|
||||||
];
|
];
|
||||||
|
|
||||||
for stmt in &statements {
|
for stmt in &statements {
|
||||||
|
|||||||
@@ -81,6 +81,35 @@ pub async fn init_schema(pool: &AnyPool) {
|
|||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
|
created_at TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
|
||||||
)",
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
rate_limit INTEGER NOT NULL DEFAULT 60
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_render_cache (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
content_hash TEXT NOT NULL UNIQUE,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_key_usage (
|
||||||
|
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY(key_id, date)
|
||||||
|
)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_key_usage_detail (
|
||||||
|
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||||
|
minute TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY(key_id, minute)
|
||||||
|
)",
|
||||||
];
|
];
|
||||||
|
|
||||||
for stmt in &statements {
|
for stmt in &statements {
|
||||||
|
|||||||
+15
-1
@@ -13,6 +13,7 @@ use tower_http::trace::TraceLayer;
|
|||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod admin;
|
mod admin;
|
||||||
|
mod api_keys;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod compiler;
|
mod compiler;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -21,6 +22,7 @@ mod folders;
|
|||||||
mod files;
|
mod files;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod public_api;
|
||||||
mod setup;
|
mod setup;
|
||||||
mod world;
|
mod world;
|
||||||
mod collab;
|
mod collab;
|
||||||
@@ -28,6 +30,8 @@ mod collab;
|
|||||||
use compiler::TypstCompiler;
|
use compiler::TypstCompiler;
|
||||||
use handlers::{compile_handler, export_handler, yjs_handler};
|
use handlers::{compile_handler, export_handler, yjs_handler};
|
||||||
|
|
||||||
|
pub type RateLimiterMap = Arc<Mutex<HashMap<String, (u32, std::time::Instant)>>>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub compiler: Arc<Mutex<TypstCompiler>>,
|
pub compiler: Arc<Mutex<TypstCompiler>>,
|
||||||
@@ -35,6 +39,7 @@ pub struct AppState {
|
|||||||
pub db: AnyPool,
|
pub db: AnyPool,
|
||||||
pub key: Key,
|
pub key: Key,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
|
pub rate_limiter: RateLimiterMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl axum::extract::FromRef<AppState> for Key {
|
impl axum::extract::FromRef<AppState> for Key {
|
||||||
@@ -84,6 +89,7 @@ async fn main() {
|
|||||||
db,
|
db,
|
||||||
key,
|
key,
|
||||||
registration_enabled,
|
registration_enabled,
|
||||||
|
rate_limiter: Arc::new(Mutex::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let api_routes = Router::new()
|
let api_routes = Router::new()
|
||||||
@@ -114,7 +120,14 @@ async fn main() {
|
|||||||
.route("/docs/{id}/invite", post(collab::invite_collaborator))
|
.route("/docs/{id}/invite", post(collab::invite_collaborator))
|
||||||
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
|
.route("/docs/{id}/comments", get(collab::get_comments).post(collab::add_comment))
|
||||||
.route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version))
|
.route("/docs/{id}/versions", get(collab::get_versions).post(collab::create_version))
|
||||||
.route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment));
|
.route("/comments/{id}", patch(collab::update_comment).delete(collab::delete_comment))
|
||||||
|
.route("/keys", get(api_keys::list_keys).post(api_keys::create_key))
|
||||||
|
.route("/keys/usage", get(api_keys::get_aggregate_usage))
|
||||||
|
.route("/keys/{id}", delete(api_keys::delete_key))
|
||||||
|
.route("/keys/{id}/regenerate", post(api_keys::regenerate_key));
|
||||||
|
|
||||||
|
let v1_routes = Router::new()
|
||||||
|
.route("/render", post(public_api::render_handler));
|
||||||
|
|
||||||
let yjs_routes = Router::new()
|
let yjs_routes = Router::new()
|
||||||
.route("/{id}", get(yjs_handler));
|
.route("/{id}", get(yjs_handler));
|
||||||
@@ -123,6 +136,7 @@ async fn main() {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", api_routes.layer(TraceLayer::new_for_http()))
|
.nest("/api", api_routes.layer(TraceLayer::new_for_http()))
|
||||||
|
.nest("/v1", v1_routes.layer(TraceLayer::new_for_http()))
|
||||||
.nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http()))
|
.nest("/yjs", yjs_routes.layer(TraceLayer::new_for_http()))
|
||||||
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))
|
.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(format!("{}/index.html", static_dir))))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|||||||
@@ -195,6 +195,27 @@ pub struct AdminCreateUserRequest {
|
|||||||
pub is_admin: Option<bool>,
|
pub is_admin: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct UsagePoint {
|
||||||
|
pub date: String,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct ApiKeyView {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub key_prefix: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub rate_limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CreateApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct SetupRequest {
|
pub struct SetupRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{api_keys::hash_key, AppState};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RenderRequest {
|
||||||
|
pub code: String,
|
||||||
|
pub format: String,
|
||||||
|
pub files: Option<Vec<InlineFile>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct InlineFile {
|
||||||
|
pub name: String,
|
||||||
|
pub data: String, // base64-encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_cache_key(format: &str, code: &str, files: &Option<Vec<InlineFile>>) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(format.as_bytes());
|
||||||
|
hasher.update(b"\x00");
|
||||||
|
hasher.update(code.as_bytes());
|
||||||
|
if let Some(files) = files {
|
||||||
|
let mut pairs: Vec<_> = files.iter().map(|f| (f.name.as_str(), f.data.as_str())).collect();
|
||||||
|
pairs.sort_by_key(|(n, _)| *n);
|
||||||
|
for (name, data) in pairs {
|
||||||
|
hasher.update(b"\x01");
|
||||||
|
hasher.update(name.as_bytes());
|
||||||
|
hasher.update(data.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn render_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Json(payload): Json<RenderRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Extract Bearer token
|
||||||
|
let api_key = match headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.filter(|v| v.starts_with("Bearer "))
|
||||||
|
.map(|v| v[7..].to_string())
|
||||||
|
{
|
||||||
|
Some(k) => k,
|
||||||
|
None => return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header. Use: Authorization: Bearer <api-key>").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if payload.format != "png" && payload.format != "pdf" {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid format. Must be 'png' or 'pdf'").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.code.trim().is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, "code cannot be empty").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_hash = hash_key(&api_key);
|
||||||
|
|
||||||
|
let key_row = sqlx::query_as::<_, (String, String, i64)>(
|
||||||
|
"SELECT id, user_id, rate_limit FROM api_keys WHERE key_hash = ?"
|
||||||
|
)
|
||||||
|
.bind(&key_hash)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (key_id, user_id, rate_limit) = match key_row {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("Database error: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limiting: fixed window of 60 seconds
|
||||||
|
{
|
||||||
|
let mut limiter = state.rate_limiter.lock().await;
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let window = std::time::Duration::from_secs(60);
|
||||||
|
let entry = limiter.entry(key_id.clone()).or_insert((0u32, now));
|
||||||
|
if now.duration_since(entry.1) > window {
|
||||||
|
entry.0 = 1;
|
||||||
|
entry.1 = now;
|
||||||
|
} else if entry.0 < rate_limit as u32 {
|
||||||
|
entry.0 += 1;
|
||||||
|
} else {
|
||||||
|
return (StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded. Max requests per minute reached.").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let now_str = now.to_rfc3339();
|
||||||
|
let today = now.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
let _ = sqlx::query("UPDATE api_keys SET last_used_at = ? WHERE id = ?")
|
||||||
|
.bind(&now_str)
|
||||||
|
.bind(&key_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO api_key_usage (key_id, date, count) VALUES (?, ?, 1) \
|
||||||
|
ON CONFLICT (key_id, date) DO UPDATE SET count = api_key_usage.count + 1"
|
||||||
|
)
|
||||||
|
.bind(&key_id)
|
||||||
|
.bind(&today)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let minute_str = now.format("%Y-%m-%d %H:%M").to_string();
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO api_key_usage_detail (key_id, minute, count) VALUES (?, ?, 1) \
|
||||||
|
ON CONFLICT (key_id, minute) DO UPDATE SET count = api_key_usage_detail.count + 1"
|
||||||
|
)
|
||||||
|
.bind(&key_id)
|
||||||
|
.bind(&minute_str)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let cutoff_minute = (chrono::Utc::now() - chrono::TimeDelta::hours(25))
|
||||||
|
.format("%Y-%m-%d %H:%M")
|
||||||
|
.to_string();
|
||||||
|
let _ = sqlx::query("DELETE FROM api_key_usage_detail WHERE minute < ?")
|
||||||
|
.bind(&cutoff_minute)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
let cache_key = compute_cache_key(&payload.format, &payload.code, &payload.files);
|
||||||
|
let content_type: &'static str = if payload.format == "pdf" { "application/pdf" } else { "image/png" };
|
||||||
|
|
||||||
|
if let Ok(Some((data, created_at))) = sqlx::query_as::<_, (Vec<u8>, String)>(
|
||||||
|
"SELECT data, created_at FROM api_render_cache WHERE content_hash = ? AND format = ?"
|
||||||
|
)
|
||||||
|
.bind(&cache_key)
|
||||||
|
.bind(&payload.format)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(&created_at) {
|
||||||
|
let age = chrono::Utc::now().signed_duration_since(parsed.with_timezone(&chrono::Utc));
|
||||||
|
if age.num_seconds() < 3600 {
|
||||||
|
return (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user's account files
|
||||||
|
let mut files_map: HashMap<String, Vec<u8>> = HashMap::new();
|
||||||
|
if let Ok(files) = sqlx::query_as::<_, (String, Vec<u8>)>(
|
||||||
|
"SELECT name, data FROM files WHERE owner_id = ?"
|
||||||
|
)
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
for (name, data) in files {
|
||||||
|
files_map.insert(name, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline files override account files
|
||||||
|
if let Some(inline_files) = &payload.files {
|
||||||
|
for f in inline_files {
|
||||||
|
if let Ok(decoded) = BASE64.decode(&f.data) {
|
||||||
|
files_map.insert(f.name.clone(), decoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile
|
||||||
|
let compiler = state.compiler.lock().await;
|
||||||
|
let result = match payload.format.as_str() {
|
||||||
|
"pdf" => compiler.export_pdf(payload.code.clone(), files_map),
|
||||||
|
"png" => compiler.export_png(payload.code.clone(), files_map),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
drop(compiler);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(data) => {
|
||||||
|
// Store in cache (ignore errors — concurrent inserts are fine)
|
||||||
|
let cache_id = Uuid::new_v4().to_string();
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO api_render_cache (id, content_hash, format, data, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(&cache_id)
|
||||||
|
.bind(&cache_key)
|
||||||
|
.bind(&payload.format)
|
||||||
|
.bind(&data)
|
||||||
|
.bind(&now_str)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data).into_response()
|
||||||
|
}
|
||||||
|
Err(_) => (StatusCode::UNPROCESSABLE_ENTITY, "Typst compilation failed. Check your code for errors.").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
||||||
|
|
||||||
|
|
||||||
|
<a href="/api-docs" class="text-sm font-medium text-gray-600 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="API Docs">
|
||||||
|
<Icon icon="mdi:api" class="text-xl" />
|
||||||
|
</a>
|
||||||
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Typst Docs">
|
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Typst Docs">
|
||||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-xl" />
|
<Icon icon="mdi:book-open-page-variant-outline" class="text-xl" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import bash from 'highlight.js/lib/languages/bash';
|
||||||
|
import javascript from 'highlight.js/lib/languages/javascript';
|
||||||
|
import python from 'highlight.js/lib/languages/python';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
|
||||||
|
hljs.registerLanguage('bash', bash);
|
||||||
|
hljs.registerLanguage('javascript', javascript);
|
||||||
|
hljs.registerLanguage('python', python);
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
|
||||||
|
let activeSection = $state('overview');
|
||||||
|
let copiedSnippet = $state<string | null>(null);
|
||||||
|
|
||||||
|
let baseUrl = $derived($page.url.origin);
|
||||||
|
|
||||||
|
let curlPng = $derived(`curl -X POST ${baseUrl}/v1/render \\
|
||||||
|
-H "Authorization: Bearer td_your_api_key_here" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"code":"#set page(width:200pt,height:80pt)\\nHello, *World*!","format":"png"}' \\
|
||||||
|
--output hello.png`);
|
||||||
|
|
||||||
|
let curlPdf = $derived(`curl -X POST ${baseUrl}/v1/render \\
|
||||||
|
-H "Authorization: Bearer td_your_api_key_here" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"code":"= My Report\\n\\nSome body text.","format":"pdf"}' \\
|
||||||
|
--output report.pdf`);
|
||||||
|
|
||||||
|
let jsExample = $derived(`const response = await fetch('${baseUrl}/v1/render', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer td_your_api_key_here',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: '#set page(width: 200pt, height: 80pt)\\nHello, *World*!',
|
||||||
|
format: 'png',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
document.querySelector('img').src = URL.createObjectURL(blob);`);
|
||||||
|
|
||||||
|
let pythonExample = $derived(`import httpx
|
||||||
|
|
||||||
|
response = httpx.post(
|
||||||
|
"${baseUrl}/v1/render",
|
||||||
|
headers={"Authorization": "Bearer td_your_api_key_here"},
|
||||||
|
json={
|
||||||
|
"code": "= My Report\\\\n\\\\nSome body text.",
|
||||||
|
"format": "pdf",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with open("report.pdf", "wb") as f:
|
||||||
|
f.write(response.content)`);
|
||||||
|
|
||||||
|
let filesExample = $derived(`import base64, httpx
|
||||||
|
|
||||||
|
with open("logo.png", "rb") as f:
|
||||||
|
logo_b64 = base64.b64encode(f.read()).decode()
|
||||||
|
|
||||||
|
response = httpx.post(
|
||||||
|
"${baseUrl}/v1/render",
|
||||||
|
headers={"Authorization": "Bearer td_your_api_key_here"},
|
||||||
|
json={
|
||||||
|
"code": """
|
||||||
|
#set page(width: 300pt, height: 200pt)
|
||||||
|
#image("logo.png", width: 80pt)
|
||||||
|
= My Report
|
||||||
|
Some body text.
|
||||||
|
""",
|
||||||
|
"format": "png",
|
||||||
|
"files": [{"name": "logo.png", "data": logo_b64}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
with open("output.png", "wb") as f:
|
||||||
|
f.write(response.content)`);
|
||||||
|
|
||||||
|
const requestSchemaJson = `{
|
||||||
|
"code": "string", // Typst markup (required)
|
||||||
|
"format": "png" | "pdf", // Output format (required)
|
||||||
|
"files": [ // Optional inline assets
|
||||||
|
{
|
||||||
|
"name": "string", // Filename used in Typst code
|
||||||
|
"data": "string" // Base64-encoded file content
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Highlighted versions (derived so they update if baseUrl changes)
|
||||||
|
let hCurlPng = $derived(hljs.highlight(curlPng, { language: 'bash' }).value);
|
||||||
|
let hCurlPdf = $derived(hljs.highlight(curlPdf, { language: 'bash' }).value);
|
||||||
|
let hJs = $derived(hljs.highlight(jsExample, { language: 'javascript' }).value);
|
||||||
|
let hPython = $derived(hljs.highlight(pythonExample, { language: 'python' }).value);
|
||||||
|
let hFiles = $derived(hljs.highlight(filesExample, { language: 'python' }).value);
|
||||||
|
let hSchema = $derived(hljs.highlight(requestSchemaJson,{ language: 'json' }).value);
|
||||||
|
|
||||||
|
async function copy(id: string, text: string) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
copiedSnippet = id;
|
||||||
|
setTimeout(() => copiedSnippet = null, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navSections = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: 'mdi:book-open-outline' },
|
||||||
|
{ id: 'auth', label: 'Authentication', icon: 'mdi:key-outline' },
|
||||||
|
{ id: 'endpoint', label: 'POST /v1/render', icon: 'mdi:api' },
|
||||||
|
{ id: 'examples', label: 'Examples', icon: 'mdi:code-braces' },
|
||||||
|
{ id: 'rate-limits', label: 'Rate Limits', icon: 'mdi:speedometer' },
|
||||||
|
{ id: 'errors', label: 'Error Reference', icon: 'mdi:alert-circle-outline' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>API Docs - TypstDrive</title>
|
||||||
|
<meta name="description" content="TypstDrive Render API documentation." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.hljs) {
|
||||||
|
color: #abb2bf;
|
||||||
|
background: #1e2127;
|
||||||
|
}
|
||||||
|
:global(.hljs-comment), :global(.hljs-quote) { color: #5c6370; font-style: italic; }
|
||||||
|
:global(.hljs-doctag), :global(.hljs-keyword), :global(.hljs-formula) { color: #c678dd; }
|
||||||
|
:global(.hljs-section), :global(.hljs-name), :global(.hljs-selector-tag),
|
||||||
|
:global(.hljs-deletion), :global(.hljs-subst) { color: #e06c75; }
|
||||||
|
:global(.hljs-literal) { color: #56b6c2; }
|
||||||
|
:global(.hljs-string), :global(.hljs-regexp), :global(.hljs-addition),
|
||||||
|
:global(.hljs-attribute), :global(.hljs-meta .hljs-string) { color: #98c379; }
|
||||||
|
:global(.hljs-attr), :global(.hljs-variable), :global(.hljs-template-variable),
|
||||||
|
:global(.hljs-type), :global(.hljs-selector-class), :global(.hljs-selector-attr),
|
||||||
|
:global(.hljs-selector-pseudo), :global(.hljs-number) { color: #d19a66; }
|
||||||
|
:global(.hljs-symbol), :global(.hljs-bullet), :global(.hljs-link),
|
||||||
|
:global(.hljs-meta), :global(.hljs-selector-id), :global(.hljs-title) { color: #61aeee; }
|
||||||
|
:global(.hljs-built_in), :global(.hljs-title.class_), :global(.hljs-class .hljs-title) { color: #e6c07b; }
|
||||||
|
:global(.hljs-emphasis) { font-style: italic; }
|
||||||
|
:global(.hljs-strong) { font-weight: bold; }
|
||||||
|
:global(.hljs-link) { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<nav class="bg-[var(--theme-bg)] shadow-sm border-b border-gray-200 dark:border-white/10 px-6 py-4 flex justify-between items-center sticky top-0 z-10 transition-colors duration-200 flex-shrink-0">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<Icon icon="mdi:api" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||||
|
API Reference
|
||||||
|
</h1>
|
||||||
|
<button onclick={() => goto('/dashboard')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:arrow-left" class="text-lg" />
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
|
||||||
|
<aside class="w-56 flex-shrink-0 hidden md:block">
|
||||||
|
<nav class="sticky top-24 space-y-1">
|
||||||
|
{#each navSections as section}
|
||||||
|
<button
|
||||||
|
onclick={() => activeSection = section.id}
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 {activeSection === section.id
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5'}"
|
||||||
|
>
|
||||||
|
<Icon icon={section.icon} class="text-lg flex-shrink-0" />
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
|
||||||
|
<a href="/settings" class="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/5 transition-all duration-150">
|
||||||
|
<Icon icon="mdi:key-plus" class="text-lg flex-shrink-0" />
|
||||||
|
Manage API Keys
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
||||||
|
|
||||||
|
<div class="md:hidden flex gap-2 flex-wrap">
|
||||||
|
{#each navSections as section}
|
||||||
|
<button
|
||||||
|
onclick={() => activeSection = section.id}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors {activeSection === section.id ? 'bg-blue-600 text-white' : 'bg-gray-100 dark:bg-white/10 text-gray-600 dark:text-gray-300'}"
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeSection === 'overview'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:book-open-outline" class="text-2xl text-blue-500" />
|
||||||
|
Overview
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||||
|
The TypstDrive Render API lets you compile Typst markup into PNG images or PDF documents programmatically.
|
||||||
|
Authenticate with an API key and POST Typst code — get back binary output.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30">
|
||||||
|
<Icon icon="mdi:image-outline" class="text-2xl text-blue-500 mb-2" />
|
||||||
|
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">PNG output</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">First page rendered at 2× scale</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-purple-50 dark:bg-purple-900/10 border border-purple-100 dark:border-purple-800/30">
|
||||||
|
<Icon icon="mdi:file-pdf-box" class="text-2xl text-purple-500 mb-2" />
|
||||||
|
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">PDF output</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Full multi-page PDF document</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30">
|
||||||
|
<Icon icon="mdi:lightning-bolt" class="text-2xl text-green-500 mb-2" />
|
||||||
|
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">Cached results</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Identical inputs skip recompilation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-black/30 rounded-xl p-4 border border-gray-200 dark:border-white/10">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Base URL</p>
|
||||||
|
<code class="font-mono text-sm text-blue-600 dark:text-blue-400">{baseUrl}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'auth'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:key-outline" class="text-2xl text-blue-500" />
|
||||||
|
Authentication
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||||
|
All requests must include an API key in the <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">Authorization</code> header.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Header format</p>
|
||||||
|
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-3 text-xs font-mono leading-relaxed rounded-xl">{@html hljs.highlight('Authorization: Bearer td_your_api_key_here', { language: 'bash' }).value}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-700/30 flex gap-3">
|
||||||
|
<Icon icon="mdi:information-outline" class="text-amber-500 text-xl flex-shrink-0 mt-0.5" />
|
||||||
|
<div class="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<p class="font-semibold mb-1">Keep your keys secret</p>
|
||||||
|
<p>API keys grant access to your account's uploaded files during compilation. Never expose them in client-side code or commit them to version control.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Managing keys</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Create, regenerate, and revoke keys in
|
||||||
|
<a href="/settings" class="text-blue-600 dark:text-blue-400 hover:underline">Settings → API Keys</a>.
|
||||||
|
The full key is shown only once at creation time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'endpoint'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8 space-y-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:api" class="text-2xl text-blue-500" />
|
||||||
|
POST /v1/render
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="px-2 py-0.5 text-xs font-bold bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-md">POST</span>
|
||||||
|
<code class="font-mono text-sm text-gray-800 dark:text-gray-200">/v1/render</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Compile Typst markup and return rendered binary output as PNG or PDF.
|
||||||
|
Results are cached for 1 hour — identical inputs return the cached result without recompiling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-gray-200 dark:bg-white/10"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Request headers</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-white/10">
|
||||||
|
<th class="text-left py-2 pr-4 font-semibold text-gray-700 dark:text-gray-300 w-40">Header</th>
|
||||||
|
<th class="text-left py-2 font-semibold text-gray-700 dark:text-gray-300">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-600 dark:text-gray-400">
|
||||||
|
<tr class="border-b border-gray-100 dark:border-white/5">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">Authorization</td>
|
||||||
|
<td class="py-2"><code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">Bearer <api-key></code> — required</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">Content-Type</td>
|
||||||
|
<td class="py-2"><code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">application/json</code> — required</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Request body</p>
|
||||||
|
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-4 text-xs font-mono leading-relaxed rounded-xl">{@html hSchema}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 mb-3">Response</p>
|
||||||
|
<div class="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30 text-sm">
|
||||||
|
<span class="font-mono text-xs font-bold text-green-700 dark:text-green-400">200 OK</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 ml-2">Binary body with <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">Content-Type: image/png</code> or <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-2 rounded">application/pdf</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 text-sm text-blue-800 dark:text-blue-300">
|
||||||
|
<p class="font-semibold mb-1 flex items-center gap-2"><Icon icon="mdi:folder-account-outline" class="text-base" /> Account files available automatically</p>
|
||||||
|
<p>Files uploaded to your TypstDrive account are available by filename inside your Typst code. Pass additional files inline via the <code class="font-mono text-xs bg-blue-100 dark:bg-blue-800/40 px-1 rounded">files</code> array to supplement or override them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'examples'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8 space-y-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:code-braces" class="text-2xl text-blue-500" />
|
||||||
|
Examples
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#each [
|
||||||
|
{ id: 'curl-png', label: 'cURL — render PNG', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPng, raw: curlPng },
|
||||||
|
{ id: 'curl-pdf', label: 'cURL — render PDF', icon: 'mdi:bash', iconColor: 'text-gray-400', code: hCurlPdf, raw: curlPdf },
|
||||||
|
{ id: 'js', label: 'JavaScript / TypeScript', icon: 'mdi:language-javascript', iconColor: 'text-yellow-400', code: hJs, raw: jsExample },
|
||||||
|
{ id: 'python', label: 'Python (httpx)', icon: 'mdi:language-python', iconColor: 'text-blue-400', code: hPython, raw: pythonExample},
|
||||||
|
{ id: 'files', label: 'Python — with inline files', icon: 'mdi:file-image-outline', iconColor: 'text-purple-400', code: hFiles, raw: filesExample },
|
||||||
|
] as ex}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-sm font-bold text-gray-800 dark:text-gray-200 flex items-center gap-2">
|
||||||
|
<Icon icon={ex.icon} class="text-lg {ex.iconColor}" />
|
||||||
|
{ex.label}
|
||||||
|
</p>
|
||||||
|
<button onclick={() => copy(ex.id, ex.raw)} class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors px-2 py-1 rounded-md hover:bg-gray-100 dark:hover:bg-white/10">
|
||||||
|
<Icon icon={copiedSnippet === ex.id ? 'mdi:check' : 'mdi:content-copy'} class="text-sm" />
|
||||||
|
{copiedSnippet === ex.id ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="rounded-xl border border-gray-700 overflow-x-auto"><code class="hljs block px-4 py-4 text-xs font-mono leading-relaxed rounded-xl">{@html ex.code}</code></pre>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'rate-limits'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:speedometer" class="text-2xl text-blue-500" />
|
||||||
|
Rate Limits
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div class="p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">60</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">requests / minute per key</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">10</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">API keys per account</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-700/30 text-sm text-amber-800 dark:text-amber-300 mb-4">
|
||||||
|
<p class="font-semibold mb-1">Caching saves quota</p>
|
||||||
|
<p>Identical inputs (same code + files) skip recompilation and are served from cache for up to 1 hour. Cached responses return instantly and do not consume your rate limit.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">When exceeded</p>
|
||||||
|
<div class="p-3 rounded-lg bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-800/30 text-sm">
|
||||||
|
<code class="font-mono text-xs font-bold text-red-700 dark:text-red-400">429 Too Many Requests</code>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 ml-2">— wait for the current 60-second window to reset.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'errors'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:alert-circle-outline" class="text-2xl text-blue-500" />
|
||||||
|
Error Reference
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [
|
||||||
|
{ code: '400', name: 'Bad Request', desc: 'Invalid format value, empty code, or malformed JSON body.' },
|
||||||
|
{ code: '401', name: 'Unauthorized', desc: 'Missing or invalid Authorization header, or unknown API key.' },
|
||||||
|
{ code: '422', name: 'Unprocessable Entity', desc: 'Your Typst code compiled with errors. Fix the markup and retry.' },
|
||||||
|
{ code: '429', name: 'Too Many Requests', desc: 'Rate limit exceeded. Wait for the current 60-second window to reset.' },
|
||||||
|
{ code: '500', name: 'Internal Server Error', desc: 'Unexpected server error. Try again after a short delay.' },
|
||||||
|
] as err}
|
||||||
|
<div class="flex items-start gap-4 p-4 rounded-xl border border-gray-100 dark:border-white/10 bg-gray-50 dark:bg-black/20">
|
||||||
|
<code class="font-mono text-sm font-bold text-gray-800 dark:text-gray-200 flex-shrink-0 w-8">{err.code}</code>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200">{err.name}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{err.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 p-4 rounded-xl bg-gray-50 dark:bg-black/30 border border-gray-200 dark:border-white/10">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">Error body</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Error responses return plain text describing the issue — no JSON envelope.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { userStore } from '$lib/ts/auth';
|
import { userStore } from '$lib/ts/auth';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||||
import Footer from '$lib/components/Footer.svelte';
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import { Chart, LineController, LineElement, PointElement, CategoryScale, LinearScale, Filler, Tooltip } from 'chart.js';
|
||||||
|
Chart.register(LineController, LineElement, PointElement, CategoryScale, LinearScale, Filler, Tooltip);
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +16,15 @@
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ApiKey = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key_prefix: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
rate_limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
let activeSection = $state('account');
|
let activeSection = $state('account');
|
||||||
|
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
@@ -37,6 +48,29 @@
|
|||||||
let deletingUserId = $state<string | null>(null);
|
let deletingUserId = $state<string | null>(null);
|
||||||
let confirmDeleteId = $state<string | null>(null);
|
let confirmDeleteId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let apiKeys = $state<ApiKey[]>([]);
|
||||||
|
let apiKeysLoading = $state(false);
|
||||||
|
let apiKeysError = $state('');
|
||||||
|
let showCreateKeyForm = $state(false);
|
||||||
|
let createKeyName = $state('');
|
||||||
|
let createKeyError = $state('');
|
||||||
|
let createKeyLoading = $state(false);
|
||||||
|
let newlyCreatedKey = $state<{ key: string; name: string } | null>(null);
|
||||||
|
let confirmDeleteKeyId = $state<string | null>(null);
|
||||||
|
let deletingKeyId = $state<string | null>(null);
|
||||||
|
let copiedKey = $state(false);
|
||||||
|
let confirmRegenerateId = $state<string | null>(null);
|
||||||
|
let regeneratingKeyId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Usage chart
|
||||||
|
type UsagePoint = { date: string; count: number };
|
||||||
|
type UsagePeriod = '1hr' | '1day' | '1week';
|
||||||
|
let usageData = $state<UsagePoint[]>([]);
|
||||||
|
let usageLoading = $state(false);
|
||||||
|
let usagePeriod = $state<UsagePeriod>('1week');
|
||||||
|
let chartCanvas = $state<HTMLCanvasElement | null>(null);
|
||||||
|
let chartInstance: Chart | null = null;
|
||||||
|
|
||||||
let showCreateForm = $state(false);
|
let showCreateForm = $state(false);
|
||||||
let createUsername = $state('');
|
let createUsername = $state('');
|
||||||
let createEmail = $state('');
|
let createEmail = $state('');
|
||||||
@@ -67,6 +101,174 @@
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadApiKeys() {
|
||||||
|
apiKeysLoading = true;
|
||||||
|
apiKeysError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/keys');
|
||||||
|
if (res.ok) {
|
||||||
|
apiKeys = await res.json();
|
||||||
|
} else {
|
||||||
|
apiKeysError = 'Failed to load API keys.';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
apiKeysError = 'Network error.';
|
||||||
|
}
|
||||||
|
apiKeysLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApiKey(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
createKeyError = '';
|
||||||
|
createKeyLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/keys', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: createKeyName })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
createKeyError = await res.text() || 'Failed to create key.';
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
newlyCreatedKey = { key: data.key, name: data.name };
|
||||||
|
apiKeys = [...apiKeys, {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
key_prefix: data.prefix,
|
||||||
|
created_at: data.created_at,
|
||||||
|
last_used_at: null,
|
||||||
|
rate_limit: data.rate_limit,
|
||||||
|
}];
|
||||||
|
showCreateKeyForm = false;
|
||||||
|
createKeyName = '';
|
||||||
|
copiedKey = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
createKeyError = 'Network error.';
|
||||||
|
}
|
||||||
|
createKeyLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApiKey(id: string) {
|
||||||
|
deletingKeyId = id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
apiKeys = apiKeys.filter(k => k.id !== id);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
deletingKeyId = null;
|
||||||
|
confirmDeleteKeyId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyKey(key: string) {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
copiedKey = true;
|
||||||
|
setTimeout(() => copiedKey = false, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateApiKey(id: string) {
|
||||||
|
regeneratingKeyId = id;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/keys/${id}/regenerate`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
newlyCreatedKey = { key: data.key, name: data.name };
|
||||||
|
apiKeys = apiKeys.map(k => k.id === id ? {
|
||||||
|
...k, key_prefix: data.prefix, created_at: data.created_at, last_used_at: null
|
||||||
|
} : k);
|
||||||
|
copiedKey = false;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
regeneratingKeyId = null;
|
||||||
|
confirmRegenerateId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsage(period: UsagePeriod) {
|
||||||
|
usageLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/keys/usage?period=${period}`);
|
||||||
|
if (res.ok) usageData = await res.json();
|
||||||
|
} catch {}
|
||||||
|
usageLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function buildChartData(period: UsagePeriod) {
|
||||||
|
const labels: string[] = [];
|
||||||
|
const counts: number[] = [];
|
||||||
|
if (period === '1hr') {
|
||||||
|
// Floor to current UTC minute, then step back 59 more
|
||||||
|
const now = new Date();
|
||||||
|
const baseMs = now.getTime() - (now.getUTCSeconds() * 1000 + now.getUTCMilliseconds());
|
||||||
|
for (let i = 59; i >= 0; i--) {
|
||||||
|
const d = new Date(baseMs - i * 60000);
|
||||||
|
const key = `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||||
|
labels.push(`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`);
|
||||||
|
const pt = usageData.find(p => p.date === key);
|
||||||
|
counts.push(pt ? pt.count : 0);
|
||||||
|
}
|
||||||
|
} else if (period === '1day') {
|
||||||
|
// Floor to current UTC hour, then step back 23 more
|
||||||
|
const now = new Date();
|
||||||
|
const baseMs = now.getTime() - (now.getUTCMinutes() * 60000 + now.getUTCSeconds() * 1000 + now.getUTCMilliseconds());
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const d = new Date(baseMs - i * 3600000);
|
||||||
|
const key = `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}`;
|
||||||
|
labels.push(`${pad(d.getUTCHours())}:00`);
|
||||||
|
const pt = usageData.find(p => p.date === key);
|
||||||
|
counts.push(pt ? pt.count : 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const iso = d.toISOString().split('T')[0];
|
||||||
|
labels.push(iso.slice(5));
|
||||||
|
const pt = usageData.find(p => p.date === iso);
|
||||||
|
counts.push(pt ? pt.count : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { labels, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!chartCanvas) return;
|
||||||
|
const { labels, counts } = buildChartData(usagePeriod);
|
||||||
|
if (chartInstance) chartInstance.destroy();
|
||||||
|
chartInstance = new Chart(chartCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Requests',
|
||||||
|
data: counts,
|
||||||
|
fill: true,
|
||||||
|
backgroundColor: 'rgba(59,130,246,0.12)',
|
||||||
|
borderColor: 'rgba(59,130,246,0.85)',
|
||||||
|
pointBackgroundColor: 'rgba(59,130,246,0.9)',
|
||||||
|
pointRadius: usagePeriod === '1hr' ? 2 : 3,
|
||||||
|
tension: 0.35,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { callbacks: {
|
||||||
|
title: (items) => items[0].label,
|
||||||
|
label: (item) => ` ${item.raw} request${(item.raw as number) !== 1 ? 's' : ''}`,
|
||||||
|
}}},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { stepSize: 1, color: '#9ca3af', font: { size: 10 } }, grid: { color: 'rgba(156,163,175,0.1)' } },
|
||||||
|
x: { ticks: { color: '#9ca3af', font: { size: 10 }, maxTicksLimit: usagePeriod === '1hr' ? 12 : 8 }, grid: { display: false } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { chartInstance?.destroy(); chartInstance = null; };
|
||||||
|
});
|
||||||
|
|
||||||
async function loadAdminUsers() {
|
async function loadAdminUsers() {
|
||||||
adminLoading = true;
|
adminLoading = true;
|
||||||
adminError = '';
|
adminError = '';
|
||||||
@@ -83,6 +285,18 @@
|
|||||||
adminLoading = false;
|
adminLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSection === 'api-keys') {
|
||||||
|
loadApiKeys();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (activeSection === 'api-keys') {
|
||||||
|
loadUsage(usagePeriod);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeSection === 'admin' && $userStore?.is_admin) {
|
if (activeSection === 'admin' && $userStore?.is_admin) {
|
||||||
loadAdminUsers();
|
loadAdminUsers();
|
||||||
@@ -196,6 +410,7 @@
|
|||||||
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
|
{ id: 'account', label: 'Account', icon: 'mdi:account-outline' },
|
||||||
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
{ id: 'theme', label: 'Theme', icon: 'mdi:palette-outline' },
|
||||||
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
{ id: 'storage', label: 'Storage', icon: 'mdi:harddisk' },
|
||||||
|
{ id: 'api-keys', label: 'API Keys', icon: 'mdi:key-outline' },
|
||||||
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
...($userStore?.is_admin ? [{ id: 'admin', label: 'Admin', icon: 'mdi:shield-crown-outline' }] : [])
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
@@ -218,7 +433,6 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
|
<div class="flex flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8 gap-8">
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="w-56 flex-shrink-0">
|
<aside class="w-56 flex-shrink-0">
|
||||||
<nav class="sticky top-24 space-y-1">
|
<nav class="sticky top-24 space-y-1">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
@@ -245,10 +459,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
<main class="flex-1 min-w-0 space-y-6 pb-16">
|
||||||
|
|
||||||
<!-- Account Section -->
|
|
||||||
{#if activeSection === 'account'}
|
{#if activeSection === 'account'}
|
||||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
@@ -342,7 +554,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Theme Section -->
|
|
||||||
{#if activeSection === 'theme'}
|
{#if activeSection === 'theme'}
|
||||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
@@ -356,7 +567,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Storage Section -->
|
|
||||||
{#if activeSection === 'storage'}
|
{#if activeSection === 'storage'}
|
||||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
<div class="p-6 sm:p-8">
|
<div class="p-6 sm:p-8">
|
||||||
@@ -394,6 +604,216 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSection === 'api-keys'}
|
||||||
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
|
<div class="p-6 sm:p-8">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:key-outline" class="text-2xl text-blue-500 dark:text-blue-400" />
|
||||||
|
API Keys
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onclick={() => { showCreateKeyForm = !showCreateKeyForm; createKeyError = ''; newlyCreatedKey = null; }}
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-semibold rounded-lg transition-colors {showCreateKeyForm ? 'bg-gray-200 dark:bg-white/10 text-gray-700 dark:text-gray-300' : 'bg-blue-600 hover:bg-blue-700 text-white shadow-sm'}"
|
||||||
|
>
|
||||||
|
<Icon icon={showCreateKeyForm ? 'mdi:close' : 'mdi:plus'} class="text-base" />
|
||||||
|
{showCreateKeyForm ? 'Cancel' : 'New Key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Use API keys to render Typst documents programmatically via <code class="font-mono text-xs bg-gray-100 dark:bg-white/10 px-1.5 py-0.5 rounded">POST /v1/render</code>.
|
||||||
|
Each key allows up to 60 requests/minute.
|
||||||
|
<a href="/api-docs" class="text-blue-600 dark:text-blue-400 hover:underline ml-1">View API docs →</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Usage chart -->
|
||||||
|
<div class="mb-6 p-4 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-black/30">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Requests — {usagePeriod === '1hr' ? 'Last 60 Min' : usagePeriod === '1day' ? 'Last 24 Hours' : 'Last 7 Days'}
|
||||||
|
{#if usageData.length > 0}
|
||||||
|
<span class="ml-2 normal-case font-normal text-gray-400 dark:text-gray-500">
|
||||||
|
({usageData.reduce((s, p) => s + p.count, 0)} total)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each ([['1hr', '1 hr'], ['1day', '1 day'], ['1week', '1 week']] as const) as [val, label]}
|
||||||
|
<button
|
||||||
|
onclick={() => usagePeriod = val}
|
||||||
|
class="px-2 py-0.5 text-xs font-semibold rounded-md transition-colors {usagePeriod === val ? 'bg-blue-600 text-white' : 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-white/10'}"
|
||||||
|
>{label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-32">
|
||||||
|
{#if usageLoading}
|
||||||
|
<div class="h-full flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin mr-2" /> Loading...
|
||||||
|
</div>
|
||||||
|
{:else if usageData.length === 0}
|
||||||
|
<div class="h-full flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
No usage yet — make your first API call to see data here.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<canvas bind:this={chartCanvas}></canvas>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if newlyCreatedKey}
|
||||||
|
<div class="mb-6 p-4 rounded-xl border border-green-200 dark:border-green-700/50 bg-green-50 dark:bg-green-900/10">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-bold text-green-800 dark:text-green-300 flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:check-circle" class="text-lg" />
|
||||||
|
Key created: {newlyCreatedKey.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-green-700 dark:text-green-400 mt-0.5">Copy this key now — it will not be shown again.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => newlyCreatedKey = null} class="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200 flex-shrink-0">
|
||||||
|
<Icon icon="mdi:close" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-3">
|
||||||
|
<code class="flex-1 font-mono text-xs bg-white dark:bg-black/40 border border-green-200 dark:border-green-700/50 text-gray-800 dark:text-gray-200 px-3 py-2 rounded-lg break-all">{newlyCreatedKey.key}</code>
|
||||||
|
<button
|
||||||
|
onclick={() => copyKey(newlyCreatedKey!.key)}
|
||||||
|
class="flex-shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm font-semibold rounded-lg transition-colors {copiedKey ? 'bg-green-600 text-white' : 'bg-gray-200 dark:bg-white/10 hover:bg-gray-300 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300'}"
|
||||||
|
>
|
||||||
|
<Icon icon={copiedKey ? 'mdi:check' : 'mdi:content-copy'} class="text-base" />
|
||||||
|
{copiedKey ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showCreateKeyForm}
|
||||||
|
<form onsubmit={createApiKey} class="mb-6 p-4 rounded-xl border border-blue-200 dark:border-blue-800/50 bg-blue-50/50 dark:bg-blue-900/10 space-y-3">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Icon icon="mdi:key-plus" class="text-blue-500" />
|
||||||
|
Create API Key
|
||||||
|
</h3>
|
||||||
|
{#if createKeyError}
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-3 py-2 rounded-lg text-sm">{createKeyError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Key Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
bind:value={createKeyName}
|
||||||
|
placeholder="e.g. My App, CI Pipeline"
|
||||||
|
class="w-full bg-white dark:bg-black/40 border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createKeyLoading || !createKeyName.trim()}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{#if createKeyLoading}
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin text-base" />
|
||||||
|
Creating...
|
||||||
|
{:else}
|
||||||
|
<Icon icon="mdi:key-plus" class="text-base" />
|
||||||
|
Create
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if apiKeysError}
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-sm mb-4">{apiKeysError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if apiKeysLoading}
|
||||||
|
<div class="flex items-center justify-center py-12 text-gray-400 dark:text-gray-500">
|
||||||
|
<Icon icon="mdi:loading" class="animate-spin text-2xl mr-2" />
|
||||||
|
Loading keys...
|
||||||
|
</div>
|
||||||
|
{:else if apiKeys.length === 0}
|
||||||
|
<div class="text-center py-12 text-gray-400 dark:text-gray-500">
|
||||||
|
<Icon icon="mdi:key-outline" class="text-4xl mb-2 opacity-40" />
|
||||||
|
<p class="text-sm">No API keys yet. Create one to get started.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each apiKeys as key (key.id)}
|
||||||
|
<div class="flex items-center gap-4 px-4 py-3 rounded-xl border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-black/20">
|
||||||
|
<div class="h-9 w-9 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||||||
|
<Icon icon="mdi:key" class="text-lg" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">{key.name}</p>
|
||||||
|
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{key.key_prefix}... · {key.rate_limit}/min</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flex-shrink-0 hidden sm:block">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">Created {formatDate(key.created_at)}</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">{key.last_used_at ? `Last used ${formatDate(key.last_used_at)}` : 'Never used'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{#if confirmRegenerateId === key.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Regenerate?</span>
|
||||||
|
<button
|
||||||
|
onclick={() => regenerateApiKey(key.id)}
|
||||||
|
disabled={regeneratingKeyId === key.id}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-amber-500 hover:bg-amber-600 text-white font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{regeneratingKeyId === key.id ? '...' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => confirmRegenerateId = null}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-white/10 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if confirmDeleteKeyId === key.id}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Delete?</span>
|
||||||
|
<button
|
||||||
|
onclick={() => deleteApiKey(key.id)}
|
||||||
|
disabled={deletingKeyId === key.id}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-red-600 hover:bg-red-700 text-white font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deletingKeyId === key.id ? '...' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => confirmDeleteKeyId = null}
|
||||||
|
class="text-xs px-2 py-1 rounded-md bg-gray-200 hover:bg-gray-300 dark:bg-white/10 dark:hover:bg-white/20 text-gray-700 dark:text-gray-300 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => { confirmRegenerateId = key.id; confirmDeleteKeyId = null; }}
|
||||||
|
title="Regenerate key"
|
||||||
|
class="p-1.5 rounded-lg text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:refresh" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => { confirmDeleteKeyId = key.id; confirmRegenerateId = null; }}
|
||||||
|
title="Revoke key"
|
||||||
|
class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:delete-outline" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Admin Section -->
|
<!-- Admin Section -->
|
||||||
{#if activeSection === 'admin' && $userStore?.is_admin}
|
{#if activeSection === 'admin' && $userStore?.is_admin}
|
||||||
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
<div class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||||
|
|||||||
Reference in New Issue
Block a user