fix: cleanup
This commit is contained in:
parent
ec515a39a7
commit
2d95265e7d
25 changed files with 1590 additions and 233 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
620
docs/superpowers/plans/2026-08-09-disable-auth-env-var.md
Normal file
620
docs/superpowers/plans/2026-08-09-disable-auth-env-var.md
Normal 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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,26 @@ 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)];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
{{ isDark ? "☀" : "☾" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!auth.authDisabled"
|
||||
class="icon-btn danger-btn"
|
||||
title="Sign out"
|
||||
@click="handleLogout"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"> </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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue