Compare commits

..

3 commits

Author SHA1 Message Date
10f6e3aebc Merge pull request 'fix: cleanup' (#1) from worktree-disable-auth into main
All checks were successful
ci / build-ui (push) Successful in 17s
ci / test (push) Successful in 4m1s
ci / publish (push) Successful in 3m29s
Reviewed-on: #1
2026-08-10 08:47:57 -04:00
41fac552c5 style: fix rustfmt formatting
All checks were successful
ci / build-ui (pull_request) Successful in 14s
ci / test (pull_request) Successful in 3m19s
ci / publish (pull_request) Has been skipped
CI's fmt --check step was failing on an unformatted assert_eq! line.
2026-08-10 08:41:37 -04:00
2d95265e7d fix: cleanup
Some checks failed
ci / build-ui (pull_request) Successful in 21s
ci / test (pull_request) Failing after 34s
ci / publish (pull_request) Has been skipped
2026-08-09 14:34:28 -04:00
25 changed files with 1593 additions and 233 deletions

View file

@ -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/<filename>

View file

@ -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"

View file

@ -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. |
---

View file

@ -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:

View file

@ -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<Claims>` (`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<Claims> {
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<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
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<AppState>) -> Json<Value> {
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<void>`, `useAuthStore().authDisabled: Ref<boolean>` — 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<string | null>(localStorage.getItem("mercury_token"));
const claims = computed<Claims | null>(() =>
token.value ? parseJwt(token.value) : null,
);
// DISABLE_AUTH dev mode: fetched once from the backend at boot.
const authDisabled = ref(false);
let configCheckPromise: Promise<void> | null = null;
function checkAuthDisabled(): Promise<void> {
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<void> {
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<string, string> {
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).

View file

@ -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).

View file

@ -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<Claims> {
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<AppState>,
mut req: Request,
@ -33,14 +45,10 @@ pub async fn blacklist_layer(
) -> Result<Response, StatusCode> {
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<Response, StatusCode> {
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<Response, StatusCode> {
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<Response, StatusCode> {
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<Response, StatusCode> {
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<AppState>, 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());
}
}

View file

@ -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));
}
}

View file

@ -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");
}
}

View file

@ -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()))

View file

@ -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,
}))
}

View file

@ -34,3 +34,7 @@ pub async fn login(
Ok(Json(json!({ "token": token })))
}
pub async fn auth_config(State(state): State<AppState>) -> Json<Value> {
Json(json!({ "disable_auth": state.config.disable_auth }))
}

View file

@ -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<String> = sorted_body.iter().map(|(c, _)| c.clone()).collect();
let placeholders: Vec<String> = (1..=cols.len()).map(|i| format!("${}", i)).collect();
let placeholders: Vec<String> = 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<String> = sorted_body.iter().map(|(c, _)| c.clone()).collect();
let set_clause: Vec<String> = cols
let set_clause: Vec<String> = 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::<chrono::DateTime<chrono::Utc>, _>(col.ordinal())
.map(|v| Value::String(v.to_rfc3339()))
.unwrap_or(Value::Null),
"JSON" | "JSONB" => row
.try_get::<Value, _>(col.ordinal())
.unwrap_or(Value::Null),
_ => row
.try_get::<String, _>(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)];

View file

@ -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'
}

View file

@ -21,19 +21,42 @@ export const useAuthStore = defineStore("auth", () => {
const claims = computed<Claims | null>(() =>
token.value ? parseJwt(token.value) : null,
);
// DISABLE_AUTH dev mode: fetched once from the backend at boot.
const authDisabled = ref(false);
let configCheckPromise: Promise<void> | null = null;
function checkAuthDisabled(): Promise<void> {
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<void> {
const res = await fetch("/auth/login", {
@ -59,6 +82,8 @@ export const useAuthStore = defineStore("auth", () => {
return {
token,
claims,
authDisabled,
checkAuthDisabled,
isAuthenticated,
username,
isSuperAdmin,

View file

@ -39,7 +39,7 @@
<td class="date-cell">{{ k.expires_at ? fmtDate(k.expires_at) : '—' }}</td>
<td class="date-cell">{{ k.last_used_at ? fmtDate(k.last_used_at) : 'Never' }}</td>
<td class="actions-cell">
<NychButton size="sm" variant="danger" @click="revoke(k.id, k.name)">Revoke</NychButton>
<NychButton size="sm" variant="danger" :loading="revokingId === k.id" @click="revoke(k.id, k.name)">Revoke</NychButton>
</td>
</tr>
</tbody>
@ -53,7 +53,7 @@
<!-- Create dialog -->
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(560px,95vw)]">
<NychDialogContent class="w-[min(560px,95vw)] sm:max-w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>New API Key</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -63,8 +63,8 @@
<div class="field">
<label>Role</label>
<NychSelect v-model="form.permissions_mask" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role" /></NychSelectTrigger>
<NychSelectContent>
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role">{{ form.permissions_mask ? roleName(form.permissions_mask) : '' }}</NychSelectValue></NychSelectTrigger>
<NychSelectContent class="z-[60]">
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
@ -74,14 +74,14 @@
<label>Expires <span class="optional">(optional leave blank for no expiry)</span></label>
<NychInput v-model="form.expires_at" type="datetime-local" class="w-full" />
</div>
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" class="w-full">Generate Key</NychButton>
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" :loading="creating" class="w-full">Generate Key</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<!-- Key reveal dialog shown once after creation -->
<NychDialog v-model:open="showReveal">
<NychDialogContent class="w-[min(600px,95vw)]" :show-close-button="false">
<NychDialogContent class="w-[min(600px,95vw)] sm:max-w-[min(600px,95vw)]" :show-close-button="false">
<NychDialogHeader><NychDialogTitle>API Key Created</NychDialogTitle></NychDialogHeader>
<div class="reveal-body">
<NychAlert variant="warning" class="reveal-warning">
@ -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<number | null>(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() {

View file

@ -47,7 +47,7 @@
<td><span v-if="e.bypass_mask" class="bit-badge">{{ e.bypass_mask }}</span><span v-else class="dim"></span></td>
<td class="actions-cell">
<NychButton size="sm" @click="openEdit(e)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteEntry(e.id)">Delete</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingId === e.id" @click="deleteEntry(e.id)">Delete</NychButton>
</td>
</tr>
</tbody>
@ -60,7 +60,7 @@
</div>
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(640px,95vw)]">
<NychDialogContent class="w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit Blacklist Entry</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
@ -82,20 +82,20 @@
<div class="field">
<label>Status</label>
<NychSelect v-model="editForm.active" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
<NychSelectContent>
<NychSelectTrigger class="w-full"><NychSelectValue>{{ editForm.active === 'true' ? 'Active' : 'Disabled' }}</NychSelectValue></NychSelectTrigger>
<NychSelectContent class="z-[60]">
<NychSelectItem value="true">Active</NychSelectItem>
<NychSelectItem value="false">Disabled</NychSelectItem>
</NychSelectContent>
</NychSelect>
</div>
<NychButton type="submit" class="w-full">Save Changes</NychButton>
<NychButton type="submit" class="w-full" :loading="saving">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(640px,95vw)]">
<NychDialogContent class="w-[min(640px,95vw)] sm:max-w-[min(640px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Blacklist Entry</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -116,7 +116,7 @@
<NychInput v-model="form.bypass_mask" placeholder="32" class="w-full" />
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
</div>
<NychButton type="submit" class="w-full">Add to Blacklist</NychButton>
<NychButton type="submit" class="w-full" :loading="creating">Add to Blacklist</NychButton>
</form>
</NychDialogContent>
</NychDialog>
@ -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<number | null>(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)

View file

@ -6,8 +6,8 @@
<span class="subtitle">In-memory LRU cache for compiled SQL queries inspect and flush as needed</span>
</div>
<div class="header-actions">
<NychButton @click="load">Refresh</NychButton>
<NychButton variant="danger" @click="flushCache">Flush Cache</NychButton>
<NychButton :loading="loading" @click="load">Refresh</NychButton>
<NychButton variant="danger" :loading="flushing" @click="flushCache">Flush Cache</NychButton>
</div>
</div>
@ -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)

View file

@ -34,7 +34,7 @@
<td class="desc-cell">{{ o.description ?? '—' }}</td>
<td class="actions-cell">
<NychButton size="sm" @click="openEdit(o)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteObject(o.key)">Delete</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingKey === o.key" @click="deleteObject(o.key)">Delete</NychButton>
</td>
</tr>
</tbody>
@ -49,7 +49,7 @@
</div>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(560px,95vw)]">
<NychDialogContent class="w-[min(560px,95vw)] sm:max-w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>Upload CDN Object</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -69,13 +69,13 @@
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="form.description" placeholder="App logo" class="w-full" />
</div>
<NychButton type="submit" class="w-full" :disabled="!selectedFile || loading">Upload</NychButton>
<NychButton type="submit" class="w-full" :disabled="!selectedFile || creating" :loading="creating">Upload</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(560px,95vw)]">
<NychDialogContent class="w-[min(560px,95vw)] sm:max-w-[min(560px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit {{ editKey }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
@ -90,7 +90,7 @@
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.description" class="w-full" />
</div>
<NychButton type="submit" class="w-full" :disabled="loading">Save Changes</NychButton>
<NychButton type="submit" class="w-full" :disabled="saving" :loading="saving">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
@ -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<string | null>(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
}
}

View file

@ -31,7 +31,7 @@
<td><code>{{ o.origin }}</code></td>
<td class="date-cell">{{ fmtDate(o.created_at) }}</td>
<td class="actions-cell">
<NychButton size="sm" variant="danger" @click="deleteOrigin(o.id)">Delete</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingId === o.id" @click="deleteOrigin(o.id)">Delete</NychButton>
</td>
</tr>
</tbody>
@ -46,7 +46,7 @@
</div>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(480px,95vw)]">
<NychDialogContent class="w-[min(480px,95vw)] sm:max-w-[min(480px,95vw)]">
<NychDialogHeader><NychDialogTitle>Add CORS Origin</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -54,7 +54,7 @@
<NychInput v-model="form.origin" placeholder="https://app.example.com" class="w-full" />
<p class="hint">Use <code>*</code> to allow all origins (permissive mode).</p>
</div>
<NychButton type="submit" class="w-full" :disabled="!form.origin || loading">Add Origin</NychButton>
<NychButton type="submit" class="w-full" :disabled="!form.origin || creating" :loading="creating">Add Origin</NychButton>
</form>
</NychDialogContent>
</NychDialog>
@ -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<number | null>(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
}
}

View file

@ -31,6 +31,7 @@
{{ isDark ? "☀" : "☾" }}
</button>
<button
v-if="!auth.authDisabled"
class="icon-btn danger-btn"
title="Sign out"
@click="handleLogout"

View file

@ -34,7 +34,7 @@
<td class="desc-cell">{{ p.description ?? '—' }}</td>
<td class="actions-cell">
<NychButton size="sm" @click="openEdit(p)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deletePermission(p.id)">Delete</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingId === p.id" @click="deletePermission(p.id)">Delete</NychButton>
</td>
</tr>
</tbody>
@ -47,7 +47,7 @@
</div>
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(600px,95vw)]">
<NychDialogContent class="w-[min(600px,95vw)] sm:max-w-[min(600px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit {{ editForm.name }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
@ -58,13 +58,13 @@
<label>Description</label>
<NychInput v-model="editForm.description" class="w-full" />
</div>
<NychButton type="submit" class="w-full">Save Changes</NychButton>
<NychButton type="submit" class="w-full" :loading="saving">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(600px,95vw)]">
<NychDialogContent class="w-[min(600px,95vw)] sm:max-w-[min(600px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Permission</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -80,7 +80,7 @@
<label>Description</label>
<NychInput v-model="form.description" placeholder="What this permission grants" class="w-full" />
</div>
<NychButton type="submit" class="w-full">Create Permission</NychButton>
<NychButton type="submit" class="w-full" :loading="creating">Create Permission</NychButton>
</form>
</NychDialogContent>
</NychDialog>
@ -110,15 +110,22 @@ async function load() {
}
}
const creating = ref(false)
async function submitCreate() {
await fetch('/api/permissions', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
showCreate.value = false
form.value = { name: '', bit_value: '', description: '' }
load()
creating.value = true
try {
await fetch('/api/permissions', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
showCreate.value = false
form.value = { name: '', bit_value: '', description: '' }
await load()
} finally {
creating.value = false
}
}
function openEdit(p: any) {
@ -127,20 +134,34 @@ function openEdit(p: any) {
showEdit.value = true
}
const saving = ref(false)
async function submitEdit() {
await fetch(`/api/permissions/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(editForm.value),
})
showEdit.value = false
load()
saving.value = true
try {
await fetch(`/api/permissions/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(editForm.value),
})
showEdit.value = false
await load()
} finally {
saving.value = false
}
}
const deletingId = ref<number | null>(null)
async function deletePermission(id: number) {
if (!confirm('Delete this permission?')) return
await fetch(`/api/permissions/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
deletingId.value = id
try {
await fetch(`/api/permissions/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
await load()
} finally {
deletingId.value = null
}
}
onMounted(load)

View file

@ -32,7 +32,7 @@
<td class="date-cell">{{ new Date(q.updated_at).toLocaleString() }}</td>
<td class="actions-cell">
<NychButton size="sm" @click="openEdit(q)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteQuery(q)">Delete</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingId === q.id" @click="deleteQuery(q)">Delete</NychButton>
</td>
</tr>
</tbody>
@ -45,7 +45,7 @@
</div>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(800px,95vw)]">
<NychDialogContent class="w-[min(800px,95vw)] sm:max-w-[min(800px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Query</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -61,13 +61,13 @@
<NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" class="w-full" />
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
</div>
<NychButton type="submit" class="w-full">Save Query</NychButton>
<NychButton type="submit" class="w-full" :loading="creating">Save Query</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(800px,95vw)]">
<NychDialogContent class="w-[min(800px,95vw)] sm:max-w-[min(800px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit {{ editIdentifier }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
@ -79,7 +79,7 @@
<label>Description <span class="optional">(optional)</span></label>
<NychInput v-model="editForm.description" placeholder="Brief description" class="w-full" />
</div>
<NychButton type="submit" class="w-full">Update Query</NychButton>
<NychButton type="submit" class="w-full" :loading="saving">Update Query</NychButton>
</form>
</NychDialogContent>
</NychDialog>
@ -110,15 +110,22 @@ async function load() {
}
}
const creating = ref(false)
async function submitCreate() {
await fetch('/api/queries', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
showCreate.value = false
form.value = { identifier: '', sql_template: '', description: '' }
load()
creating.value = true
try {
await fetch('/api/queries', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
showCreate.value = false
form.value = { identifier: '', sql_template: '', description: '' }
await load()
} finally {
creating.value = false
}
}
function openEdit(row: any) {
@ -128,20 +135,34 @@ function openEdit(row: any) {
showEdit.value = true
}
const saving = ref(false)
async function submitEdit() {
await fetch(`/api/queries/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(editForm.value),
})
showEdit.value = false
load()
saving.value = true
try {
await fetch(`/api/queries/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(editForm.value),
})
showEdit.value = false
await load()
} finally {
saving.value = false
}
}
const deletingId = ref('')
async function deleteQuery(row: any) {
if (!confirm(`Delete query "${row.identifier}"?`)) return
await fetch(`/api/queries/${row.id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
deletingId.value = row.id
try {
await fetch(`/api/queries/${row.id}`, { method: 'DELETE', headers: auth.authHeaders() })
await load()
} finally {
deletingId.value = ''
}
}
onMounted(load)

View file

@ -30,7 +30,11 @@
<td class="count-cell">{{ t.column_count }}</td>
<td class="actions-cell">
<NychButton size="sm" variant="secondary" @click="openInspect(t.table_name)">Inspect</NychButton>
<NychButton size="sm" variant="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
<template v-if="!isProtected(t.table_name)">
<NychButton size="sm" @click="openBrowse(t.table_name)">Browse</NychButton>
<NychButton size="sm" variant="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
</template>
<span v-else class="protected-hint" title="Core table — managed via its dedicated admin page">protected</span>
</td>
</tr>
</tbody>
@ -44,13 +48,19 @@
<!-- Create Table Dialog -->
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(700px,95vw)]">
<NychDialogContent class="w-[min(880px,95vw)] sm:max-w-[min(880px,95vw)]">
<NychDialogHeader><NychDialogTitle>New Table</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
<label>Table Name</label>
<NychInput v-model="createForm.name" placeholder="my_table" class="w-full" autocomplete="off" />
<p class="hint">Lowercase letters, numbers, and underscores only.</p>
<NychInput
:model-value="createForm.name"
@update:model-value="(v: string | number) => (createForm.name = String(v).toLowerCase())"
placeholder="my_table"
class="w-full"
autocomplete="off"
/>
<p class="hint">Letters, numbers, and underscores only automatically lowercased.</p>
</div>
<div class="columns-section">
@ -58,7 +68,7 @@
<label>Columns</label>
<NychButton type="button" size="sm" @click="addColumn">+ Add Column</NychButton>
</div>
<div class="hint fixed-col-hint">An <code>id SERIAL PRIMARY KEY</code> column is always added automatically.</div>
<div class="hint fixed-col-hint"> Don't add an <code>id</code> column <code>id SERIAL PRIMARY KEY</code> is added automatically.</div>
<div class="column-row header-row">
<span>Name</span>
@ -67,34 +77,45 @@
<span></span>
</div>
<div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
<NychInput v-model="col.name" placeholder="column_name" class="w-full" />
<NychSelect v-model="col.col_type" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
<NychSelectContent>
<NychSelectItem v-for="ct in COLUMN_TYPES" :key="ct" :value="ct">{{ ct }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<div class="nullable-toggle">
<input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
<label :for="`nullable-${i}`">Yes</label>
<template v-for="(col, i) in createForm.columns" :key="i">
<div class="column-row">
<NychInput
:model-value="col.name"
@update:model-value="(v: string | number) => (col.name = String(v).toLowerCase())"
placeholder="column_name"
class="w-full"
:class="{ 'input-error': isIdColumn(col.name) }"
/>
<NychSelect v-model="col.col_type" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue>{{ col.col_type }}</NychSelectValue></NychSelectTrigger>
<NychSelectContent class="z-[60]">
<NychSelectItem v-for="ct in COLUMN_TYPES" :key="ct" :value="ct">{{ ct }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<div class="nullable-toggle">
<input type="checkbox" v-model="col.nullable" :id="`nullable-${i}`" />
<label :for="`nullable-${i}`">Yes</label>
</div>
<button type="button" class="remove-btn" @click="removeColumn(i)"></button>
</div>
<button type="button" class="remove-btn" @click="removeColumn(i)"></button>
</div>
<div v-if="isIdColumn(col.name)" class="column-error">
"id" is added automatically choose a different name.
</div>
</template>
<div class="empty-columns" v-if="createForm.columns.length === 0">
<span>Add at least one column.</span>
</div>
</div>
<NychButton type="submit" class="w-full" :disabled="!canSubmitCreate">Create Table</NychButton>
<NychButton type="submit" class="w-full" :disabled="!canSubmitCreate" :loading="creating">Create Table</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<!-- Inspect Dialog (read-only) -->
<NychDialog v-model:open="showInspect">
<NychDialogContent class="w-[min(760px,95vw)]">
<NychDialogContent class="w-fit min-w-[420px] max-w-[95vw] sm:max-w-[95vw]">
<NychDialogHeader><NychDialogTitle>Inspect {{ inspectPreview?.table_name ?? '' }}</NychDialogTitle></NychDialogHeader>
<div v-if="inspectLoading" class="preview-loading">Loading table data</div>
<div v-else-if="inspectPreview" class="preview-body">
@ -111,10 +132,15 @@
<span class="meta-label">CRUD endpoint</span>
<code class="meta-value endpoint">/api/{{ inspectPreview.table_name }}</code>
</div>
<div class="meta-row" v-if="!isProtected(inspectPreview.table_name)">
<span class="meta-label">&nbsp;</span>
<NychButton size="sm" @click="showInspect = false; openBrowse(inspectPreview!.table_name)">Browse Rows </NychButton>
</div>
</div>
<div class="schema-section">
<div class="section-label">Schema</div>
<div class="schema-scroll">
<table class="schema-table">
<thead>
<tr>
@ -131,6 +157,7 @@
</tr>
</tbody>
</table>
</div>
</div>
<div class="sample-section" v-if="inspectPreview.sample_rows.length > 0">
@ -148,7 +175,7 @@
<tbody>
<tr v-for="(row, i) in inspectPreview.sample_rows" :key="i">
<td v-for="col in inspectPreview.columns" :key="col.column_name">
{{ row[col.column_name] ?? '—' }}
{{ isSensitiveColumn(col.column_name) ? '••••••••' : (row[col.column_name] ?? '—') }}
</td>
</tr>
</tbody>
@ -166,7 +193,7 @@
<!-- Drop Preview Dialog -->
<NychDialog v-model:open="showDropPreview">
<NychDialogContent class="w-[min(760px,95vw)]">
<NychDialogContent class="w-fit min-w-[420px] max-w-[95vw] sm:max-w-[95vw]">
<NychDialogHeader><NychDialogTitle>Drop Table</NychDialogTitle></NychDialogHeader>
<div v-if="dropPreviewLoading" class="preview-loading">Loading table data</div>
<div v-else-if="dropPreview" class="preview-body">
@ -189,6 +216,7 @@
<div class="schema-section">
<div class="section-label">Schema</div>
<div class="schema-scroll">
<table class="schema-table">
<thead>
<tr>
@ -205,6 +233,7 @@
</tr>
</tbody>
</table>
</div>
</div>
<div class="sample-section" v-if="dropPreview.sample_rows.length > 0">
@ -222,7 +251,7 @@
<tbody>
<tr v-for="(row, i) in dropPreview.sample_rows" :key="i">
<td v-for="col in dropPreview.columns" :key="col.column_name">
{{ row[col.column_name] ?? '—' }}
{{ isSensitiveColumn(col.column_name) ? '••••••••' : (row[col.column_name] ?? '—') }}
</td>
</tr>
</tbody>
@ -237,7 +266,72 @@
<div class="drop-actions">
<NychButton variant="secondary" @click="showDropPreview = false">Cancel</NychButton>
<NychButton variant="danger" :disabled="dropConfirmName !== dropPreview.table_name" @click="confirmDrop">Drop Table</NychButton>
<NychButton variant="danger" :disabled="dropConfirmName !== dropPreview.table_name" :loading="dropping" @click="confirmDrop">Drop Table</NychButton>
</div>
</div>
</NychDialogContent>
</NychDialog>
<!-- Browse / edit rows -->
<NychDialog v-model:open="showBrowse">
<NychDialogContent class="w-fit min-w-[420px] max-w-[95vw] sm:max-w-[95vw]">
<NychDialogHeader><NychDialogTitle>Browse {{ browseTable }}</NychDialogTitle></NychDialogHeader>
<div v-if="browseLoading" class="preview-loading">Loading rows</div>
<div v-else class="preview-body">
<div class="sample-scroll">
<table class="data-table sample-table">
<thead>
<tr>
<th v-for="col in browseColumns" :key="col.column_name">{{ col.column_name }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in browseRows" :key="row.id as string">
<td v-for="col in browseColumns" :key="col.column_name">
<NychTextarea
v-if="isJsonColumn(col.data_type)"
v-model="row.__edit[col.column_name]"
:rows="4"
class="w-[220px] json-editor"
:class="{ 'input-error': !isValidJsonInput(col.data_type, row.__edit[col.column_name]) }"
/>
<NychInput
v-else-if="col.column_name !== 'id'"
v-model="row.__edit[col.column_name]"
class="w-[160px]"
/>
<code v-else>{{ row.id }}</code>
</td>
<td class="row-actions">
<NychButton size="sm" :disabled="hasInvalidJson(row.__edit)" :loading="isRowBusy(row.id)" @click="saveRow(row)">Save</NychButton>
<NychButton size="sm" variant="danger" :loading="isRowBusy(row.id)" @click="deleteRow(row)">Delete</NychButton>
</td>
</tr>
<tr v-if="browseRows.length === 0">
<td :colspan="browseColumns.length + 1" class="empty-hint">No rows yet.</td>
</tr>
</tbody>
</table>
</div>
<div class="new-row-section">
<div class="section-label">Add row</div>
<div class="new-row-grid">
<div v-for="col in browseColumns.filter(c => c.column_name !== 'id')" :key="col.column_name" class="field">
<label>{{ col.column_name }} <span class="hint">({{ col.data_type }})</span></label>
<NychTextarea
v-if="isJsonColumn(col.data_type)"
v-model="newRow[col.column_name]"
:rows="4"
class="w-full json-editor"
:class="{ 'input-error': !isValidJsonInput(col.data_type, newRow[col.column_name]) }"
placeholder='{"key": "value"}'
/>
<NychInput v-else v-model="newRow[col.column_name]" class="w-full" />
</div>
</div>
<NychButton class="w-full" :disabled="hasInvalidJson(newRow)" :loading="addingRow" @click="addRow">Add Row</NychButton>
</div>
</div>
</NychDialogContent>
@ -280,6 +374,42 @@ interface ColumnDef {
nullable: boolean
}
// Mirrors src/routes/admin/tables.rs PROTECTED_TABLES core tables get their
// own dedicated admin page and are excluded from generic row editing here.
const PROTECTED_TABLES = ['users', 'blacklist', 'api_keys', 'queries', 'permissions', 'cors_origins', 'cdn_objects']
function isProtected(name: string) {
return PROTECTED_TABLES.includes(name.toLowerCase())
}
function isSensitiveColumn(name: string) {
return /password|secret|token/i.test(name)
}
function isJsonColumn(dataType: string) {
const t = dataType.toLowerCase()
return t === 'jsonb' || t === 'json'
}
function isValidJsonInput(dataType: string, raw: string): boolean {
if (!isJsonColumn(dataType) || raw === '') return true
try {
JSON.parse(raw)
return true
} catch {
return false
}
}
function coerceValue(dataType: string, raw: string): unknown {
if (raw === '') return null
const t = dataType.toLowerCase()
if (['integer', 'bigint', 'smallint'].includes(t)) return parseInt(raw, 10)
if (['numeric', 'real', 'double precision'].includes(t)) return parseFloat(raw)
if (t === 'boolean') return raw.toLowerCase() === 'true'
if (isJsonColumn(t)) return JSON.parse(raw)
return raw
}
const tables = ref<TableInfo[]>([])
const loading = ref(false)
@ -287,8 +417,15 @@ const loading = ref(false)
const showCreate = ref(false)
const createForm = ref<{ name: string; columns: ColumnDef[] }>({ name: '', columns: [] })
function isIdColumn(name: string) {
return name.trim().toLowerCase() === 'id'
}
const canSubmitCreate = computed(
() => createForm.value.name.trim() !== '' && createForm.value.columns.length > 0,
() =>
createForm.value.name.trim() !== '' &&
createForm.value.columns.length > 0 &&
!createForm.value.columns.some((c) => isIdColumn(c.name)),
)
function addColumn() {
@ -325,19 +462,26 @@ async function fetchPreview(tableName: string): Promise<TablePreview> {
return res.json()
}
const creating = ref(false)
async function submitCreate() {
const res = await fetch('/api/admin/tables', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(createForm.value),
})
if (res.ok) {
const createdName = createForm.value.name
showCreate.value = false
createForm.value = { name: '', columns: [] }
await load()
// immediately open inspect so the user can verify the new table
openInspect(createdName)
creating.value = true
try {
const res = await fetch('/api/admin/tables', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(createForm.value),
})
if (res.ok) {
const created: TableInfo = await res.json()
showCreate.value = false
createForm.value = { name: '', columns: [] }
await load()
// immediately open inspect so the user can verify the new table
openInspect(created.table_name)
}
} finally {
creating.value = false
}
}
@ -358,15 +502,146 @@ async function openDropPreview(tableName: string) {
dropPreviewLoading.value = false
}
const dropping = ref(false)
async function confirmDrop() {
if (!dropPreview.value) return
await fetch(`/api/admin/tables/${dropPreview.value.table_name}`, {
method: 'DELETE',
headers: auth.authHeaders(),
})
showDropPreview.value = false
dropPreview.value = null
load()
dropping.value = true
try {
await fetch(`/api/admin/tables/${dropPreview.value.table_name}`, {
method: 'DELETE',
headers: auth.authHeaders(),
})
showDropPreview.value = false
dropPreview.value = null
load()
} finally {
dropping.value = false
}
}
// browse / row editor
const showBrowse = ref(false)
const browseLoading = ref(false)
const browseTable = ref('')
const browseColumns = ref<ColumnInfo[]>([])
type EditableRow = Record<string, unknown> & { id: unknown; __edit: Record<string, string> }
const browseRows = ref<EditableRow[]>([])
const newRow = ref<Record<string, string>>({})
function toEditableRow(row: Record<string, unknown>): EditableRow {
const edit: Record<string, string> = {}
for (const col of browseColumns.value) {
if (col.column_name === 'id') continue
const v = row[col.column_name]
if (v === null || v === undefined) {
edit[col.column_name] = ''
} else if (isJsonColumn(col.data_type)) {
edit[col.column_name] = JSON.stringify(v, null, 2)
} else {
edit[col.column_name] = String(v)
}
}
return { ...row, __edit: edit } as EditableRow
}
async function openBrowse(tableName: string) {
showBrowse.value = true
browseLoading.value = true
browseTable.value = tableName
browseRows.value = []
newRow.value = {}
try {
const preview = await fetchPreview(tableName)
browseColumns.value = preview.columns
const res = await fetch(`/api/${tableName}`, { headers: auth.authHeaders() })
const rows: Record<string, unknown>[] = res.ok ? await res.json() : []
browseRows.value = rows.map(toEditableRow)
for (const col of browseColumns.value) {
if (col.column_name !== 'id') newRow.value[col.column_name] = ''
}
} finally {
browseLoading.value = false
}
}
function hasInvalidJson(edits: Record<string, string>): boolean {
return browseColumns.value.some(
(col) => col.column_name !== 'id' && !isValidJsonInput(col.data_type, edits[col.column_name] ?? ''),
)
}
function bodyFromEdits(edits: Record<string, string>): Record<string, unknown> {
const body: Record<string, unknown> = {}
for (const col of browseColumns.value) {
if (col.column_name === 'id') continue
const raw = edits[col.column_name] ?? ''
if (raw === '') continue
body[col.column_name] = coerceValue(col.data_type, raw)
}
return body
}
const busyRowIds = ref(new Set<unknown>())
function isRowBusy(id: unknown) {
return busyRowIds.value.has(id)
}
async function saveRow(row: EditableRow) {
busyRowIds.value.add(row.id)
try {
const res = await fetch(`/api/${browseTable.value}/${row.id}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(bodyFromEdits(row.__edit)),
})
if (res.ok) {
const updated = await res.json()
const idx = browseRows.value.findIndex(r => r.id === row.id)
if (idx !== -1) browseRows.value[idx] = toEditableRow(updated)
}
} finally {
busyRowIds.value.delete(row.id)
}
}
async function deleteRow(row: EditableRow) {
if (!confirm(`Delete row ${row.id}?`)) return
busyRowIds.value.add(row.id)
try {
const res = await fetch(`/api/${browseTable.value}/${row.id}`, {
method: 'DELETE',
headers: auth.authHeaders(),
})
if (res.ok) {
browseRows.value = browseRows.value.filter(r => r.id !== row.id)
}
} finally {
busyRowIds.value.delete(row.id)
}
}
const addingRow = ref(false)
async function addRow() {
addingRow.value = true
try {
const body = bodyFromEdits(newRow.value)
const res = await fetch(`/api/${browseTable.value}`, {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const created = await res.json()
browseRows.value.push(toEditableRow(created))
for (const col of browseColumns.value) {
if (col.column_name !== 'id') newRow.value[col.column_name] = ''
}
}
} finally {
addingRow.value = false
}
}
onMounted(load)
@ -393,6 +668,18 @@ onMounted(load)
.fixed-col-hint {
margin-top: -0.25rem;
color: var(--warning, var(--text-muted));
}
.input-error {
border-color: var(--danger) !important;
}
.column-error {
font-size: 0.78rem;
color: var(--danger);
padding: 0 0.25rem;
margin-top: -0.25rem;
}
.column-row {
@ -455,6 +742,7 @@ onMounted(load)
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.preview-meta {
@ -516,6 +804,11 @@ onMounted(load)
color: var(--text-muted);
}
.schema-scroll {
overflow-x: auto;
padding-bottom: 0.5rem;
}
.schema-table {
width: 100%;
border-collapse: collapse;
@ -527,6 +820,7 @@ onMounted(load)
padding: 0.35rem 0.6rem;
border-bottom: 1px solid var(--border-lo);
text-align: left;
white-space: nowrap;
}
.schema-table th {
@ -544,12 +838,19 @@ onMounted(load)
border-radius: 4px;
padding: 0.1rem 0.4rem;
color: var(--text-label);
white-space: nowrap;
}
.sample-table th,
.sample-table td {
white-space: nowrap;
}
.sample-scroll {
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
max-height: 200px;
padding-bottom: 0.5rem;
border: 1px solid var(--border-lo);
border-radius: 6px;
}
@ -582,4 +883,42 @@ onMounted(load)
gap: 0.5rem;
padding-top: 0.25rem;
}
/* ── Protected badge ────────────────────────────── */
.protected-hint {
font-size: 0.75rem;
color: var(--text-dim);
font-style: italic;
}
/* ── Browse / row editor ────────────────────────── */
.row-actions {
display: flex;
gap: 0.4rem;
white-space: nowrap;
}
.json-editor {
font-family: var(--font-mono);
font-size: 0.78rem;
resize: vertical;
}
.empty-hint {
text-align: center;
color: var(--text-dim);
padding: 1rem;
}
.new-row-section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.new-row-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.6rem;
}
</style>

View file

@ -35,8 +35,13 @@
<td><code>{{ u.permissions_mask }}</code></td>
<td class="date-cell">{{ new Date(u.created_at).toLocaleDateString() }}</td>
<td class="actions-cell">
<NychButton size="sm" @click="openEdit(u)">Edit</NychButton>
<NychButton size="sm" variant="danger" @click="deleteUser(u.id)">Delete</NychButton>
<template v-if="isSelf(u)">
<span class="self-hint" title="You can't edit or delete your own account from here">you</span>
</template>
<template v-else>
<NychButton size="sm" @click="openEdit(u)">Edit</NychButton>
<NychButton size="sm" variant="danger" :loading="deletingId === u.id" @click="deleteUser(u.id)">Delete</NychButton>
</template>
</td>
</tr>
</tbody>
@ -49,7 +54,7 @@
</div>
<NychDialog v-model:open="showEdit">
<NychDialogContent class="w-[min(680px,95vw)]">
<NychDialogContent class="w-[min(680px,95vw)] sm:max-w-[min(680px,95vw)]">
<NychDialogHeader><NychDialogTitle>Edit {{ editForm.username }}</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitEdit" class="dialog-form">
<div class="field">
@ -63,20 +68,20 @@
<div class="field">
<label>Role</label>
<NychSelect v-model="editForm.permissions_mask" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue /></NychSelectTrigger>
<NychSelectContent>
<NychSelectTrigger class="w-full"><NychSelectValue>{{ roleName(editForm.permissions_mask) }}</NychSelectValue></NychSelectTrigger>
<NychSelectContent class="z-[60]">
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
</div>
<NychButton type="submit" class="w-full">Save Changes</NychButton>
<NychButton type="submit" class="w-full" :loading="saving">Save Changes</NychButton>
</form>
</NychDialogContent>
</NychDialog>
<NychDialog v-model:open="showCreate">
<NychDialogContent class="w-[min(680px,95vw)]">
<NychDialogContent class="w-[min(680px,95vw)] sm:max-w-[min(680px,95vw)]">
<NychDialogHeader><NychDialogTitle>New User</NychDialogTitle></NychDialogHeader>
<form @submit.prevent="submitCreate" class="dialog-form">
<div class="field">
@ -90,14 +95,14 @@
<div class="field">
<label>Role</label>
<NychSelect v-model="selectedRole" class="w-full">
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role" /></NychSelectTrigger>
<NychSelectContent>
<NychSelectTrigger class="w-full"><NychSelectValue placeholder="Select a role">{{ roleName(selectedRole) }}</NychSelectValue></NychSelectTrigger>
<NychSelectContent class="z-[60]">
<NychSelectItem v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</NychSelectItem>
</NychSelectContent>
</NychSelect>
<p class="hint">{{ roleDescription }}</p>
</div>
<NychButton type="submit" class="w-full">Create User</NychButton>
<NychButton type="submit" class="w-full" :loading="creating">Create User</NychButton>
</form>
</NychDialogContent>
</NychDialog>
@ -131,6 +136,10 @@ const roleDescription = computed(() =>
ROLES.find(r => r.value === selectedRole.value)?.description ?? ''
)
function isSelf(u: any) {
return u.username === auth.username
}
function roleName(mask: string) {
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
}
@ -154,16 +163,23 @@ async function load() {
}
}
const creating = ref(false)
async function submitCreate() {
await fetch('/api/users', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form.value, permissions_mask: selectedRole.value }),
})
showCreate.value = false
form.value = { username: '', password: '' }
selectedRole.value = '1'
load()
creating.value = true
try {
await fetch('/api/users', {
method: 'POST',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form.value, permissions_mask: selectedRole.value }),
})
showCreate.value = false
form.value = { username: '', password: '' }
selectedRole.value = '1'
await load()
} finally {
creating.value = false
}
}
function openEdit(u: any) {
@ -172,22 +188,36 @@ function openEdit(u: any) {
showEdit.value = true
}
const saving = ref(false)
async function submitEdit() {
const body: any = { username: editForm.value.username, permissions_mask: editForm.value.permissions_mask }
if (editForm.value.password) body.password = editForm.value.password
await fetch(`/api/users/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
showEdit.value = false
load()
saving.value = true
try {
const body: any = { username: editForm.value.username, permissions_mask: editForm.value.permissions_mask }
if (editForm.value.password) body.password = editForm.value.password
await fetch(`/api/users/${editId.value}`, {
method: 'PUT',
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
showEdit.value = false
await load()
} finally {
saving.value = false
}
}
const deletingId = ref<number | null>(null)
async function deleteUser(id: number) {
if (!confirm('Delete this user?')) return
await fetch(`/api/users/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
load()
deletingId.value = id
try {
await fetch(`/api/users/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
await load()
} finally {
deletingId.value = null
}
}
onMounted(load)
@ -197,6 +227,7 @@ onMounted(load)
.id-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; }
.username-cell { font-weight: 600; color: var(--text-high); }
.date-cell { color: var(--text-muted); font-size: 0.82rem; font-family: var(--font-mono); }
.self-hint { font-size: 0.75rem; color: var(--text-dim); font-style: italic; }
.role-badge {
display: inline-block;