diff --git a/.env.example b/.env.example index 63dc40d..3c21eea 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ CACHE_SWEEP_INTERVAL_SECS=60 # Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers. CORS_ORIGINS= +# DEV ONLY: set to "true" to bypass all authentication/permission checks +# (every request is treated as a super-admin, no login required). Never set +# this in production. +DISABLE_AUTH= + # CDN (MinIO object storage) — credentials and bucket are pre-configured internally. # When registering CDN objects in the admin UI, use this URL prefix: # docker-compose: http://cdn:9000/mercury/ diff --git a/Cargo.toml b/Cargo.toml index d6bed48..bcd567b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ path = "src/main.rs" [dependencies] axum = { version = "0.7", features = ["macros", "multipart"] } tokio = { version = "1", features = ["full"] } -sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" jsonwebtoken = "9" diff --git a/README.md b/README.md index 2781a6a..12edbb6 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ Custom permissions are added via the admin suite and assigned the next available | `CACHE_MAX_CAPACITY` | 10000 | Max query templates in memory | | `CACHE_IDLE_TIMEOUT_SECS` | 300 | Evict after N seconds idle | | `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task | +| `DISABLE_AUTH` | `false` | **Dev only.** When `true`, bypasses all authentication/permission checks — every request is treated as a super-admin and the admin UI skips login. Never enable in production. | --- diff --git a/docker-compose.yml b/docker-compose.yml index 392bed6..32bf5c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,6 +54,7 @@ services: CACHE_SWEEP_INTERVAL_SECS: ${CACHE_SWEEP_INTERVAL_SECS:-60} CDN_ENDPOINT: http://cdn:9000 CDN_BUCKET: mercury + DISABLE_AUTH: ${DISABLE_AUTH:-false} MERCURY_ADMIN_USER: test MERCURY_ADMIN_PASSWORD: test depends_on: diff --git a/docs/superpowers/plans/2026-08-09-disable-auth-env-var.md b/docs/superpowers/plans/2026-08-09-disable-auth-env-var.md new file mode 100644 index 0000000..66037c0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-disable-auth-env-var.md @@ -0,0 +1,620 @@ +# DISABLE_AUTH Env Var Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `DISABLE_AUTH` environment variable that, when set, makes the Mercury API treat every request as an authenticated super-admin (no token required) and makes the Vue admin UI skip the login screen entirely. + +**Architecture:** A new `Config.disable_auth` bool gates a single new helper, `authenticate_or_bypass`, that all auth-checking middleware (`require_auth`, `require_super_admin`, `require_admin_query`, `require_admin_cache`, `blacklist_layer`) call instead of manually extracting a bearer token. When bypassing, it injects a synthetic super-admin `Claims`. A new public `GET /auth/config` endpoint exposes the flag so the frontend's Pinia auth store can short-circuit `isAuthenticated`/`isSuperAdmin` and the router guard never redirects to `/login`. + +**Tech Stack:** Rust/Axum/SQLx backend, Vue 3 + Pinia + vue-router frontend. + +## Global Constraints + +- Default `DISABLE_AUTH` to off/false — the app must behave exactly as today when it's unset. +- Truthy values are `1` and `true` (case-insensitive); anything else (including unset) is false. +- The bypass is all-or-nothing: no partial/route-scoped bypass. +- Follow existing project test conventions: this codebase only unit-tests pure functions (no DB-backed router/handler tests exist anywhere in `src/`) — do not introduce a new DB-mocking pattern; where a change touches a real `PgPool`-holding handler with no pure logic to extract, verify manually instead of adding a test. +- No commits during this implementation — leave changes in the working tree. + +--- + +### Task 1: `Config.disable_auth` + +**Files:** +- Modify: `src/config.rs` (struct fields ~lines 3-17, `from_env` body ~lines 20-43, `tests` mod ~line 50+) + +**Interfaces:** +- Produces: `Config.disable_auth: bool` — read by Task 2's middleware and Task 3's `/auth/config` handler. + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` mod at the bottom of `src/config.rs`: + +```rust + #[test] + fn test_config_disable_auth_default_false() { + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + std::env::remove_var("DISABLE_AUTH"); + let cfg = Config::from_env().unwrap(); + assert!(!cfg.disable_auth); + } + + #[test] + fn test_config_disable_auth_true() { + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + std::env::set_var("DISABLE_AUTH", "true"); + let cfg = Config::from_env().unwrap(); + assert!(cfg.disable_auth); + std::env::remove_var("DISABLE_AUTH"); + } + + #[test] + fn test_config_disable_auth_numeric_true() { + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + std::env::set_var("DISABLE_AUTH", "1"); + let cfg = Config::from_env().unwrap(); + assert!(cfg.disable_auth); + std::env::remove_var("DISABLE_AUTH"); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib config::tests` +Expected: FAIL — `no field \`disable_auth\` on type \`Config\`` (compile error) + +- [ ] **Step 3: Implement the field and parsing** + +In `src/config.rs`, add the field to the struct (after `cdn_bucket`): + +```rust + /// Bucket name used for CDN object storage. + pub cdn_bucket: String, + /// Dev-only: when true, all auth/permission checks are bypassed and every + /// request is treated as a super-admin. NEVER enable in production. + pub disable_auth: bool, +} +``` + +And in `from_env()`, add after the `cdn_bucket` line: + +```rust + cdn_bucket: std::env::var("CDN_BUCKET").unwrap_or_else(|_| "mercury".into()), + disable_auth: std::env::var("DISABLE_AUTH") + .map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true")) + .unwrap_or(false), + }) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --lib config::tests` +Expected: PASS (5 tests: the existing `test_config_defaults` plus the 3 new ones) + +--- + +### Task 2: Middleware bypass + +**Files:** +- Modify: `src/auth/mod.rs` (add `bypass_claims`, ~after `permissions` mod, before `Claims` struct) +- Modify: `src/auth/middleware.rs` (add `authenticate_or_bypass`; rewrite `require_auth`, `require_super_admin`, `require_admin_query`, `require_admin_cache`, `blacklist_layer`) + +**Interfaces:** +- Consumes: `Config.disable_auth: bool` (Task 1), `AppState { pool, query_cache, blacklist_cache, cors_cache, http_client, cdn_base_url, config }` (`src/state.rs`). +- Produces: `pub fn bypass_claims() -> Claims` (`src/auth/mod.rs`), `async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option` (`src/auth/middleware.rs`, crate-private) — used by all `require_*` functions and `blacklist_layer`. + +- [ ] **Step 1: Write the failing test for `bypass_claims`** + +Add to the `tests` mod at the bottom of `src/auth/mod.rs`: + +```rust + #[test] + fn test_bypass_claims_has_full_permissions() { + let claims = bypass_claims(); + assert_eq!(claims.sub, "dev-bypass"); + assert_eq!(claims.permissions_mask(), u128::MAX); + assert!(claims.has_permission(permissions::SUPER_ADMIN)); + assert!(claims.has_permission(permissions::ADMIN_QUERY)); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test --lib auth::tests::test_bypass_claims_has_full_permissions` +Expected: FAIL — `cannot find function \`bypass_claims\`` (compile error) + +- [ ] **Step 3: Implement `bypass_claims`** + +In `src/auth/mod.rs`, add after the `permissions` mod block (before `Claims` struct): + +```rust +/// Synthetic super-admin claims used when `DISABLE_AUTH` is set. Grants every +/// permission bit so all `require_*` and blacklist bypass checks pass. +pub fn bypass_claims() -> Claims { + Claims { + sub: "dev-bypass".to_string(), + permissions: u128::MAX.to_string(), + exp: usize::MAX, + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test --lib auth::tests::test_bypass_claims_has_full_permissions` +Expected: PASS + +- [ ] **Step 5: Write the failing tests for `authenticate_or_bypass`** + +Add a `tests` mod at the bottom of `src/auth/middleware.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{AppState, BlacklistCache, CorsCache, QueryCache}; + use axum::body::Body; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + + fn test_state(disable_auth: bool) -> AppState { + // connect_lazy performs no I/O — safe to use without a running DB. + let pool = PgPoolOptions::new() + .connect_lazy("postgres://user:pass@localhost/db") + .expect("lazy pool"); + AppState { + pool, + query_cache: QueryCache::new(), + blacklist_cache: BlacklistCache::new(), + cors_cache: CorsCache::new(), + http_client: reqwest::Client::new(), + cdn_base_url: "http://localhost:9000/mercury".into(), + config: Arc::new(crate::config::Config { + database_url: "postgres://user:pass@localhost/db".into(), + jwt_secret: "secret".into(), + jwt_expiry_secs: 3600, + cache_max_capacity: 10000, + cache_idle_timeout_secs: 300, + cache_sweep_interval_secs: 60, + cors_origins: vec![], + cdn_endpoint: "http://localhost:9000".into(), + cdn_bucket: "mercury".into(), + disable_auth, + }), + } + } + + fn test_request() -> Request { + Request::builder() + .uri("/api/orders") + .body(Body::empty()) + .unwrap() + } + + #[tokio::test] + async fn test_bypass_when_disabled_and_no_token() { + let state = test_state(true); + let mut req = test_request(); + let claims = authenticate_or_bypass(&state, &mut req).await; + assert_eq!(claims.map(|c| c.permissions_mask()), Some(u128::MAX)); + } + + #[tokio::test] + async fn test_no_bypass_when_enabled_and_no_token() { + let state = test_state(false); + let mut req = test_request(); + let claims = authenticate_or_bypass(&state, &mut req).await; + assert!(claims.is_none()); + } +} +``` + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `cargo test --lib auth::middleware::tests` +Expected: FAIL — `cannot find function \`authenticate_or_bypass\`` (compile error) + +- [ ] **Step 7: Implement `authenticate_or_bypass` and rewire the `require_*` functions and `blacklist_layer`** + +In `src/auth/middleware.rs`, add this function directly after the existing `authenticate` function: + +```rust +/// Like `authenticate`, but short-circuits to a synthetic super-admin when +/// `DISABLE_AUTH` is set — no `Authorization` header required in that case. +async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option { + if state.config.disable_auth { + let claims = crate::auth::bypass_claims(); + req.extensions_mut().insert(claims.clone()); + return Some(claims); + } + let token = extract_bearer(req)?; + authenticate(&token, state, req).await +} +``` + +Replace the body of `blacklist_layer`'s `caller_mask` computation: + +```rust + let caller_mask = if let Some(token) = extract_bearer(&req) { + authenticate(&token, &state, &mut req) + .await + .map(|c| c.permissions_mask()) + .unwrap_or(0) + } else { + 0 + }; +``` + +with: + +```rust + let caller_mask = authenticate_or_bypass(&state, &mut req) + .await + .map(|c| c.permissions_mask()) + .unwrap_or(0); +``` + +Replace `require_auth`'s body: + +```rust +pub async fn require_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + authenticate(&token, &state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + Ok(next.run(req).await) +} +``` + +with: + +```rust +pub async fn require_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + authenticate_or_bypass(&state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + Ok(next.run(req).await) +} +``` + +For each of `require_super_admin`, `require_admin_query`, `require_admin_cache`, replace: + +```rust + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; +``` + +with: + +```rust + let claims = authenticate_or_bypass(&state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; +``` + +(leave the rest of each function — the `has_permission` check and `next.run(req).await` — unchanged). + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `cargo test --lib auth::` +Expected: PASS (all `auth::mod` and `auth::middleware` tests, including the 2 new ones) + +- [ ] **Step 9: Run the full test suite to check for regressions** + +Run: `cargo test --lib` +Expected: PASS — all existing tests (`state::tests`, `routes::crud::tests`, `routes::admin::*::tests`) still pass unchanged. + +--- + +### Task 3: `GET /auth/config` endpoint + +**Files:** +- Modify: `src/routes/auth.rs` (add `auth_config` handler) +- Modify: `src/main.rs` (import + route registration, ~line 25 imports, ~line 149 router) + +**Interfaces:** +- Consumes: `Config.disable_auth: bool` (Task 1). +- Produces: `GET /auth/config` → `200 { "disable_auth": bool }`, unauthenticated — consumed by Task 4's frontend auth store. + +- [ ] **Step 1: Implement the handler** + +In `src/routes/auth.rs`, add after `login`: + +```rust +pub async fn auth_config(State(state): State) -> Json { + Json(json!({ "disable_auth": state.config.disable_auth })) +} +``` + +(No new imports needed — `State`, `Json`, `json`, `Value`, and `AppState` are already imported in this file.) + +- [ ] **Step 2: Wire the route in `src/main.rs`** + +Change the import: + +```rust + routes::{ + admin::admin_router, + auth::login, +``` + +to: + +```rust + routes::{ + admin::admin_router, + auth::{auth_config, login}, +``` + +Change the router construction: + +```rust + let app = Router::new() + .route("/auth/login", post(login)) +``` + +to: + +```rust + let app = Router::new() + .route("/auth/login", post(login)) + .route("/auth/config", get(auth_config)) +``` + +- [ ] **Step 3: Verify it builds** + +Run: `cargo build` +Expected: builds with no errors. + +- [ ] **Step 4: Manual verification (no DB-backed router tests exist in this codebase — see Global Constraints)** + +Run: `docker compose up --build -d db cdn cdn-init api` (or `./dev.sh` for the full stack), then: + +```bash +curl -s http://localhost:3000/auth/config +``` + +Expected with `DISABLE_AUTH` unset: `{"disable_auth":false}` +Then set `DISABLE_AUTH=true` in the environment (or `.env`), restart the `api` service, and re-run the curl — expected: `{"disable_auth":true}`. Also verify `curl -s http://localhost:3000/api/orders` (or any CRUD table) now returns `200` with no `Authorization` header, instead of `401`. + +--- + +### Task 4: Frontend — skip login when auth is disabled + +**Files:** +- Modify: `ui/src/stores/auth.ts` (full content shown below) +- Modify: `ui/src/router/index.ts` (full content shown below) + +**Interfaces:** +- Consumes: `GET /auth/config` (Task 3). +- Produces: `useAuthStore().checkAuthDisabled(): Promise`, `useAuthStore().authDisabled: Ref` — consumed by the router guard. + +- [ ] **Step 1: Update the auth store** + +Replace the full contents of `ui/src/stores/auth.ts` with: + +```ts +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; + +interface Claims { + sub: string; + permissions: string; + exp: number; +} + +function parseJwt(token: string): Claims | null { + try { + const payload = token.split(".")[1]; + return payload ? (JSON.parse(atob(payload)) as Claims) : null; + } catch { + return null; + } +} + +export const useAuthStore = defineStore("auth", () => { + const token = ref(localStorage.getItem("mercury_token")); + const claims = computed(() => + token.value ? parseJwt(token.value) : null, + ); + + // DISABLE_AUTH dev mode: fetched once from the backend at boot. + const authDisabled = ref(false); + let configCheckPromise: Promise | null = null; + + function checkAuthDisabled(): Promise { + if (!configCheckPromise) { + configCheckPromise = fetch("/auth/config") + .then((res) => (res.ok ? res.json() : { disable_auth: false })) + .then((data) => { + authDisabled.value = Boolean(data.disable_auth); + }) + .catch(() => { + authDisabled.value = false; + }); + } + return configCheckPromise; + } + + const isAuthenticated = computed(() => { + if (authDisabled.value) return true; + if (!claims.value) return false; + return claims.value.exp * 1000 > Date.now(); + }); + const username = computed(() => + authDisabled.value ? "dev-bypass" : (claims.value?.sub ?? ""), + ); + + function hasPermission(bit: bigint): boolean { + if (authDisabled.value) return true; + if (!claims.value) return false; + const mask = BigInt(claims.value.permissions); + return (mask & bit) !== 0n; + } + + const isSuperAdmin = computed(() => authDisabled.value || hasPermission(32n)); + + async function login(username: string, password: string): Promise { + const res = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error("Invalid credentials"); + const data = await res.json(); + token.value = data.token; + localStorage.setItem("mercury_token", data.token); + } + + function logout() { + token.value = null; + localStorage.removeItem("mercury_token"); + } + + function authHeaders(): Record { + return token.value ? { Authorization: `Bearer ${token.value}` } : {}; + } + + return { + token, + claims, + authDisabled, + checkAuthDisabled, + isAuthenticated, + username, + isSuperAdmin, + hasPermission, + login, + logout, + authHeaders, + }; +}); +``` + +- [ ] **Step 2: Update the router guard** + +Replace the full contents of `ui/src/router/index.ts` with: + +```ts +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('../views/Login.vue') }, + { + path: '/admin', + component: () => import('../views/admin/Layout.vue'), + children: [ + { path: 'queries', component: () => import('../views/admin/Queries.vue') }, + { path: 'tables', component: () => import('../views/admin/Tables.vue') }, + { path: 'users', component: () => import('../views/admin/Users.vue') }, + { path: 'permissions', component: () => import('../views/admin/Permissions.vue') }, + { path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') }, + { path: 'api-keys', component: () => import('../views/admin/ApiKeys.vue') }, + { path: 'cache', component: () => import('../views/admin/Cache.vue') }, + { path: 'cors', component: () => import('../views/admin/Cors.vue') }, + { path: 'cdn', component: () => import('../views/admin/Cdn.vue') }, + ], + meta: { requiresAuth: true }, + }, + { path: '/', redirect: '/admin/queries' }, + ], +}) + +router.beforeEach(async (to) => { + const auth = useAuthStore() + await auth.checkAuthDisabled() + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return '/login' + } + if (to.path === '/login' && auth.isAuthenticated) { + return '/admin/queries' + } +}) + +export default router +``` + +- [ ] **Step 3: Verify the FE builds** + +Run: `cd ui && npm run build` +Expected: builds with no TypeScript errors. + +- [ ] **Step 4: Manual browser verification (no FE test framework configured in this project)** + +With `DISABLE_AUTH` unset, run `./dev.sh`, open `http://localhost:3000` — expect the normal `/login` redirect. +With `DISABLE_AUTH=true` set (e.g. `DISABLE_AUTH=true docker compose up --build`), open `http://localhost:3000` — expect immediate landing on `/admin/queries` with no login prompt, and that every admin nav section (Users, Permissions, Blacklist, API Keys, Cache, CORS, CDN) loads data without a 401. + +--- + +### Task 5: Docs and compose plumbing + +**Files:** +- Modify: `.env.example` +- Modify: `docker-compose.yml` (`api` service `environment` block, ~line 40) +- Modify: `README.md` (Configuration table, ~line 146-153) + +**Interfaces:** +- Consumes: nothing (documentation-only). +- Produces: nothing consumed by other tasks — this is the last task. + +- [ ] **Step 1: Update `.env.example`** + +Add after `CORS_ORIGINS=`: + +``` +# Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers. +CORS_ORIGINS= + +# DEV ONLY: set to "true" to bypass all authentication/permission checks +# (every request is treated as a super-admin, no login required). Never set +# this in production. +DISABLE_AUTH= +``` + +- [ ] **Step 2: Update `docker-compose.yml`** + +In the `api` service `environment` block, add after `CDN_BUCKET: mercury`: + +```yaml + CDN_BUCKET: mercury + DISABLE_AUTH: ${DISABLE_AUTH:-false} +``` + +- [ ] **Step 3: Update `README.md`** + +In the Configuration table, add a row after `CACHE_SWEEP_INTERVAL_SECS`: + +```markdown +| `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task | +| `DISABLE_AUTH` | `false` | **Dev only.** When `true`, bypasses all authentication/permission checks — every request is treated as a super-admin and the admin UI skips login. Never enable in production. | +``` + +- [ ] **Step 4: Verify docs render sanely** + +Run: `git diff --stat .env.example docker-compose.yml README.md` +Expected: shows the 3 files with small additive diffs, no unrelated changes. + +--- + +## Final Verification + +- [ ] `cargo test --lib` — all tests pass. +- [ ] `cargo build` — builds clean. +- [ ] `cd ui && npm run build` — builds clean. +- [ ] Manual: `DISABLE_AUTH=true docker compose up --build`, confirm UI skips login and all admin sections load; then unset it and confirm `/login` is required again (existing `admin`/`admin`-style credentials per README, or `MERCURY_ADMIN_USER`/`MERCURY_ADMIN_PASSWORD` from compose). diff --git a/docs/superpowers/specs/2026-08-09-disable-auth-env-var-design.md b/docs/superpowers/specs/2026-08-09-disable-auth-env-var-design.md new file mode 100644 index 0000000..fb00ec9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-disable-auth-env-var-design.md @@ -0,0 +1,52 @@ +# DISABLE_AUTH env var — design + +## Purpose + +Add a dev-only `DISABLE_AUTH` environment variable that bypasses authentication +and permission checks end-to-end (API + admin UI), so the app can be driven +by tooling (e.g. browser automation) without a login step. Insecure by design; +must default to off and be documented as such. + +## Backend + +- `Config` (`src/config.rs`) gains `disable_auth: bool`, read from `DISABLE_AUTH`. + Truthy values: `1`, `true` (case-insensitive). Default: `false`. +- `src/auth/middleware.rs` gains `authenticate_or_bypass(state, req)`: + - If `state.config.disable_auth`, return a synthetic super-admin `Claims` + (`sub: "dev-bypass"`, `permissions: u128::MAX.to_string()`, `exp: usize::MAX`) + without inspecting the request at all. + - Otherwise, delegate to the existing `extract_bearer` + `authenticate` flow. +- `require_auth`, `require_super_admin`, `require_admin_query`, + `require_admin_cache`, and `blacklist_layer` switch from + `extract_bearer(...).ok_or(UNAUTHORIZED)?` + `authenticate(...)` to + `authenticate_or_bypass(...)`, so the bypass applies uniformly (including + blacklist `bypass_mask` checks, since the caller mask is `u128::MAX`). +- New route `GET /auth/config` (`src/routes/auth.rs`), unauthenticated, + returns `{"disable_auth": bool}` so the frontend can detect the mode. + +## Frontend + +- `ui/src/stores/auth.ts`: on first access, fetch `GET /auth/config`. If + `disable_auth` is `true`, set an `authDisabled` ref and short-circuit + `isAuthenticated` / `isSuperAdmin` / `hasPermission` to always report a + fully-privileged, logged-in user — no token needed. +- `ui/src/router/index.ts`: `beforeEach` awaits the auth store's boot check + (memoized, runs once) before evaluating `requiresAuth`, so `/admin/*` + routes never redirect to `/login` while the flag is set. +- No changes to `Login.vue` itself; it simply becomes unreachable in this mode + (redirect away from `/login` when already "authenticated"). + +## Docs / plumbing + +- `.env.example`: add `DISABLE_AUTH=` (commented, default off) with a warning. +- `docker-compose.yml`: add `DISABLE_AUTH: ${DISABLE_AUTH:-false}` under the + `api` service environment. +- `README.md`: add `DISABLE_AUTH` to the Configuration table, marked + dev-only / insecure — do not use in production. + +## Out of scope + +- No change to `/auth/login` behavior when the flag is off. +- No new permission bits or config for partial bypass (all-or-nothing). +- No changes to how the FE issues investigation (separate task, done live + with agent-browser after this lands). diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 36154cf..1dd0c33 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -26,6 +26,18 @@ async fn authenticate(token: &str, state: &AppState, req: &mut Request) -> Optio Some(claims) } +/// Like `authenticate`, but short-circuits to a synthetic super-admin when +/// `DISABLE_AUTH` is set — no `Authorization` header required in that case. +async fn authenticate_or_bypass(state: &AppState, req: &mut Request) -> Option { + if state.config.disable_auth { + let claims = crate::auth::bypass_claims(); + req.extensions_mut().insert(claims.clone()); + return Some(claims); + } + let token = extract_bearer(req)?; + authenticate(&token, state, req).await +} + pub async fn blacklist_layer( State(state): State, mut req: Request, @@ -33,14 +45,10 @@ pub async fn blacklist_layer( ) -> Result { let method = req.method().as_str().to_uppercase(); let path = req.uri().path().to_string(); - let caller_mask = if let Some(token) = extract_bearer(&req) { - authenticate(&token, &state, &mut req) - .await - .map(|c| c.permissions_mask()) - .unwrap_or(0) - } else { - 0 - }; + let caller_mask = authenticate_or_bypass(&state, &mut req) + .await + .map(|c| c.permissions_mask()) + .unwrap_or(0); if state .blacklist_cache .is_blocked(&method, &path, caller_mask) @@ -64,8 +72,7 @@ pub async fn require_auth( mut req: Request, next: Next, ) -> Result { - let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; - authenticate(&token, &state, &mut req) + authenticate_or_bypass(&state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; Ok(next.run(req).await) @@ -76,8 +83,7 @@ pub async fn require_super_admin( mut req: Request, next: Next, ) -> Result { - let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; - let claims = authenticate(&token, &state, &mut req) + let claims = authenticate_or_bypass(&state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) { @@ -91,8 +97,7 @@ pub async fn require_admin_query( mut req: Request, next: Next, ) -> Result { - let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; - let claims = authenticate(&token, &state, &mut req) + let claims = authenticate_or_bypass(&state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) { @@ -106,8 +111,7 @@ pub async fn require_admin_cache( mut req: Request, next: Next, ) -> Result { - let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; - let claims = authenticate(&token, &state, &mut req) + let claims = authenticate_or_bypass(&state, &mut req) .await .ok_or(StatusCode::UNAUTHORIZED)?; if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) { @@ -179,3 +183,62 @@ pub async fn cors_layer(State(state): State, req: Request, next: Next) } response } + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{AppState, BlacklistCache, CorsCache, QueryCache}; + use axum::body::Body; + use sqlx::postgres::PgPoolOptions; + use std::sync::Arc; + + fn test_state(disable_auth: bool) -> AppState { + // connect_lazy performs no I/O — safe to use without a running DB. + let pool = PgPoolOptions::new() + .connect_lazy("postgres://user:pass@localhost/db") + .expect("lazy pool"); + AppState { + pool, + query_cache: QueryCache::new(), + blacklist_cache: BlacklistCache::new(), + cors_cache: CorsCache::new(), + http_client: reqwest::Client::new(), + cdn_base_url: "http://localhost:9000/mercury".into(), + config: Arc::new(crate::config::Config { + database_url: "postgres://user:pass@localhost/db".into(), + jwt_secret: "secret".into(), + jwt_expiry_secs: 3600, + cache_max_capacity: 10000, + cache_idle_timeout_secs: 300, + cache_sweep_interval_secs: 60, + cors_origins: vec![], + cdn_endpoint: "http://localhost:9000".into(), + cdn_bucket: "mercury".into(), + disable_auth, + }), + } + } + + fn test_request() -> Request { + Request::builder() + .uri("/api/orders") + .body(Body::empty()) + .unwrap() + } + + #[tokio::test] + async fn test_bypass_when_disabled_and_no_token() { + let state = test_state(true); + let mut req = test_request(); + let claims = authenticate_or_bypass(&state, &mut req).await; + assert_eq!(claims.map(|c| c.permissions_mask()), Some(u128::MAX)); + } + + #[tokio::test] + async fn test_no_bypass_when_enabled_and_no_token() { + let state = test_state(false); + let mut req = test_request(); + let claims = authenticate_or_bypass(&state, &mut req).await; + assert!(claims.is_none()); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index fb6a7df..523e3ec 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -17,6 +17,16 @@ pub mod permissions { pub const SUPER_ADMIN: u128 = 32; } +/// Synthetic super-admin claims used when `DISABLE_AUTH` is set. Grants every +/// permission bit so all `require_*` and blacklist bypass checks pass. +pub fn bypass_claims() -> Claims { + Claims { + sub: "dev-bypass".to_string(), + permissions: u128::MAX.to_string(), + exp: usize::MAX, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { pub sub: String, @@ -120,4 +130,13 @@ mod tests { assert!(claims.has_permission(permissions::ADMIN_QUERY)); assert!(!claims.has_permission(permissions::SUPER_ADMIN)); } + + #[test] + fn test_bypass_claims_has_full_permissions() { + let claims = bypass_claims(); + assert_eq!(claims.sub, "dev-bypass"); + assert_eq!(claims.permissions_mask(), u128::MAX); + assert!(claims.has_permission(permissions::SUPER_ADMIN)); + assert!(claims.has_permission(permissions::ADMIN_QUERY)); + } } diff --git a/src/config.rs b/src/config.rs index aee1b76..918b011 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,9 @@ pub struct Config { pub cdn_endpoint: String, /// Bucket name used for CDN object storage. pub cdn_bucket: String, + /// Dev-only: when true, all auth/permission checks are bypassed and every + /// request is treated as a super-admin. NEVER enable in production. + pub disable_auth: bool, } impl Config { @@ -42,6 +45,9 @@ impl Config { cdn_endpoint: std::env::var("CDN_ENDPOINT") .unwrap_or_else(|_| "http://localhost:9000".into()), cdn_bucket: std::env::var("CDN_BUCKET").unwrap_or_else(|_| "mercury".into()), + disable_auth: std::env::var("DISABLE_AUTH") + .map(|v| matches!(v.trim().to_lowercase().as_str(), "1" | "true")) + .unwrap_or(false), }) } } @@ -49,6 +55,11 @@ impl Config { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + // Serializes tests that mutate the process-global DISABLE_AUTH env var, + // since cargo runs tests in parallel by default. + static DISABLE_AUTH_ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn test_config_defaults() { @@ -61,4 +72,36 @@ mod tests { assert_eq!(cfg.cache_sweep_interval_secs, 60); assert!(cfg.cors_origins.is_empty()); } + + #[test] + fn test_config_disable_auth_default_false() { + let _guard = DISABLE_AUTH_ENV_LOCK.lock().unwrap(); + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + std::env::remove_var("DISABLE_AUTH"); + let cfg = Config::from_env().unwrap(); + assert!(!cfg.disable_auth); + } + + #[test] + fn test_config_disable_auth_true() { + let _guard = DISABLE_AUTH_ENV_LOCK.lock().unwrap(); + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + std::env::set_var("DISABLE_AUTH", "true"); + let cfg = Config::from_env().unwrap(); + assert!(cfg.disable_auth); + std::env::remove_var("DISABLE_AUTH"); + } + + #[test] + fn test_config_disable_auth_numeric_true() { + let _guard = DISABLE_AUTH_ENV_LOCK.lock().unwrap(); + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + std::env::set_var("DISABLE_AUTH", "1"); + let cfg = Config::from_env().unwrap(); + assert!(cfg.disable_auth); + std::env::remove_var("DISABLE_AUTH"); + } } diff --git a/src/main.rs b/src/main.rs index 6caf0e6..fb54d8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ use crate::{ models::blacklist::BlacklistEntry, routes::{ admin::admin_router, - auth::login, + auth::{auth_config, login}, cdn::{cdn_create, cdn_delete, cdn_list, cdn_proxy, cdn_update, cdn_upload}, crud::handle_crud, }, @@ -190,6 +190,7 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/auth/login", post(login)) + .route("/auth/config", get(auth_config)) .merge(cdn_routes) .merge(crud_routes) .nest("/api/admin", admin_router(state.clone())) diff --git a/src/routes/admin/tables.rs b/src/routes/admin/tables.rs index 695d2a8..902a4e4 100644 --- a/src/routes/admin/tables.rs +++ b/src/routes/admin/tables.rs @@ -112,6 +112,9 @@ pub async fn get_table_preview( if !crate::routes::is_valid_identifier(&name) { return Err(StatusCode::BAD_REQUEST); } + // Unquoted identifiers are folded to lowercase by Postgres at CREATE TABLE + // time; normalize here so a differently-cased lookup still resolves. + let name = name.to_lowercase(); let col_rows = sqlx::query( "SELECT column_name, data_type, is_nullable \ @@ -169,7 +172,10 @@ pub async fn create_table( if !crate::routes::is_valid_identifier(&body.name) { return Err(StatusCode::BAD_REQUEST); } - if is_protected(&body.name) { + // Postgres folds unquoted identifiers to lowercase at parse time; normalize + // up front so the name we return matches what callers can look up later. + let table_name = body.name.to_lowercase(); + if is_protected(&table_name) { return Err(StatusCode::FORBIDDEN); } @@ -183,6 +189,10 @@ pub async fn create_table( if !crate::routes::is_valid_identifier(&col.name) { return Err(StatusCode::BAD_REQUEST); } + let col_name = col.name.to_lowercase(); + if col_name == "id" { + return Err(StatusCode::BAD_REQUEST); + } let upper_type = col.col_type.to_uppercase(); if !ALLOWED_TYPES.contains(&upper_type.as_str()) { @@ -191,10 +201,10 @@ pub async fn create_table( let nullable = col.nullable.unwrap_or(true); let null_clause = if nullable { "" } else { " NOT NULL" }; - col_defs.push(format!("{} {}{}", col.name, upper_type, null_clause)); + col_defs.push(format!("{} {}{}", col_name, upper_type, null_clause)); } - let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", ")); + let sql = format!("CREATE TABLE {} ({})", table_name, col_defs.join(", ")); sqlx::query(&sql).execute(&state.pool).await.map_err(|e| { tracing::error!("create table error: {}", e); @@ -202,7 +212,7 @@ pub async fn create_table( })?; Ok(Json(TableInfo { - table_name: body.name, + table_name, column_count: body.columns.len() as i64 + 1, })) } diff --git a/src/routes/auth.rs b/src/routes/auth.rs index e4cdf40..9f1210e 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -34,3 +34,7 @@ pub async fn login( Ok(Json(json!({ "token": token }))) } + +pub async fn auth_config(State(state): State) -> Json { + Json(json!({ "disable_auth": state.config.disable_auth })) +} diff --git a/src/routes/crud.rs b/src/routes/crud.rs index c44777f..86e127f 100644 --- a/src/routes/crud.rs +++ b/src/routes/crud.rs @@ -28,6 +28,21 @@ fn coerce_id(id_val: &str) -> Value { } } +// Object/array values are bound as JSON text (see the bind loop in handle_crud); +// an explicit cast is required for that text to land in a jsonb column, since +// there's no column-type awareness here to pick it automatically. +// ponytail: bare scalar JSON (string/number/bool/null) destined for a jsonb +// column isn't detected — indistinguishable at this layer from a plain scalar +// column value. Widen if that turns out to matter. +fn placeholder(index: usize, value: &Value) -> String { + let base = format!("${}", index); + if matches!(value, Value::Object(_) | Value::Array(_)) { + format!("{}::jsonb", base) + } else { + base + } +} + pub fn build_query( method: &str, table: &str, @@ -90,7 +105,11 @@ pub fn build_query( return Err(anyhow!("POST requires a body with at least one field")); } let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); - let placeholders: Vec = (1..=cols.len()).map(|i| format!("${}", i)).collect(); + let placeholders: Vec = sorted_body + .iter() + .enumerate() + .map(|(i, (_, v))| placeholder(i + 1, v)) + .collect(); let sql = format!( "INSERT INTO {} ({}) VALUES ({}) RETURNING *", table, @@ -107,10 +126,10 @@ pub fn build_query( return Err(anyhow!("PUT requires a body with at least one field")); } let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); - let set_clause: Vec = cols + let set_clause: Vec = sorted_body .iter() .enumerate() - .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .map(|(i, (c, v))| format!("{} = {}", c, placeholder(i + 1, v))) .collect(); let id_placeholder = cols.len() + 1; let sql = format!( @@ -171,6 +190,9 @@ pub fn pg_row_to_json(row: PgRow) -> Value { .try_get::, _>(col.ordinal()) .map(|v| Value::String(v.to_rfc3339())) .unwrap_or(Value::Null), + "JSON" | "JSONB" => row + .try_get::(col.ordinal()) + .unwrap_or(Value::Null), _ => row .try_get::(col.ordinal()) .map(Value::String) @@ -432,6 +454,29 @@ mod tests { assert_eq!(key, "GET:users:role,status"); } + #[test] + fn test_build_insert_casts_json_object_to_jsonb() { + let cols = vec![ + ("payload".into(), serde_json::json!({"a": 1})), + ("name".into(), Value::String("Alice".into())), + ]; + let (sql, _, _) = build_query("POST", "items", None, &cols, &[]).unwrap(); + assert_eq!( + sql, + "INSERT INTO items (name, payload) VALUES ($1, $2::jsonb) RETURNING *" + ); + } + + #[test] + fn test_build_update_casts_json_array_to_jsonb() { + let cols = vec![("tags".into(), serde_json::json!(["a", "b"]))]; + let (sql, _, _) = build_query("PUT", "items", Some("1"), &cols, &[]).unwrap(); + assert_eq!( + sql, + "UPDATE items SET tags = $1::jsonb WHERE id = $2 RETURNING *" + ); + } + #[test] fn test_build_null_body_value() { let cols = vec![("note".into(), Value::Null)]; diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts index f942970..2588b86 100644 --- a/ui/src/router/index.ts +++ b/ui/src/router/index.ts @@ -25,8 +25,9 @@ const router = createRouter({ ], }) -router.beforeEach((to) => { +router.beforeEach(async (to) => { const auth = useAuthStore() + await auth.checkAuthDisabled() if (to.meta.requiresAuth && !auth.isAuthenticated) { return '/login' } diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts index 4fe8b5f..425c249 100644 --- a/ui/src/stores/auth.ts +++ b/ui/src/stores/auth.ts @@ -21,19 +21,42 @@ export const useAuthStore = defineStore("auth", () => { const claims = computed(() => token.value ? parseJwt(token.value) : null, ); + + // DISABLE_AUTH dev mode: fetched once from the backend at boot. + const authDisabled = ref(false); + let configCheckPromise: Promise | null = null; + + function checkAuthDisabled(): Promise { + if (!configCheckPromise) { + configCheckPromise = fetch("/auth/config") + .then((res) => (res.ok ? res.json() : { disable_auth: false })) + .then((data) => { + authDisabled.value = Boolean(data.disable_auth); + }) + .catch(() => { + authDisabled.value = false; + }); + } + return configCheckPromise; + } + const isAuthenticated = computed(() => { + if (authDisabled.value) return true; if (!claims.value) return false; return claims.value.exp * 1000 > Date.now(); }); - const username = computed(() => claims.value?.sub ?? ""); + const username = computed(() => + authDisabled.value ? "dev-bypass" : (claims.value?.sub ?? ""), + ); function hasPermission(bit: bigint): boolean { + if (authDisabled.value) return true; if (!claims.value) return false; const mask = BigInt(claims.value.permissions); return (mask & bit) !== 0n; } - const isSuperAdmin = computed(() => hasPermission(32n)); + const isSuperAdmin = computed(() => authDisabled.value || hasPermission(32n)); async function login(username: string, password: string): Promise { const res = await fetch("/auth/login", { @@ -59,6 +82,8 @@ export const useAuthStore = defineStore("auth", () => { return { token, claims, + authDisabled, + checkAuthDisabled, isAuthenticated, username, isSuperAdmin, diff --git a/ui/src/views/admin/ApiKeys.vue b/ui/src/views/admin/ApiKeys.vue index 6957c94..4822d2a 100644 --- a/ui/src/views/admin/ApiKeys.vue +++ b/ui/src/views/admin/ApiKeys.vue @@ -39,7 +39,7 @@ {{ k.expires_at ? fmtDate(k.expires_at) : '—' }} {{ k.last_used_at ? fmtDate(k.last_used_at) : 'Never' }} - Revoke + Revoke @@ -53,7 +53,7 @@ - + New API Key
@@ -63,8 +63,8 @@
- - + {{ form.permissions_mask ? roleName(form.permissions_mask) : '' }} + {{ r.label }} @@ -74,14 +74,14 @@
- Generate Key + Generate Key - + API Key Created
@@ -149,32 +149,46 @@ async function load() { } } +const creating = ref(false) + async function submitCreate() { - const body: any = { - name: form.value.name, - permissions_mask: form.value.permissions_mask, + creating.value = true + try { + const body: any = { + name: form.value.name, + permissions_mask: form.value.permissions_mask, + } + if (form.value.expires_at) { + body.expires_at = new Date(form.value.expires_at).toISOString() + } + const res = await fetch('/api/admin/api-keys', { + method: 'POST', + headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json() + showCreate.value = false + form.value = { name: '', permissions_mask: '', expires_at: '' } + newKey.value = data.key + copied.value = false + showReveal.value = true + await load() + } finally { + creating.value = false } - if (form.value.expires_at) { - body.expires_at = new Date(form.value.expires_at).toISOString() - } - const res = await fetch('/api/admin/api-keys', { - method: 'POST', - headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - const data = await res.json() - showCreate.value = false - form.value = { name: '', permissions_mask: '', expires_at: '' } - newKey.value = data.key - copied.value = false - showReveal.value = true - load() } +const revokingId = ref(null) + async function revoke(id: number, name: string) { if (!confirm(`Revoke key "${name}"? Any services using it will lose access immediately.`)) return - await fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE', headers: auth.authHeaders() }) - load() + revokingId.value = id + try { + await fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE', headers: auth.authHeaders() }) + await load() + } finally { + revokingId.value = null + } } async function copyKey() { diff --git a/ui/src/views/admin/Blacklist.vue b/ui/src/views/admin/Blacklist.vue index 9944c92..b09bf8f 100644 --- a/ui/src/views/admin/Blacklist.vue +++ b/ui/src/views/admin/Blacklist.vue @@ -47,7 +47,7 @@ {{ e.bypass_mask }} Edit - Delete + Delete @@ -60,7 +60,7 @@
- + Edit Blacklist Entry
@@ -82,20 +82,20 @@
- - + {{ editForm.active === 'true' ? 'Active' : 'Disabled' }} + Active Disabled
- Save Changes + Save Changes - + New Blacklist Entry
@@ -116,7 +116,7 @@

Permission bit that allows callers to bypass this rule. Leave blank to block everyone.

- Add to Blacklist + Add to Blacklist
@@ -157,21 +157,28 @@ function stringToMethods(s: string | null | undefined): string[] { return s ? s.split(',').map(m => m.trim()).filter(Boolean) : [] } +const creating = ref(false) + async function submitCreate() { - await fetch('/api/blacklist', { - method: 'POST', - headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ - pattern: form.value.pattern, - method: methodsToString(form.value.methods), - reason: form.value.reason || null, - bypass_mask: form.value.bypass_mask || null, - active: true, - }), - }) - showCreate.value = false - form.value = { pattern: '', methods: [], reason: '', bypass_mask: '' } - load() + creating.value = true + try { + await fetch('/api/blacklist', { + method: 'POST', + headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pattern: form.value.pattern, + method: methodsToString(form.value.methods), + reason: form.value.reason || null, + bypass_mask: form.value.bypass_mask || null, + active: true, + }), + }) + showCreate.value = false + form.value = { pattern: '', methods: [], reason: '', bypass_mask: '' } + await load() + } finally { + creating.value = false + } } function openEdit(e: any) { @@ -186,26 +193,40 @@ function openEdit(e: any) { showEdit.value = true } +const saving = ref(false) + async function submitEdit() { - await fetch(`/api/blacklist/${editId.value}`, { - method: 'PUT', - headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ - pattern: editForm.value.pattern, - method: methodsToString(editForm.value.methods), - reason: editForm.value.reason || null, - bypass_mask: editForm.value.bypass_mask || null, - active: editForm.value.active === 'true', - }), - }) - showEdit.value = false - load() + saving.value = true + try { + await fetch(`/api/blacklist/${editId.value}`, { + method: 'PUT', + headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pattern: editForm.value.pattern, + method: methodsToString(editForm.value.methods), + reason: editForm.value.reason || null, + bypass_mask: editForm.value.bypass_mask || null, + active: editForm.value.active === 'true', + }), + }) + showEdit.value = false + await load() + } finally { + saving.value = false + } } +const deletingId = ref(null) + async function deleteEntry(id: number) { if (!confirm('Delete this blacklist entry?')) return - await fetch(`/api/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() }) - load() + deletingId.value = id + try { + await fetch(`/api/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() }) + await load() + } finally { + deletingId.value = null + } } onMounted(load) diff --git a/ui/src/views/admin/Cache.vue b/ui/src/views/admin/Cache.vue index 80b8399..6c30c5d 100644 --- a/ui/src/views/admin/Cache.vue +++ b/ui/src/views/admin/Cache.vue @@ -6,8 +6,8 @@ In-memory LRU cache for compiled SQL queries — inspect and flush as needed
- Refresh - Flush Cache + Refresh + Flush Cache
@@ -67,14 +67,26 @@ const hitRateClass = computed(() => { }) async function load() { - const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() }) - stats.value = await res.json() + loading.value = true + try { + const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() }) + stats.value = await res.json() + } finally { + loading.value = false + } } +const flushing = ref(false) + async function flushCache() { if (!confirm('Flush the entire query cache?')) return - await fetch('/api/admin/cache/', { method: 'DELETE', headers: auth.authHeaders() }) - load() + flushing.value = true + try { + await fetch('/api/admin/cache/', { method: 'DELETE', headers: auth.authHeaders() }) + await load() + } finally { + flushing.value = false + } } onMounted(load) diff --git a/ui/src/views/admin/Cdn.vue b/ui/src/views/admin/Cdn.vue index 8c1a262..3fa4786 100644 --- a/ui/src/views/admin/Cdn.vue +++ b/ui/src/views/admin/Cdn.vue @@ -34,7 +34,7 @@ {{ o.description ?? '—' }} Edit - Delete + Delete @@ -49,7 +49,7 @@ - + Upload CDN Object
@@ -69,13 +69,13 @@
- Upload + Upload
- + Edit — {{ editKey }}
@@ -90,7 +90,7 @@
- Save Changes + Save Changes
@@ -126,9 +126,11 @@ async function load() { } } +const creating = ref(false) + async function submitCreate() { if (!selectedFile.value) return - loading.value = true + creating.value = true try { const fd = new FormData() fd.append('file', selectedFile.value) @@ -146,7 +148,7 @@ async function submitCreate() { form.value = { key: '', content_type: '', description: '' } await load() } finally { - loading.value = false + creating.value = false } } @@ -160,8 +162,10 @@ function openEdit(o: any) { showEdit.value = true } +const saving = ref(false) + async function submitEdit() { - loading.value = true + saving.value = true try { const res = await fetch(`/api/cdn/${editKey.value}`, { method: 'PUT', @@ -176,19 +180,21 @@ async function submitEdit() { showEdit.value = false await load() } finally { - loading.value = false + saving.value = false } } +const deletingKey = ref(null) + async function deleteObject(key: string) { if (!confirm(`Delete CDN object "${key}"?`)) return - loading.value = true + deletingKey.value = key try { const res = await fetch(`/api/cdn/${key}`, { method: 'DELETE', headers: auth.authHeaders() }) if (!res.ok) return await load() } finally { - loading.value = false + deletingKey.value = null } } diff --git a/ui/src/views/admin/Cors.vue b/ui/src/views/admin/Cors.vue index 7c4e870..d389569 100644 --- a/ui/src/views/admin/Cors.vue +++ b/ui/src/views/admin/Cors.vue @@ -31,7 +31,7 @@ {{ o.origin }} {{ fmtDate(o.created_at) }} - Delete + Delete @@ -46,7 +46,7 @@ - + Add CORS Origin
@@ -54,7 +54,7 @@

Use * to allow all origins (permissive mode).

- Add Origin + Add Origin
@@ -85,8 +85,10 @@ async function load() { } } +const creating = ref(false) + async function submitCreate() { - loading.value = true + creating.value = true try { await fetch('/api/cors_origins', { method: 'POST', @@ -97,18 +99,20 @@ async function submitCreate() { form.value = { origin: '' } await load() } finally { - loading.value = false + creating.value = false } } +const deletingId = ref(null) + async function deleteOrigin(id: number) { if (!confirm('Remove this CORS origin?')) return - loading.value = true + deletingId.value = id try { await fetch(`/api/cors_origins/${id}`, { method: 'DELETE', headers: auth.authHeaders() }) await load() } finally { - loading.value = false + deletingId.value = null } } diff --git a/ui/src/views/admin/Layout.vue b/ui/src/views/admin/Layout.vue index 37837fb..bee570c 100644 --- a/ui/src/views/admin/Layout.vue +++ b/ui/src/views/admin/Layout.vue @@ -31,6 +31,7 @@ {{ isDark ? "☀" : "☾" }}