Mercury
This commit is contained in:
parent
45aa92c42c
commit
034cd4df8c
70 changed files with 10066 additions and 1 deletions
8
.env.example
Normal file
8
.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
DATABASE_URL=postgres://mercury:mercury@db:5432/mercury
|
||||||
|
JWT_SECRET=change_me_in_production
|
||||||
|
JWT_EXPIRY_SECS=3600
|
||||||
|
CACHE_MAX_CAPACITY=10000
|
||||||
|
CACHE_IDLE_TIMEOUT_SECS=300
|
||||||
|
CACHE_SWEEP_INTERVAL_SECS=60
|
||||||
|
# Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers.
|
||||||
|
CORS_ORIGINS=
|
||||||
89
.forgejo/workflows/ci.yml
Normal file
89
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: self-hosted
|
||||||
|
container: git.mcpeakdev.com/mcpeakdev/rust-ci:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
build-ui:
|
||||||
|
runs-on: self-hosted
|
||||||
|
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
- name: Audit dependencies
|
||||||
|
run: bun audit || true
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: bun run build
|
||||||
|
working-directory: ui
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [test, build-ui]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: self-hosted
|
||||||
|
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.mcpeakdev.com
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract image tag
|
||||||
|
id: meta
|
||||||
|
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.mcpeakdev.com/mcpeakdev/mercury:${{ steps.meta.outputs.tag }}
|
||||||
|
git.mcpeakdev.com/mcpeakdev/mercury:latest
|
||||||
|
cache-from: type=registry,ref=git.mcpeakdev.com/mcpeakdev/mercury:latest
|
||||||
|
cache-to: type=inline
|
||||||
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
[package]
|
||||||
|
name = "mercury"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mercury"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
bcrypt = "0.15"
|
||||||
|
dashmap = "5"
|
||||||
|
glob = "0.3"
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "cors"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
anyhow = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Stage 1: Build Vue UI
|
||||||
|
FROM oven/bun:1-alpine AS ui-builder
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY ui/ .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Stage 2: Build Rust API
|
||||||
|
FROM rust:1.96-slim AS api-builder
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY src/ src/
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=api-builder /app/target/release/mercury .
|
||||||
|
COPY --from=ui-builder /ui/dist ./ui/dist
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["./mercury"]
|
||||||
173
README.md
173
README.md
|
|
@ -1,3 +1,174 @@
|
||||||
# Mercury
|
# Mercury
|
||||||
|
|
||||||
A learning rust API
|
A high-performance, monolithic Rust API with a dynamic CRUD engine, query registry cache, JWT bitmask permissions, and a Vue 3 admin frontend.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set JWT_SECRET
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
API: http://localhost:3000/api
|
||||||
|
Admin UI: http://localhost:3000
|
||||||
|
Default credentials: `admin` / `admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Contract
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/login
|
||||||
|
Body: { "username": "...", "password": "..." }
|
||||||
|
Returns: { "token": "<JWT>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
All admin routes require `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### CRUD — Dynamic Table Access
|
||||||
|
|
||||||
|
Requests are mapped to the named PostgreSQL table. The SQL is generated, cached, and executed automatically.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/{table} List all rows (supports ?col=val filters)
|
||||||
|
GET /api/{table}/{id} Get row by id
|
||||||
|
POST /api/{table} Insert row (JSON body)
|
||||||
|
PUT /api/{table}/{id} Update row by id (JSON body)
|
||||||
|
DELETE /api/{table}/{id} Delete row by id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- `users` and `permissions` tables are blacklisted from public CRUD — use the admin suite.
|
||||||
|
- Filters are ANDed together: `GET /api/orders?status=open&priority=high`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin — Query Registry
|
||||||
|
|
||||||
|
Requires JWT with `ADMIN_QUERY` permission (bit 8).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/queries List all registered queries
|
||||||
|
POST /admin/queries Register a raw SQL template
|
||||||
|
GET /admin/queries/{identifier} Get query by slug
|
||||||
|
PUT /admin/queries/{identifier} Update SQL template or description
|
||||||
|
DELETE /admin/queries/{identifier} Remove query (evicts from cache)
|
||||||
|
GET /admin/queries/{identifier}/execute Execute query with ?param=val bindings
|
||||||
|
```
|
||||||
|
|
||||||
|
SQL templates use `:param_name` placeholders:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM orders WHERE user_id = :user_id AND status = :status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin — Cache
|
||||||
|
|
||||||
|
Requires JWT with `ADMIN_CACHE` permission (bit 16).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/cache/stats Cache size, hit count, miss count
|
||||||
|
DELETE /admin/cache Flush entire cache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin — Users
|
||||||
|
|
||||||
|
Requires JWT with `SUPER_ADMIN` permission (bit 32).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/users List users
|
||||||
|
POST /admin/users Create user
|
||||||
|
GET /admin/users/{id} Get user
|
||||||
|
PUT /admin/users/{id} Update user
|
||||||
|
DELETE /admin/users/{id} Delete user
|
||||||
|
POST /admin/users/{id}/permissions/grant/{bit} OR bit into permissions mask
|
||||||
|
DELETE /admin/users/{id}/permissions/revoke/{bit} AND NOT bit from permissions mask
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin — Permissions
|
||||||
|
|
||||||
|
Requires JWT with `SUPER_ADMIN` permission (bit 32).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/permissions List permission definitions
|
||||||
|
POST /admin/permissions Create custom permission (auto-assigns next bit)
|
||||||
|
PUT /admin/permissions/{id} Update name/description
|
||||||
|
DELETE /admin/permissions/{id} Remove permission
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Admin — Route Blacklist
|
||||||
|
|
||||||
|
Requires JWT with `SUPER_ADMIN` permission (bit 32). Changes take effect immediately in memory.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/blacklist List all entries
|
||||||
|
POST /admin/blacklist Add glob pattern
|
||||||
|
PUT /admin/blacklist/{id} Update entry (set active: false to disable)
|
||||||
|
DELETE /admin/blacklist/{id} Remove entry
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern syntax: `*` matches one path segment, `**` matches many.
|
||||||
|
Example: `/api/sensitive/**` blocks all methods under that path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permission Bitmask
|
||||||
|
|
||||||
|
| Name | Bit | Value |
|
||||||
|
|-------------|-----|-------|
|
||||||
|
| READ | 0 | 1 |
|
||||||
|
| WRITE | 1 | 2 |
|
||||||
|
| DELETE | 2 | 4 |
|
||||||
|
| ADMIN_QUERY | 3 | 8 |
|
||||||
|
| ADMIN_CACHE | 4 | 16 |
|
||||||
|
| SUPER_ADMIN | 5 | 32 |
|
||||||
|
|
||||||
|
Custom permissions are added via the admin suite and assigned the next available power-of-2 bit. Masks support up to 128 bits (u128).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATABASE_URL` | required | PostgreSQL connection string |
|
||||||
|
| `JWT_SECRET` | required | HMAC-HS256 signing secret |
|
||||||
|
| `JWT_EXPIRY_SECS` | 3600 | Token lifetime in seconds |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API only (requires local Postgres)
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# UI dev server (proxies to local API)
|
||||||
|
cd ui && npm install && npm run dev
|
||||||
|
|
||||||
|
# Full stack
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **API:** Rust, Axum, SQLx, PostgreSQL, DashMap, jsonwebtoken, bcrypt
|
||||||
|
- **Frontend:** Vue 3, Vite, @nychthemeron/library (dark mode default)
|
||||||
|
- **Infra:** Docker multi-stage build, docker compose
|
||||||
|
|
|
||||||
15
dev.sh
Executable file
15
dev.sh
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
cd $SCRIPT_DIR/ui/ && bun i
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker compose down
|
||||||
|
}
|
||||||
|
trap cleanup INT TERM
|
||||||
|
|
||||||
|
docker compose up --build
|
||||||
4
docker-compose.dev.yml
Normal file
4
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Reserved for future dev overrides.
|
||||||
|
# Port mapping omitted — dev.sh connects to the container's internal IP directly
|
||||||
|
# to avoid Docker Desktop WSL2 port-forwarding limitations.
|
||||||
|
services: {}
|
||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: mercury
|
||||||
|
POSTGRES_PASSWORD: mercury
|
||||||
|
POSTGRES_DB: mercury
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mercury"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://mercury:mercury@db:5432/mercury
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change_me_in_production}
|
||||||
|
JWT_EXPIRY_SECS: ${JWT_EXPIRY_SECS:-3600}
|
||||||
|
CACHE_MAX_CAPACITY: ${CACHE_MAX_CAPACITY:-10000}
|
||||||
|
CACHE_IDLE_TIMEOUT_SECS: ${CACHE_IDLE_TIMEOUT_SECS:-300}
|
||||||
|
CACHE_SWEEP_INTERVAL_SECS: ${CACHE_SWEEP_INTERVAL_SECS:-60}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
mem_limit: 512m
|
||||||
|
mem_reservation: 256m
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
3438
docs/superpowers/plans/2026-06-16-mercury-implementation.md
Normal file
3438
docs/superpowers/plans/2026-06-16-mercury-implementation.md
Normal file
File diff suppressed because it is too large
Load diff
1072
docs/superpowers/plans/2026-06-17-security-fixes.md
Normal file
1072
docs/superpowers/plans/2026-06-17-security-fixes.md
Normal file
File diff suppressed because it is too large
Load diff
323
docs/superpowers/specs/2026-06-16-mercury-design.md
Normal file
323
docs/superpowers/specs/2026-06-16-mercury-design.md
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
# Mercury — Design Spec
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mercury is a monolithic, high-performance universal CRUD API written in Rust. It accepts HTTP requests, maps them to PostgreSQL tables, generates and caches SQL dynamically, and exposes an admin suite (with a Vue 3 frontend) for managing queries, users, permissions, and route security. A JWT-based bitmask permission system gates all admin operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP Request
|
||||||
|
↓
|
||||||
|
Blacklist Middleware (in-memory glob match)
|
||||||
|
↓
|
||||||
|
Auth Middleware (JWT decode + permission bit check, admin routes only)
|
||||||
|
↓
|
||||||
|
Axum Router
|
||||||
|
↓
|
||||||
|
Query Cache (DashMap<String, (CachedQuery, Instant)>)
|
||||||
|
↓ cache miss
|
||||||
|
Query Registry (PostgreSQL: queries table)
|
||||||
|
↓ query not yet registered
|
||||||
|
Query Builder (auto-generates SQL from route + HTTP method)
|
||||||
|
↓
|
||||||
|
PostgreSQL (sqlx PgPool)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stack:**
|
||||||
|
- `axum` — async HTTP framework (tokio-native)
|
||||||
|
- `sqlx` — async PostgreSQL driver, raw query support, compile-time checked migrations
|
||||||
|
- `DashMap` — concurrent hashmap for the query cache
|
||||||
|
- `glob` — compiled pattern matching for the route blacklist
|
||||||
|
- `jsonwebtoken` — JWT encode/decode
|
||||||
|
- Vue 3 + Vite + `@nychthemeron/library` — frontend, served as static files by Rust
|
||||||
|
- `tower-http::ServeDir` — serves `ui/dist/` at `/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Public
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /auth/login → returns JWT
|
||||||
|
```
|
||||||
|
|
||||||
|
### CRUD (no auth, blacklist-checked)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/{table} → SELECT * FROM {table} [?col=val filters]
|
||||||
|
GET /api/{table}/{id} → SELECT * FROM {table} WHERE id = {id}
|
||||||
|
POST /api/{table} → INSERT INTO {table}
|
||||||
|
PUT /api/{table}/{id} → UPDATE {table} WHERE id = {id}
|
||||||
|
DELETE /api/{table}/{id} → DELETE FROM {table} WHERE id = {id}
|
||||||
|
```
|
||||||
|
|
||||||
|
`users` and `permissions` are blacklisted by default — only accessible via the admin suite.
|
||||||
|
|
||||||
|
### Admin — Query Registry (JWT + `ADMIN_QUERY` bit)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/queries → list all registered queries
|
||||||
|
POST /admin/queries → register raw SQL template
|
||||||
|
GET /admin/queries/{identifier} → get query by slug identifier
|
||||||
|
PUT /admin/queries/{identifier} → update SQL template or metadata
|
||||||
|
DELETE /admin/queries/{identifier} → remove from registry + evict from cache
|
||||||
|
GET /admin/queries/{identifier}/execute → execute query with ?param=val bindings
|
||||||
|
```
|
||||||
|
|
||||||
|
`{identifier}` is the human-readable slug (e.g. `get-user-orders`), not the internal UUID. The UUID is used internally only.
|
||||||
|
|
||||||
|
### Admin — Cache (JWT + `ADMIN_CACHE` bit)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/cache/stats → current size, hit count, miss count
|
||||||
|
DELETE /admin/cache → flush entire cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin — Users (JWT + `SUPER_ADMIN` bit)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/users → list users
|
||||||
|
POST /admin/users → create user
|
||||||
|
GET /admin/users/{id} → get user
|
||||||
|
PUT /admin/users/{id} → update user
|
||||||
|
DELETE /admin/users/{id} → delete user
|
||||||
|
POST /admin/users/{id}/permissions/grant/{bit_value} → OR bit into mask
|
||||||
|
DELETE /admin/users/{id}/permissions/revoke/{bit_value} → AND NOT bit from mask
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin — Permissions (JWT + `SUPER_ADMIN` bit)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/permissions → list all permission definitions
|
||||||
|
POST /admin/permissions → create custom permission (auto-assigns next available bit_value)
|
||||||
|
PUT /admin/permissions/{id} → update name/description
|
||||||
|
DELETE /admin/permissions/{id} → remove permission definition
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin — Blacklist (JWT + `SUPER_ADMIN` bit)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/blacklist → list all entries
|
||||||
|
POST /admin/blacklist → add route pattern
|
||||||
|
PUT /admin/blacklist/{id} → update entry
|
||||||
|
DELETE /admin/blacklist/{id} → remove entry
|
||||||
|
```
|
||||||
|
|
||||||
|
Any write to the blacklist immediately reloads the in-memory blacklist from the DB.
|
||||||
|
|
||||||
|
### Frontend (served by Rust)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET / → serves ui/dist/index.html (SPA entry)
|
||||||
|
GET /assets/* → static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | SERIAL PRIMARY KEY | |
|
||||||
|
| username | VARCHAR UNIQUE NOT NULL | |
|
||||||
|
| password_hash | TEXT NOT NULL | bcrypt |
|
||||||
|
| permissions_mask | NUMERIC NOT NULL DEFAULT 0 | u128 bitmask |
|
||||||
|
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||||||
|
|
||||||
|
### `permissions`
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | SERIAL PRIMARY KEY | |
|
||||||
|
| name | VARCHAR UNIQUE NOT NULL | e.g. `READ` |
|
||||||
|
| bit_value | NUMERIC UNIQUE NOT NULL | power of 2, up to 2^127 |
|
||||||
|
| description | TEXT | |
|
||||||
|
|
||||||
|
### `queries`
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | UUID PRIMARY KEY DEFAULT gen_random_uuid() | |
|
||||||
|
| identifier | VARCHAR UNIQUE NOT NULL | human-readable slug |
|
||||||
|
| sql_template | TEXT NOT NULL | raw SQL with :param placeholders |
|
||||||
|
| description | TEXT | |
|
||||||
|
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||||||
|
| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||||||
|
|
||||||
|
### `blacklist`
|
||||||
|
| column | type | notes |
|
||||||
|
|---|---|---|
|
||||||
|
| id | SERIAL PRIMARY KEY | |
|
||||||
|
| pattern | VARCHAR NOT NULL | glob-style, e.g. `/api/users/**` |
|
||||||
|
| method | VARCHAR | NULL = all methods |
|
||||||
|
| reason | TEXT | |
|
||||||
|
| active | BOOLEAN NOT NULL DEFAULT true | |
|
||||||
|
| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
|
||||||
|
|
||||||
|
### Seed Data (migration)
|
||||||
|
Default permission definitions:
|
||||||
|
|
||||||
|
| name | bit_value |
|
||||||
|
|---|---|
|
||||||
|
| READ | 1 |
|
||||||
|
| WRITE | 2 |
|
||||||
|
| DELETE | 4 |
|
||||||
|
| ADMIN_QUERY | 8 |
|
||||||
|
| ADMIN_CACHE | 16 |
|
||||||
|
| SUPER_ADMIN | 32 |
|
||||||
|
|
||||||
|
Default user: `admin` / `admin`, `permissions_mask = 63` (all bits set).
|
||||||
|
|
||||||
|
Default blacklist entries: `/api/users/**` and `/api/permissions/**` (method: NULL).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query Cache
|
||||||
|
|
||||||
|
**Structure:** `DashMap<String, (CachedQuery, Instant)>`
|
||||||
|
|
||||||
|
- Key: deterministic string derived from `{METHOD}:{table}:{params}` for auto-generated queries, or query `identifier` for named queries
|
||||||
|
- Value: the compiled query + timestamp of last access
|
||||||
|
- Hit/miss counters: two `AtomicU64` values on the shared state struct
|
||||||
|
|
||||||
|
**Eviction:** a tokio background task runs every `CACHE_SWEEP_INTERVAL_SECS`. It iterates the map and removes entries where `last_accessed.elapsed() > CACHE_IDLE_TIMEOUT_SECS`.
|
||||||
|
|
||||||
|
**Capacity:** before inserting a new entry, if `cache.len() >= CACHE_MAX_CAPACITY`, the entry with the oldest `last_accessed` is evicted first.
|
||||||
|
|
||||||
|
**Cache invalidation:** deleting or updating a query via the admin suite immediately removes it from the DashMap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blacklist Cache
|
||||||
|
|
||||||
|
**Structure:** `Arc<RwLock<Vec<(BlacklistEntry, glob::Pattern)>>>`
|
||||||
|
|
||||||
|
- Loaded from DB at startup
|
||||||
|
- Reloaded (write lock, full rebuild from DB) on any admin write to the `blacklist` table
|
||||||
|
- Checked in middleware on every request before routing: if any active entry matches `(method, path)`, return `403 Forbidden`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permission System
|
||||||
|
|
||||||
|
Permissions are u128 bitmasks. The JWT claims embed the user's current `permissions_mask`. Middleware extracts the JWT and performs a bitwise AND check — no DB lookup per request.
|
||||||
|
|
||||||
|
```
|
||||||
|
READ = 1
|
||||||
|
WRITE = 2
|
||||||
|
DELETE = 4
|
||||||
|
ADMIN_QUERY = 8
|
||||||
|
ADMIN_CACHE = 16
|
||||||
|
SUPER_ADMIN = 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom permissions auto-assign the next unused power-of-2 bit_value. Up to 128 distinct permission bits supported.
|
||||||
|
|
||||||
|
`permissions_mask` stored as PostgreSQL `NUMERIC` (arbitrary precision), converted to/from `u128` in the Rust data layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JWT
|
||||||
|
|
||||||
|
- Algorithm: HS256
|
||||||
|
- Claims: `sub` (username), `permissions` (u128 as string), `exp`
|
||||||
|
- Secret: `JWT_SECRET` env var
|
||||||
|
- Passed as `Authorization: Bearer <token>` header
|
||||||
|
- Token expiry: configurable via `JWT_EXPIRY_SECS`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration (env vars)
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://mercury:mercury@db:5432/mercury
|
||||||
|
JWT_SECRET=changeme
|
||||||
|
JWT_EXPIRY_SECS=3600
|
||||||
|
CACHE_MAX_CAPACITY=10000
|
||||||
|
CACHE_IDLE_TIMEOUT_SECS=300
|
||||||
|
CACHE_SWEEP_INTERVAL_SECS=60
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Mercury/
|
||||||
|
src/
|
||||||
|
main.rs -- startup: config, DB pool, cache init, router mount
|
||||||
|
config.rs -- Config struct loaded from env
|
||||||
|
auth/
|
||||||
|
mod.rs -- JWT encode/decode, Claims struct
|
||||||
|
middleware.rs -- axum middleware: validate JWT, check permission bit
|
||||||
|
cache/
|
||||||
|
query_cache.rs -- DashMap cache + sweep task + hit/miss counters
|
||||||
|
blacklist_cache.rs -- Arc<RwLock<...>> + glob::Pattern, reload fn
|
||||||
|
db/
|
||||||
|
pool.rs -- sqlx PgPool init
|
||||||
|
migrations/ -- .sql files, run via sqlx::migrate!()
|
||||||
|
001_initial.sql -- all tables + seed data
|
||||||
|
routes/
|
||||||
|
crud.rs -- generic /api/{table} handler + query builder
|
||||||
|
auth.rs -- /auth/login
|
||||||
|
admin/
|
||||||
|
queries.rs
|
||||||
|
cache.rs
|
||||||
|
users.rs
|
||||||
|
permissions.rs
|
||||||
|
blacklist.rs
|
||||||
|
models/
|
||||||
|
query.rs
|
||||||
|
user.rs
|
||||||
|
permission.rs
|
||||||
|
blacklist.rs
|
||||||
|
ui/
|
||||||
|
.npmrc -- @nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/
|
||||||
|
package.json
|
||||||
|
vite.config.ts
|
||||||
|
src/
|
||||||
|
main.ts -- dark mode default, router, pinia init
|
||||||
|
App.vue
|
||||||
|
router/index.ts -- route guards: redirect /login if no JWT
|
||||||
|
stores/auth.ts -- JWT storage, permissions bitmask helpers
|
||||||
|
views/
|
||||||
|
Login.vue
|
||||||
|
admin/
|
||||||
|
Queries.vue
|
||||||
|
Users.vue
|
||||||
|
Permissions.vue
|
||||||
|
Blacklist.vue
|
||||||
|
Cache.vue
|
||||||
|
Cargo.toml
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile -- multi-stage: Node build UI, Rust build API
|
||||||
|
.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
**Multi-stage Dockerfile:**
|
||||||
|
1. Stage 1 (node): install `@nychthemeron/library`, run `vite build` → `ui/dist/`
|
||||||
|
2. Stage 2 (rust): compile API, copy `ui/dist/` into final image
|
||||||
|
3. Final image runs the Rust binary; `tower-http::ServeDir` serves `ui/dist/` at `/`
|
||||||
|
|
||||||
|
**docker-compose.yml:**
|
||||||
|
- `db` service: `postgres:16-alpine`, persistent volume, health check
|
||||||
|
- `api` service: built from Dockerfile, depends on `db`, `mem_limit` set, env vars from `.env`
|
||||||
|
- Migrations run at API startup via `sqlx::migrate!()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Contract
|
||||||
|
|
||||||
|
- Dark mode is the default; set via `@nychthemeron/library` theme config in `main.ts`
|
||||||
|
- Login page at `/login` calls `POST /auth/login`, stores JWT in `localStorage`
|
||||||
|
- All admin views require `SUPER_ADMIN` bit in JWT; route guard redirects to `/login` if absent
|
||||||
|
- Permission bitmask helpers in `stores/auth.ts`: `hasPermission(bit: number): boolean`
|
||||||
|
- Admin views wire directly to their respective admin endpoints
|
||||||
BIN
pics/Mercury-Login.png
Normal file
BIN
pics/Mercury-Login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
BIN
pics/Mercury.png
Normal file
BIN
pics/Mercury.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
106
src/auth/middleware.rs
Normal file
106
src/auth/middleware.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::StatusCode,
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{auth::{decode_jwt, resolve_api_key, Claims}, state::AppState};
|
||||||
|
|
||||||
|
/// Resolves a Bearer token to Claims, trying JWT then API key.
|
||||||
|
/// Inserts Claims into request extensions on success so downstream
|
||||||
|
/// middleware can reuse them without an additional DB round-trip.
|
||||||
|
async fn authenticate(
|
||||||
|
token: &str,
|
||||||
|
state: &AppState,
|
||||||
|
req: &mut Request,
|
||||||
|
) -> Option<Claims> {
|
||||||
|
if let Some(existing) = req.extensions().get::<Claims>().cloned() {
|
||||||
|
return Some(existing);
|
||||||
|
}
|
||||||
|
let claims = if let Ok(c) = decode_jwt(token, &state.config.jwt_secret) {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
resolve_api_key(token, &state.pool).await?
|
||||||
|
};
|
||||||
|
req.extensions_mut().insert(claims.clone());
|
||||||
|
Some(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn blacklist_layer(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> 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
|
||||||
|
};
|
||||||
|
if state.blacklist_cache.is_blocked(&method, &path, caller_mask).await {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_bearer(req: &Request) -> Option<String> {
|
||||||
|
req.headers()
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_super_admin(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_admin_query(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn require_admin_cache(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
mut req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
Ok(next.run(req).await)
|
||||||
|
}
|
||||||
123
src/auth/mod.rs
Normal file
123
src/auth/mod.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
pub mod middleware;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::Utc;
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub mod permissions {
|
||||||
|
pub const READ: u128 = 1;
|
||||||
|
pub const WRITE: u128 = 2;
|
||||||
|
pub const DELETE: u128 = 4;
|
||||||
|
pub const ADMIN_QUERY: u128 = 8;
|
||||||
|
pub const ADMIN_CACHE: u128 = 16;
|
||||||
|
pub const SUPER_ADMIN: u128 = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
pub permissions: String, // u128 stored as decimal string
|
||||||
|
pub exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn permissions_mask(&self) -> u128 {
|
||||||
|
self.permissions.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_permission(&self, bit: u128) -> bool {
|
||||||
|
self.permissions_mask() & bit != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_jwt(username: &str, mask: u128, secret: &str, expiry_secs: u64) -> Result<String> {
|
||||||
|
let exp = (Utc::now().timestamp() as u64 + expiry_secs) as usize;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: username.to_string(),
|
||||||
|
permissions: mask.to_string(),
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret.as_bytes()),
|
||||||
|
)?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a new API key: `mrc_<32 random hex chars>`.
|
||||||
|
/// Returns `(plain_key, key_prefix, key_hash)`.
|
||||||
|
pub fn generate_api_key() -> (String, String, String) {
|
||||||
|
let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
|
||||||
|
let key = format!("mrc_{}", raw);
|
||||||
|
let prefix = raw[..8].to_string();
|
||||||
|
let hash = hash_api_key(&key);
|
||||||
|
(key, prefix, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_api_key(key: &str) -> String {
|
||||||
|
format!("{:x}", Sha256::digest(key.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an API key token against the database.
|
||||||
|
/// Updates `last_used_at` on success.
|
||||||
|
pub async fn resolve_api_key(token: &str, pool: &sqlx::PgPool) -> Option<Claims> {
|
||||||
|
if !token.starts_with("mrc_") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hash = hash_api_key(token);
|
||||||
|
let row: (String, String) = sqlx::query_as(
|
||||||
|
"UPDATE api_keys SET last_used_at = now()
|
||||||
|
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
RETURNING name, permissions_mask",
|
||||||
|
)
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()??;
|
||||||
|
|
||||||
|
Some(Claims {
|
||||||
|
sub: row.0,
|
||||||
|
permissions: row.1,
|
||||||
|
exp: usize::MAX,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_jwt(token: &str, secret: &str) -> Result<Claims> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)?;
|
||||||
|
Ok(data.claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_encode_decode_roundtrip() {
|
||||||
|
let secret = "test_secret";
|
||||||
|
let token = encode_jwt("alice", 63u128, secret, 3600).unwrap();
|
||||||
|
let claims = decode_jwt(&token, secret).unwrap();
|
||||||
|
assert_eq!(claims.sub, "alice");
|
||||||
|
assert_eq!(claims.permissions_mask(), 63u128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_permission() {
|
||||||
|
let claims = Claims {
|
||||||
|
sub: "bob".into(),
|
||||||
|
permissions: "9".into(), // READ (1) + ADMIN_QUERY (8)
|
||||||
|
exp: 9999999999,
|
||||||
|
};
|
||||||
|
assert!(claims.has_permission(permissions::READ));
|
||||||
|
assert!(claims.has_permission(permissions::ADMIN_QUERY));
|
||||||
|
assert!(!claims.has_permission(permissions::SUPER_ADMIN));
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/cache/mod.rs
vendored
Normal file
2
src/cache/mod.rs
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod sweep;
|
||||||
|
pub use sweep::spawn_sweep_task;
|
||||||
15
src/cache/sweep.rs
vendored
Normal file
15
src/cache/sweep.rs
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
use crate::state::QueryCache;
|
||||||
|
|
||||||
|
pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let interval = Duration::from_secs(sweep_interval_secs);
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
let now = crate::state::unix_now();
|
||||||
|
cache.map.retain(|_, entry| {
|
||||||
|
now.saturating_sub(entry.last_accessed()) < idle_timeout_secs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
57
src/config.rs
Normal file
57
src/config.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub database_url: String,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub jwt_expiry_secs: u64,
|
||||||
|
pub cache_max_capacity: usize,
|
||||||
|
pub cache_idle_timeout_secs: u64,
|
||||||
|
pub cache_sweep_interval_secs: u64,
|
||||||
|
/// Comma-separated allowed CORS origins, or "*" for permissive. Empty = no CORS headers.
|
||||||
|
pub cors_origins: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
database_url: std::env::var("DATABASE_URL")?,
|
||||||
|
jwt_secret: std::env::var("JWT_SECRET")?,
|
||||||
|
jwt_expiry_secs: std::env::var("JWT_EXPIRY_SECS")
|
||||||
|
.unwrap_or_else(|_| "3600".into())
|
||||||
|
.parse()?,
|
||||||
|
cache_max_capacity: std::env::var("CACHE_MAX_CAPACITY")
|
||||||
|
.unwrap_or_else(|_| "10000".into())
|
||||||
|
.parse()?,
|
||||||
|
cache_idle_timeout_secs: std::env::var("CACHE_IDLE_TIMEOUT_SECS")
|
||||||
|
.unwrap_or_else(|_| "300".into())
|
||||||
|
.parse()?,
|
||||||
|
cache_sweep_interval_secs: std::env::var("CACHE_SWEEP_INTERVAL_SECS")
|
||||||
|
.unwrap_or_else(|_| "60".into())
|
||||||
|
.parse()?,
|
||||||
|
cors_origins: std::env::var("CORS_ORIGINS")
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_defaults() {
|
||||||
|
std::env::set_var("DATABASE_URL", "postgres://test");
|
||||||
|
std::env::set_var("JWT_SECRET", "secret");
|
||||||
|
let cfg = Config::from_env().unwrap();
|
||||||
|
assert_eq!(cfg.jwt_expiry_secs, 3600);
|
||||||
|
assert_eq!(cfg.cache_max_capacity, 10_000);
|
||||||
|
assert_eq!(cfg.cache_idle_timeout_secs, 300);
|
||||||
|
assert_eq!(cfg.cache_sweep_interval_secs, 60);
|
||||||
|
assert!(cfg.cors_origins.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/db/migrations/001_initial.sql
Normal file
48
src/db/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
permissions_mask TEXT NOT NULL DEFAULT '0',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE permissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
bit_value TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE queries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
identifier VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
sql_template TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE blacklist (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
pattern VARCHAR(500) NOT NULL,
|
||||||
|
method VARCHAR(10),
|
||||||
|
reason TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed permissions
|
||||||
|
INSERT INTO permissions (name, bit_value, description) VALUES
|
||||||
|
('READ', '1', 'Can read via CRUD endpoints'),
|
||||||
|
('WRITE', '2', 'Can insert/update via CRUD'),
|
||||||
|
('DELETE', '4', 'Can delete via CRUD'),
|
||||||
|
('ADMIN_QUERY', '8', 'Can manage query registry'),
|
||||||
|
('ADMIN_CACHE', '16', 'Can manage cache'),
|
||||||
|
('SUPER_ADMIN', '32', 'Full access');
|
||||||
|
|
||||||
|
-- Seed blacklist (users and permissions tables are admin-only)
|
||||||
|
INSERT INTO blacklist (pattern, method, reason, active) VALUES
|
||||||
|
('/api/users/**', NULL, 'admin-only table', true),
|
||||||
|
('/api/permissions/**', NULL, 'admin-only table', true);
|
||||||
9
src/db/migrations/002_blacklist_system_tables.sql
Normal file
9
src/db/migrations/002_blacklist_system_tables.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Block PostgreSQL system catalog tables from the public CRUD API.
|
||||||
|
-- The pg_ prefix covers pg_tables, pg_class, pg_user, pg_shadow, pg_authid, etc.
|
||||||
|
-- information_schema columns contain dots so they're already rejected by the
|
||||||
|
-- identifier validator, but we block them here for defense in depth.
|
||||||
|
INSERT INTO blacklist (pattern, method, reason, active) VALUES
|
||||||
|
('/api/pg_*', NULL, 'postgresql system catalog', true),
|
||||||
|
('/api/pg_*/**', NULL, 'postgresql system catalog', true),
|
||||||
|
('/api/information_schema', NULL, 'postgresql information schema', true),
|
||||||
|
('/api/information_schema/**', NULL, 'postgresql information schema', true);
|
||||||
32
src/db/migrations/003_blacklist_bypass_permission.sql
Normal file
32
src/db/migrations/003_blacklist_bypass_permission.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Allow blacklist entries to be bypassed by callers who hold a specific permission bit.
|
||||||
|
-- bypass_mask NULL means the rule blocks everyone unconditionally.
|
||||||
|
ALTER TABLE blacklist ADD COLUMN bypass_mask TEXT;
|
||||||
|
|
||||||
|
-- Existing user/permission rules: SUPER_ADMIN (bit 32) can bypass them so the
|
||||||
|
-- admin UI can reach these tables via the generic /api/:table CRUD endpoint.
|
||||||
|
UPDATE blacklist
|
||||||
|
SET bypass_mask = '32'
|
||||||
|
WHERE pattern IN ('/api/users/**', '/api/permissions/**');
|
||||||
|
|
||||||
|
-- Add rules for queries and blacklist tables, which also need admin-only CRUD access.
|
||||||
|
INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES
|
||||||
|
('/api/users', NULL, 'admin-only table', true, '32'),
|
||||||
|
('/api/permissions', NULL, 'admin-only table', true, '32'),
|
||||||
|
('/api/queries', NULL, 'admin-only table', true, '32'),
|
||||||
|
('/api/queries/**', NULL, 'admin-only table', true, '32'),
|
||||||
|
('/api/blacklist', NULL, 'admin-only table', true, '32'),
|
||||||
|
('/api/blacklist/**', NULL, 'admin-only table', true, '32');
|
||||||
|
|
||||||
|
-- Automatically update updated_at on queries mutations so the generic CRUD
|
||||||
|
-- endpoint doesn't need to know about that column.
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER queries_updated_at
|
||||||
|
BEFORE UPDATE ON queries
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
2
src/db/migrations/004_blacklist_method_text.sql
Normal file
2
src/db/migrations/004_blacklist_method_text.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Widen method column to support comma-separated multi-method values e.g. "GET,POST"
|
||||||
|
ALTER TABLE blacklist ALTER COLUMN method TYPE TEXT;
|
||||||
12
src/db/migrations/005_api_keys.sql
Normal file
12
src/db/migrations/005_api_keys.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
permissions_mask TEXT NOT NULL DEFAULT '0',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
|
||||||
5
src/db/migrations/006_seed_api_keys_blacklist.sql
Normal file
5
src/db/migrations/006_seed_api_keys_blacklist.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Prevent raw CRUD access to the api_keys table (would expose key_hash).
|
||||||
|
-- The proper endpoints live at /api/admin/api-keys and are guarded by require_super_admin.
|
||||||
|
INSERT INTO blacklist (pattern, method, reason, active) VALUES
|
||||||
|
('/api/api_keys', NULL, 'api key hashes must not be exposed via generic CRUD', true),
|
||||||
|
('/api/api_keys/**', NULL, 'api key hashes must not be exposed via generic CRUD', true);
|
||||||
2
src/db/mod.rs
Normal file
2
src/db/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod pool;
|
||||||
|
pub use pool::create_pool;
|
||||||
11
src/db/pool.rs
Normal file
11
src/db/pool.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||||
|
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(10)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
sqlx::migrate!("src/db/migrations").run(&pool).await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
148
src/main.rs
Normal file
148
src/main.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
mod auth;
|
||||||
|
mod cache;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod models;
|
||||||
|
mod routes;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{extract::DefaultBodyLimit, http::{header, HeaderValue, Method}, middleware, routing::{get, post}, Router};
|
||||||
|
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::middleware::{blacklist_layer, require_auth},
|
||||||
|
cache::spawn_sweep_task,
|
||||||
|
config::Config,
|
||||||
|
db::create_pool,
|
||||||
|
models::blacklist::BlacklistEntry,
|
||||||
|
routes::{
|
||||||
|
admin::admin_router,
|
||||||
|
auth::login,
|
||||||
|
crud::handle_crud,
|
||||||
|
},
|
||||||
|
state::{AppState, BlacklistCache, QueryCache},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn build_cors(origins: &[String]) -> CorsLayer {
|
||||||
|
if origins.is_empty() {
|
||||||
|
return CorsLayer::new();
|
||||||
|
}
|
||||||
|
if origins.iter().any(|o| o == "*") {
|
||||||
|
return CorsLayer::permissive();
|
||||||
|
}
|
||||||
|
let parsed: Vec<HeaderValue> = origins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| o.parse().ok())
|
||||||
|
.collect();
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(parsed)
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
|
||||||
|
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "mercury=info".into()))
|
||||||
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Arc::new(Config::from_env()?);
|
||||||
|
let pool = create_pool(&config.database_url).await?;
|
||||||
|
|
||||||
|
// If no users exist, prompt to create the first admin interactively.
|
||||||
|
let user_count: i64 = sqlx::query_scalar::<_, Option<i64>>("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0);
|
||||||
|
if user_count == 0 {
|
||||||
|
println!("\nNo users found. Create the first admin account.");
|
||||||
|
print!("Username: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut username = String::new();
|
||||||
|
io::stdin().read_line(&mut username)?;
|
||||||
|
let username = username.trim().to_string();
|
||||||
|
if username.is_empty() {
|
||||||
|
anyhow::bail!("username cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
print!("Password: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut password = String::new();
|
||||||
|
io::stdin().read_line(&mut password)?;
|
||||||
|
let password = password.trim().to_string();
|
||||||
|
if password.is_empty() {
|
||||||
|
anyhow::bail!("password cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, '63')",
|
||||||
|
)
|
||||||
|
.bind(&username)
|
||||||
|
.bind(hash)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
tracing::info!("created first admin user: {}", username);
|
||||||
|
println!("Admin user '{}' created. Starting server...\n", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_cache = QueryCache::new();
|
||||||
|
let blacklist_cache = BlacklistCache::new();
|
||||||
|
|
||||||
|
// Load blacklist from DB into memory
|
||||||
|
let entries = sqlx::query_as::<_, BlacklistEntry>(
|
||||||
|
"SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id"
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?;
|
||||||
|
blacklist_cache.load(entries).await;
|
||||||
|
|
||||||
|
// Start cache sweep
|
||||||
|
spawn_sweep_task(
|
||||||
|
query_cache.clone(),
|
||||||
|
config.cache_idle_timeout_secs,
|
||||||
|
config.cache_sweep_interval_secs,
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
pool,
|
||||||
|
query_cache,
|
||||||
|
blacklist_cache,
|
||||||
|
config: config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let crud_routes = Router::new()
|
||||||
|
.route("/api/:table", get(handle_crud).post(handle_crud))
|
||||||
|
.route("/api/:table/", get(handle_crud).post(handle_crud))
|
||||||
|
.route("/api/:table/:id", get(handle_crud).put(handle_crud).delete(handle_crud))
|
||||||
|
.route("/api/:table/:id/", get(handle_crud).put(handle_crud).delete(handle_crud))
|
||||||
|
.layer(DefaultBodyLimit::max(1 * 1024 * 1024))
|
||||||
|
// require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
|
||||||
|
// Order: blacklist check → auth check → handler.
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer));
|
||||||
|
|
||||||
|
let cors_layer = build_cors(&config.cors_origins);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/auth/login", post(login))
|
||||||
|
.merge(crud_routes)
|
||||||
|
.nest("/api/admin", admin_router(state.clone()))
|
||||||
|
.nest_service("/", ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")))
|
||||||
|
.layer(cors_layer)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));
|
||||||
|
tracing::info!("listening on {}", addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
20
src/models/api_key.rs
Normal file
20
src/models/api_key.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct ApiKey {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub key_prefix: String,
|
||||||
|
pub permissions_mask: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub last_used_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateApiKey {
|
||||||
|
pub name: String,
|
||||||
|
pub permissions_mask: Option<String>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
28
src/models/blacklist.rs
Normal file
28
src/models/blacklist.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct BlacklistEntry {
|
||||||
|
pub id: i32,
|
||||||
|
pub pattern: String,
|
||||||
|
pub method: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub active: bool,
|
||||||
|
pub bypass_mask: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateBlacklistEntry {
|
||||||
|
pub pattern: String,
|
||||||
|
pub method: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateBlacklistEntry {
|
||||||
|
pub pattern: Option<String>,
|
||||||
|
pub method: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub active: Option<bool>,
|
||||||
|
}
|
||||||
5
src/models/mod.rs
Normal file
5
src/models/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod api_key;
|
||||||
|
pub mod blacklist;
|
||||||
|
pub mod permission;
|
||||||
|
pub mod query;
|
||||||
|
pub mod user;
|
||||||
21
src/models/permission.rs
Normal file
21
src/models/permission.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct Permission {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub bit_value: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreatePermission {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdatePermission {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
26
src/models/query.rs
Normal file
26
src/models/query.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct StoredQuery {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub identifier: String,
|
||||||
|
pub sql_template: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateQuery {
|
||||||
|
pub identifier: String,
|
||||||
|
pub sql_template: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateQuery {
|
||||||
|
pub sql_template: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
32
src/models/user.rs
Normal file
32
src/models/user.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: i32,
|
||||||
|
pub username: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: String,
|
||||||
|
pub permissions_mask: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateUser {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub permissions_mask: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateUser {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub permissions_mask: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
72
src/routes/admin/api_keys.rs
Normal file
72
src/routes/admin/api_keys.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::generate_api_key,
|
||||||
|
models::api_key::{ApiKey, CreateApiKey},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_api_keys(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Vec<ApiKey>>, StatusCode> {
|
||||||
|
let keys = sqlx::query_as::<_, ApiKey>(
|
||||||
|
"SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at
|
||||||
|
FROM api_keys ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<CreateApiKey>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let (plain_key, key_prefix, key_hash) = generate_api_key();
|
||||||
|
let mask = body.permissions_mask.unwrap_or_else(|| "0".into());
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO api_keys (name, key_prefix, key_hash, permissions_mask, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(&body.name)
|
||||||
|
.bind(&key_prefix)
|
||||||
|
.bind(&key_hash)
|
||||||
|
.bind(&mask)
|
||||||
|
.bind(body.expires_at)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"key": plain_key,
|
||||||
|
"prefix": key_prefix,
|
||||||
|
"name": body.name,
|
||||||
|
"permissions_mask": mask,
|
||||||
|
"note": "Store this key securely — it will not be shown again."
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_api_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let rows = sqlx::query("DELETE FROM api_keys WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
Err(StatusCode::NOT_FOUND)
|
||||||
|
} else {
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/routes/admin/cache.rs
Normal file
24
src/routes/admin/cache.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use axum::extract::Extension;
|
||||||
|
|
||||||
|
use crate::{auth::Claims, state::AppState};
|
||||||
|
|
||||||
|
pub async fn cache_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
) -> Json<Value> {
|
||||||
|
Json(json!({
|
||||||
|
"size": state.query_cache.len(),
|
||||||
|
"hits": state.query_cache.hits(),
|
||||||
|
"misses": state.query_cache.misses(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn flush_cache(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
state.query_cache.flush();
|
||||||
|
Ok(Json(json!({ "flushed": true })))
|
||||||
|
}
|
||||||
53
src/routes/admin/mod.rs
Normal file
53
src/routes/admin/mod.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
pub mod api_keys;
|
||||||
|
pub mod cache;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod tables;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
middleware,
|
||||||
|
routing::{delete, get},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::middleware::{require_admin_cache, require_admin_query, require_super_admin},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn admin_router(state: AppState) -> Router<AppState> {
|
||||||
|
let query_routes = Router::new()
|
||||||
|
.route("/:identifier/execute", get(queries::execute_query))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_admin_query,
|
||||||
|
));
|
||||||
|
|
||||||
|
let cache_routes = Router::new()
|
||||||
|
.route("/stats", get(cache::cache_stats))
|
||||||
|
.route("/", delete(cache::flush_cache))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_admin_cache,
|
||||||
|
));
|
||||||
|
|
||||||
|
let super_routes = Router::new()
|
||||||
|
.route("/tables", get(tables::list_tables).post(tables::create_table))
|
||||||
|
.route(
|
||||||
|
"/tables/:name",
|
||||||
|
get(tables::get_table_preview).delete(tables::drop_table),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api-keys",
|
||||||
|
get(api_keys::list_api_keys).post(api_keys::create_api_key),
|
||||||
|
)
|
||||||
|
.route("/api-keys/:id", delete(api_keys::revoke_api_key))
|
||||||
|
.route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_super_admin,
|
||||||
|
));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.nest("/queries", query_routes)
|
||||||
|
.nest("/cache", cache_routes)
|
||||||
|
.merge(super_routes)
|
||||||
|
}
|
||||||
80
src/routes/admin/queries.rs
Normal file
80
src/routes/admin/queries.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{auth::Claims, models::query::StoredQuery, state::AppState};
|
||||||
|
|
||||||
|
pub async fn execute_query(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Path(identifier): Path<String>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let stored = sqlx::query_as::<_, StoredQuery>(
|
||||||
|
"SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1"
|
||||||
|
)
|
||||||
|
.bind(&identifier)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let mut sql = stored.sql_template.clone();
|
||||||
|
let mut bound_vals: Vec<String> = Vec::new();
|
||||||
|
let mut idx = 1usize;
|
||||||
|
let mut sorted_params: Vec<(String, String)> = params.into_iter().collect();
|
||||||
|
sorted_params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0)));
|
||||||
|
for (name, val) in &sorted_params {
|
||||||
|
let placeholder = format!(":{}", name);
|
||||||
|
if sql.contains(&placeholder) {
|
||||||
|
sql = sql.replace(&placeholder, &format!("${}", idx));
|
||||||
|
bound_vals.push(val.clone());
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for val in &bound_vals {
|
||||||
|
q = q.bind(val.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = q
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("query execution error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let json_rows: Vec<Value> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(crate::routes::crud::pg_row_to_json)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(Value::Array(json_rows)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn test_prefix_param_substitution_order() {
|
||||||
|
let template = "SELECT * FROM t WHERE user_id = :user_id AND username = :username";
|
||||||
|
let mut params: Vec<(String, String)> = vec![
|
||||||
|
("user_id".into(), "42".into()),
|
||||||
|
("username".into(), "alice".into()),
|
||||||
|
];
|
||||||
|
params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0)));
|
||||||
|
let mut sql = template.to_string();
|
||||||
|
for (i, (name, _)) in params.iter().enumerate() {
|
||||||
|
sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1));
|
||||||
|
}
|
||||||
|
assert!(!sql.contains(":username"), "placeholder not replaced: {}", sql);
|
||||||
|
assert!(!sql.contains(":user_id"), "placeholder not replaced: {}", sql);
|
||||||
|
// username (len 8) comes first → $1; user_id (len 7) → $2
|
||||||
|
assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1");
|
||||||
|
}
|
||||||
|
}
|
||||||
239
src/routes/admin/tables.rs
Normal file
239
src/routes/admin/tables.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState};
|
||||||
|
|
||||||
|
const ALLOWED_TYPES: &[&str] = &[
|
||||||
|
"TEXT", "INTEGER", "BIGINT", "SMALLINT", "BOOLEAN", "NUMERIC",
|
||||||
|
"FLOAT4", "FLOAT8", "UUID", "TIMESTAMPTZ", "DATE", "JSONB",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"];
|
||||||
|
|
||||||
|
fn is_protected(name: &str) -> bool {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
PROTECTED_TABLES.iter().any(|&t| t == lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ColumnDef {
|
||||||
|
pub name: String,
|
||||||
|
pub col_type: String,
|
||||||
|
pub nullable: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTableRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub columns: Vec<ColumnDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TableInfo {
|
||||||
|
pub table_name: String,
|
||||||
|
pub column_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ColumnInfo {
|
||||||
|
pub column_name: String,
|
||||||
|
pub data_type: String,
|
||||||
|
pub is_nullable: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TablePreview {
|
||||||
|
pub table_name: String,
|
||||||
|
pub row_count: i64,
|
||||||
|
pub columns: Vec<ColumnInfo>,
|
||||||
|
pub sample_rows: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tables(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
) -> Result<Json<Vec<TableInfo>>, StatusCode> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT t.table_name,
|
||||||
|
COUNT(c.column_name)::bigint AS column_count
|
||||||
|
FROM information_schema.tables t
|
||||||
|
LEFT JOIN information_schema.columns c
|
||||||
|
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
|
||||||
|
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
||||||
|
GROUP BY t.table_name
|
||||||
|
ORDER BY t.table_name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let tables = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TableInfo {
|
||||||
|
table_name: r.try_get::<String, _>("table_name").unwrap_or_default(),
|
||||||
|
column_count: r.try_get::<i64, _>("column_count").unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(tables))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_table_preview(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Result<Json<TablePreview>, StatusCode> {
|
||||||
|
if !crate::routes::is_valid_identifier(&name) {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let col_rows = sqlx::query(
|
||||||
|
"SELECT column_name, data_type, is_nullable \
|
||||||
|
FROM information_schema.columns \
|
||||||
|
WHERE table_schema = 'public' AND table_name = $1 \
|
||||||
|
ORDER BY ordinal_position",
|
||||||
|
)
|
||||||
|
.bind(&name)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if col_rows.is_empty() {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let columns: Vec<ColumnInfo> = col_rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ColumnInfo {
|
||||||
|
column_name: r.try_get::<String, _>("column_name").unwrap_or_default(),
|
||||||
|
data_type: r.try_get::<String, _>("data_type").unwrap_or_default(),
|
||||||
|
is_nullable: r.try_get::<String, _>("is_nullable").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count_sql = format!("SELECT COUNT(*)::bigint FROM {}", name);
|
||||||
|
let row_count: i64 = sqlx::query(&count_sql)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map(|r| r.try_get::<i64, _>(0).unwrap_or(0))
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let sample_sql = format!("SELECT * FROM {} LIMIT 10", name);
|
||||||
|
let sample_rows: Vec<Value> = sqlx::query(&sample_sql)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.into_iter()
|
||||||
|
.map(pg_row_to_json)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(TablePreview {
|
||||||
|
table_name: name,
|
||||||
|
row_count,
|
||||||
|
columns,
|
||||||
|
sample_rows,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_table(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Json(body): Json<CreateTableRequest>,
|
||||||
|
) -> Result<Json<TableInfo>, StatusCode> {
|
||||||
|
if !crate::routes::is_valid_identifier(&body.name) {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if is_protected(&body.name) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.columns.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut col_defs = vec!["id SERIAL PRIMARY KEY".to_string()];
|
||||||
|
|
||||||
|
for col in &body.columns {
|
||||||
|
if !crate::routes::is_valid_identifier(&col.name) {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let upper_type = col.col_type.to_uppercase();
|
||||||
|
if !ALLOWED_TYPES.contains(&upper_type.as_str()) {
|
||||||
|
return Err(StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", "));
|
||||||
|
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("create table error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(TableInfo {
|
||||||
|
table_name: body.name,
|
||||||
|
column_count: body.columns.len() as i64 + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn drop_table(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(_claims): Extension<Claims>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
if !crate::routes::is_valid_identifier(&name) {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if is_protected(&name) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!("DROP TABLE IF EXISTS {}", name);
|
||||||
|
sqlx::query(&sql)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("drop table error: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "dropped": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{is_protected, PROTECTED_TABLES};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_protected_tables_list() {
|
||||||
|
assert!(PROTECTED_TABLES.contains(&"users"));
|
||||||
|
assert!(PROTECTED_TABLES.contains(&"blacklist"));
|
||||||
|
assert!(PROTECTED_TABLES.contains(&"api_keys"));
|
||||||
|
assert!(PROTECTED_TABLES.contains(&"queries"));
|
||||||
|
assert!(PROTECTED_TABLES.contains(&"permissions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_protected() {
|
||||||
|
assert!(is_protected("users"));
|
||||||
|
assert!(is_protected("USERS"));
|
||||||
|
assert!(!is_protected("orders"));
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/routes/auth.rs
Normal file
35
src/routes/auth.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::encode_jwt,
|
||||||
|
models::user::LoginRequest,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let user = sqlx::query_as::<_, crate::models::user::User>(
|
||||||
|
"SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(&body.username)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let valid = bcrypt::verify(&body.password, &user.password_hash)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask: u128 = user.permissions_mask.parse().unwrap_or(0);
|
||||||
|
let token = encode_jwt(&user.username, mask, &state.config.jwt_secret, state.config.jwt_expiry_secs)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "token": token })))
|
||||||
|
}
|
||||||
389
src/routes/crud.rs
Normal file
389
src/routes/crud.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use axum::{
|
||||||
|
extract::{Extension, Path, Query, State},
|
||||||
|
http::{Method, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::postgres::PgRow;
|
||||||
|
use sqlx::Column;
|
||||||
|
use sqlx::Row;
|
||||||
|
use sqlx::TypeInfo;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::{auth::Claims, models::blacklist::BlacklistEntry, state::{AppState, CacheEntry}};
|
||||||
|
|
||||||
|
/// Returns (sql, ordered_param_values, cache_key).
|
||||||
|
/// body_cols: (col_name, typed_value) pairs from request body.
|
||||||
|
/// filter_cols: (col_name, string_value) pairs from query params.
|
||||||
|
pub fn build_query(
|
||||||
|
method: &str,
|
||||||
|
table: &str,
|
||||||
|
id: Option<&str>,
|
||||||
|
body_cols: &[(String, Value)],
|
||||||
|
filter_cols: &[(String, String)],
|
||||||
|
) -> Result<(String, Vec<Value>, String)> {
|
||||||
|
if !crate::routes::is_valid_identifier(table) {
|
||||||
|
return Err(anyhow!("invalid identifier: {}", table));
|
||||||
|
}
|
||||||
|
for (col, _) in body_cols.iter() {
|
||||||
|
if !crate::routes::is_valid_identifier(col) {
|
||||||
|
return Err(anyhow!("invalid identifier: {}", col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (col, _) in filter_cols.iter() {
|
||||||
|
if !crate::routes::is_valid_identifier(col) {
|
||||||
|
return Err(anyhow!("invalid identifier: {}", col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted_body = body_cols.to_vec();
|
||||||
|
sorted_body.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
let mut sorted_filters = filter_cols.to_vec();
|
||||||
|
sorted_filters.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
match method.to_uppercase().as_str() {
|
||||||
|
"GET" => {
|
||||||
|
if let Some(id_val) = id {
|
||||||
|
let sql = format!("SELECT * FROM {} WHERE id = $1", table);
|
||||||
|
let key = format!("GET:{}:~id", table);
|
||||||
|
Ok((sql, vec![Value::String(id_val.to_string())], key))
|
||||||
|
} else if sorted_filters.is_empty() {
|
||||||
|
let sql = format!("SELECT * FROM {}", table);
|
||||||
|
let key = format!("GET:{}:", table);
|
||||||
|
Ok((sql, vec![], key))
|
||||||
|
} else {
|
||||||
|
let col_names: Vec<String> = sorted_filters.iter().map(|(c, _)| c.clone()).collect();
|
||||||
|
let where_clause: Vec<String> = col_names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| format!("{} = ${}", c, i + 1))
|
||||||
|
.collect();
|
||||||
|
let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND "));
|
||||||
|
let params: Vec<Value> = sorted_filters.iter().map(|(_, v)| Value::String(v.clone())).collect();
|
||||||
|
let key = format!("GET:{}:{}", table, col_names.join(","));
|
||||||
|
Ok((sql, params, key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"POST" => {
|
||||||
|
if sorted_body.is_empty() {
|
||||||
|
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 sql = format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({}) RETURNING *",
|
||||||
|
table,
|
||||||
|
cols.join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
let params: Vec<Value> = sorted_body.iter().map(|(_, v)| v.clone()).collect();
|
||||||
|
let key = format!("POST:{}:{}", table, cols.join(","));
|
||||||
|
Ok((sql, params, key))
|
||||||
|
}
|
||||||
|
"PUT" => {
|
||||||
|
let id_val = id.ok_or_else(|| anyhow!("PUT requires an id"))?;
|
||||||
|
if sorted_body.is_empty() {
|
||||||
|
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
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| format!("{} = ${}", c, i + 1))
|
||||||
|
.collect();
|
||||||
|
let id_placeholder = cols.len() + 1;
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE {} SET {} WHERE id = ${} RETURNING *",
|
||||||
|
table,
|
||||||
|
set_clause.join(", "),
|
||||||
|
id_placeholder
|
||||||
|
);
|
||||||
|
let mut params: Vec<Value> = sorted_body.iter().map(|(_, v)| v.clone()).collect();
|
||||||
|
params.push(Value::String(id_val.to_string()));
|
||||||
|
let key = format!("PUT:{}:{}:by_id", table, cols.join(","));
|
||||||
|
Ok((sql, params, key))
|
||||||
|
}
|
||||||
|
"DELETE" => {
|
||||||
|
let id_val = id.ok_or_else(|| anyhow!("DELETE requires an id"))?;
|
||||||
|
let sql = format!("DELETE FROM {} WHERE id = $1", table);
|
||||||
|
let key = format!("DELETE:{}:by_id", table);
|
||||||
|
Ok((sql, vec![Value::String(id_val.to_string())], key))
|
||||||
|
}
|
||||||
|
m => Err(anyhow!("unsupported method: {}", m)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pg_row_to_json(row: PgRow) -> Value {
|
||||||
|
let columns = row.columns();
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
for col in columns {
|
||||||
|
let name = col.name().to_string();
|
||||||
|
let type_name = col.type_info().name();
|
||||||
|
let val = match type_name {
|
||||||
|
"INT2" => row
|
||||||
|
.try_get::<i16, _>(col.ordinal())
|
||||||
|
.map(|v| Value::Number(i64::from(v).into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"INT4" | "SERIAL" => row
|
||||||
|
.try_get::<i32, _>(col.ordinal())
|
||||||
|
.map(|v| Value::Number(i64::from(v).into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"INT8" => row
|
||||||
|
.try_get::<i64, _>(col.ordinal())
|
||||||
|
.map(|v| Value::Number(v.into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"FLOAT4" | "FLOAT8" => row
|
||||||
|
.try_get::<f64, _>(col.ordinal())
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| serde_json::Number::from_f64(v))
|
||||||
|
.map(Value::Number)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"BOOL" => row
|
||||||
|
.try_get::<bool, _>(col.ordinal())
|
||||||
|
.map(Value::Bool)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"UUID" => row
|
||||||
|
.try_get::<uuid::Uuid, _>(col.ordinal())
|
||||||
|
.map(|v| Value::String(v.to_string()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"TIMESTAMPTZ" | "TIMESTAMP" => row
|
||||||
|
.try_get::<chrono::DateTime<chrono::Utc>, _>(col.ordinal())
|
||||||
|
.map(|v| Value::String(v.to_rfc3339()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
_ => row
|
||||||
|
.try_get::<String, _>(col.ordinal())
|
||||||
|
.map(Value::String)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
};
|
||||||
|
map.insert(name, val);
|
||||||
|
}
|
||||||
|
Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_blacklist(state: &AppState) -> Result<(), StatusCode> {
|
||||||
|
let entries = sqlx::query_as::<_, BlacklistEntry>(
|
||||||
|
"SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
state.blacklist_cache.load(entries).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_password_hash(v: Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::Object(mut m) => {
|
||||||
|
m.remove("password_hash");
|
||||||
|
Value::Object(m)
|
||||||
|
}
|
||||||
|
Value::Array(arr) => Value::Array(
|
||||||
|
arr.into_iter()
|
||||||
|
.map(|item| match item {
|
||||||
|
Value::Object(mut m) => {
|
||||||
|
m.remove("password_hash");
|
||||||
|
Value::Object(m)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_crud(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
method: Method,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Path(params): Path<HashMap<String, String>>,
|
||||||
|
Query(query_params): Query<HashMap<String, String>>,
|
||||||
|
body: Option<Json<HashMap<String, Value>>>,
|
||||||
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
|
let table = params.get("table").ok_or(StatusCode::BAD_REQUEST)?.clone();
|
||||||
|
let id = params.get("id").map(|s| s.as_str());
|
||||||
|
let method_str = method.as_str();
|
||||||
|
|
||||||
|
// Enforce permission bits before doing any work.
|
||||||
|
let required_bit = match method_str.to_uppercase().as_str() {
|
||||||
|
"GET" => crate::auth::permissions::READ,
|
||||||
|
"POST" | "PUT" => crate::auth::permissions::WRITE,
|
||||||
|
"DELETE" => crate::auth::permissions::DELETE,
|
||||||
|
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
||||||
|
};
|
||||||
|
if !claims.has_permission(required_bit) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect body as typed Values directly — no sentinel needed.
|
||||||
|
let mut body_cols: Vec<(String, Value)> = body
|
||||||
|
.map(|Json(b)| b.into_iter().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Hash the password field for the users table before building the query.
|
||||||
|
if table == "users" && matches!(method_str.to_uppercase().as_str(), "POST" | "PUT") {
|
||||||
|
if let Some(pos) = body_cols.iter().position(|(k, _)| k == "password") {
|
||||||
|
let (_, val) = body_cols.remove(pos);
|
||||||
|
if let Value::String(plaintext) = val {
|
||||||
|
let hash = bcrypt::hash(&plaintext, bcrypt::DEFAULT_COST)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
body_cols.push(("password_hash".to_string(), Value::String(hash)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter_cols: Vec<(String, String)> = query_params.into_iter().collect();
|
||||||
|
|
||||||
|
let (sql, params_vals, cache_key) =
|
||||||
|
build_query(method_str, &table, id, &body_cols, &filter_cols)
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let sql = if let Some(entry) = state.query_cache.get(&cache_key) {
|
||||||
|
entry.sql.clone()
|
||||||
|
} else {
|
||||||
|
let entry = CacheEntry::new(sql.clone());
|
||||||
|
state
|
||||||
|
.query_cache
|
||||||
|
.insert(cache_key, entry.clone(), state.config.cache_max_capacity);
|
||||||
|
entry.sql
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for val in ¶ms_vals {
|
||||||
|
match val {
|
||||||
|
Value::Null => q = q.bind(Option::<String>::None),
|
||||||
|
Value::Bool(b) => q = q.bind(*b),
|
||||||
|
Value::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
q = q.bind(i);
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
q = q.bind(f);
|
||||||
|
} else {
|
||||||
|
q = q.bind(n.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::String(s) => q = q.bind(s.as_str()),
|
||||||
|
other => q = q.bind(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = match method_str.to_uppercase().as_str() {
|
||||||
|
"GET" => {
|
||||||
|
let rows = q
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let mut v = Value::Array(rows.into_iter().map(pg_row_to_json).collect());
|
||||||
|
if table == "users" {
|
||||||
|
v = strip_password_hash(v);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
"POST" | "PUT" => {
|
||||||
|
let row = q
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if e.to_string().contains("no rows") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let mut v = pg_row_to_json(row);
|
||||||
|
if table == "users" {
|
||||||
|
v = strip_password_hash(v);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
"DELETE" => {
|
||||||
|
q.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
serde_json::json!({ "deleted": true })
|
||||||
|
}
|
||||||
|
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reload the in-memory blacklist cache after any mutation to the blacklist table.
|
||||||
|
if table == "blacklist" && method_str != "GET" {
|
||||||
|
reload_blacklist(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_select_all() {
|
||||||
|
let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap();
|
||||||
|
assert_eq!(sql, "SELECT * FROM orders");
|
||||||
|
assert!(params.is_empty());
|
||||||
|
assert_eq!(key, "GET:orders:");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_select_by_id() {
|
||||||
|
let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap();
|
||||||
|
assert_eq!(sql, "SELECT * FROM orders WHERE id = $1");
|
||||||
|
assert_eq!(params, vec![Value::String("42".into())]);
|
||||||
|
assert_eq!(key, "GET:orders:~id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_insert() {
|
||||||
|
let cols = vec![
|
||||||
|
("email".into(), Value::String("a@b.com".into())),
|
||||||
|
("name".into(), Value::String("Alice".into())),
|
||||||
|
];
|
||||||
|
let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap();
|
||||||
|
assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *");
|
||||||
|
assert_eq!(params, vec![Value::String("a@b.com".into()), Value::String("Alice".into())]);
|
||||||
|
assert_eq!(key, "POST:users:email,name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_update() {
|
||||||
|
let cols = vec![("name".into(), Value::String("Bob".into()))];
|
||||||
|
let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap();
|
||||||
|
assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *");
|
||||||
|
assert_eq!(params, vec![Value::String("Bob".into()), Value::String("7".into())]);
|
||||||
|
assert_eq!(key, "PUT:users:name:by_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_delete() {
|
||||||
|
let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap();
|
||||||
|
assert_eq!(sql, "DELETE FROM users WHERE id = $1");
|
||||||
|
assert_eq!(params, vec![Value::String("3".into())]);
|
||||||
|
assert_eq!(key, "DELETE:users:by_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_select_with_filters() {
|
||||||
|
let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())];
|
||||||
|
let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap();
|
||||||
|
assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2");
|
||||||
|
assert_eq!(params, vec![Value::String("admin".into()), Value::String("active".into())]);
|
||||||
|
assert_eq!(key, "GET:users:role,status");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_null_body_value() {
|
||||||
|
let cols = vec![("note".into(), Value::Null)];
|
||||||
|
let (sql, params, key) = build_query("POST", "items", None, &cols, &[]).unwrap();
|
||||||
|
assert_eq!(sql, "INSERT INTO items (note) VALUES ($1) RETURNING *");
|
||||||
|
assert_eq!(params, vec![Value::Null]);
|
||||||
|
assert_eq!(key, "POST:items:note");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejects_invalid_table_name() {
|
||||||
|
let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/routes/mod.rs
Normal file
7
src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod admin;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod crud;
|
||||||
|
|
||||||
|
pub fn is_valid_identifier(name: &str) -> bool {
|
||||||
|
!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
252
src/state.rs
Normal file
252
src/state.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use glob::Pattern;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::models::blacklist::BlacklistEntry;
|
||||||
|
|
||||||
|
pub fn unix_now() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: PgPool,
|
||||||
|
pub query_cache: QueryCache,
|
||||||
|
pub blacklist_cache: BlacklistCache,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct QueryCache {
|
||||||
|
pub map: Arc<DashMap<String, CacheEntry>>,
|
||||||
|
pub hits: Arc<AtomicU64>,
|
||||||
|
pub misses: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CacheEntry {
|
||||||
|
pub sql: String,
|
||||||
|
pub last_accessed_secs: Arc<AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheEntry {
|
||||||
|
pub fn new(sql: String) -> Self {
|
||||||
|
Self {
|
||||||
|
sql,
|
||||||
|
last_accessed_secs: Arc::new(AtomicU64::new(unix_now())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch(&self) {
|
||||||
|
self.last_accessed_secs.store(unix_now(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_accessed(&self) -> u64 {
|
||||||
|
self.last_accessed_secs.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
map: Arc::new(DashMap::new()),
|
||||||
|
hits: Arc::new(AtomicU64::new(0)),
|
||||||
|
misses: Arc::new(AtomicU64::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &str) -> Option<CacheEntry> {
|
||||||
|
if let Some(entry) = self.map.get(key) {
|
||||||
|
entry.touch();
|
||||||
|
self.hits.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Some(entry.clone())
|
||||||
|
} else {
|
||||||
|
self.misses.fetch_add(1, Ordering::Relaxed);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&self, key: String, entry: CacheEntry, max_capacity: usize) {
|
||||||
|
if self.map.len() >= max_capacity {
|
||||||
|
let oldest_key = self
|
||||||
|
.map
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|e| e.last_accessed())
|
||||||
|
.map(|e| e.key().clone());
|
||||||
|
if let Some(k) = oldest_key {
|
||||||
|
self.map.remove(&k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.map.insert(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, key: &str) {
|
||||||
|
self.map.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hits(&self) -> u64 {
|
||||||
|
self.hits.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn misses(&self) -> u64 {
|
||||||
|
self.misses.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.map.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flush(&self) {
|
||||||
|
self.map.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BlacklistCache {
|
||||||
|
pub inner: Arc<RwLock<Vec<CompiledEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CompiledEntry {
|
||||||
|
pub entry: BlacklistEntry,
|
||||||
|
pub pattern: Pattern,
|
||||||
|
pub bypass_mask: Option<u128>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlacklistCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(&self, entries: Vec<BlacklistEntry>) {
|
||||||
|
let compiled: Vec<CompiledEntry> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let bypass_mask = e
|
||||||
|
.bypass_mask
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse::<u128>().ok());
|
||||||
|
Pattern::new(&e.pattern)
|
||||||
|
.ok()
|
||||||
|
.map(|pattern| CompiledEntry { entry: e, pattern, bypass_mask })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
*guard = compiled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the request should be blocked.
|
||||||
|
/// `caller_mask` is 0 for unauthenticated requests; bypass only applies
|
||||||
|
/// when the caller holds the permission bit stored in bypass_mask.
|
||||||
|
pub async fn is_blocked(&self, method: &str, path: &str, caller_mask: u128) -> bool {
|
||||||
|
let path = path.trim_end_matches('/');
|
||||||
|
let path = if path.is_empty() { "/" } else { path };
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
guard.iter().any(|compiled| {
|
||||||
|
if !compiled.entry.active {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let method_matches = compiled
|
||||||
|
.entry
|
||||||
|
.method
|
||||||
|
.as_deref()
|
||||||
|
.map(|m| m.split(',').any(|part| part.trim().eq_ignore_ascii_case(method)))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if !method_matches || !compiled.pattern.matches(path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// If caller holds the bypass permission, they are not blocked.
|
||||||
|
match compiled.bypass_mask {
|
||||||
|
Some(mask) if caller_mask & mask != 0 => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_insert_and_get() {
|
||||||
|
let cache = QueryCache::new();
|
||||||
|
let entry = CacheEntry::new("SELECT 1".into());
|
||||||
|
cache.insert("key1".into(), entry, 100);
|
||||||
|
let got = cache.get("key1");
|
||||||
|
assert!(got.is_some());
|
||||||
|
assert_eq!(got.unwrap().sql, "SELECT 1");
|
||||||
|
assert_eq!(cache.hits(), 1);
|
||||||
|
assert_eq!(cache.misses(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_miss() {
|
||||||
|
let cache = QueryCache::new();
|
||||||
|
let got = cache.get("missing");
|
||||||
|
assert!(got.is_none());
|
||||||
|
assert_eq!(cache.misses(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_capacity_evicts_oldest() {
|
||||||
|
let cache = QueryCache::new();
|
||||||
|
let e1 = CacheEntry::new("SELECT 1".into());
|
||||||
|
// force e1 to be older
|
||||||
|
e1.last_accessed_secs.store(1, Ordering::Relaxed);
|
||||||
|
cache.map.insert("old".into(), e1);
|
||||||
|
let e2 = CacheEntry::new("SELECT 2".into());
|
||||||
|
cache.insert("new".into(), e2, 1); // capacity=1, should evict "old"
|
||||||
|
assert!(cache.map.get("old").is_none());
|
||||||
|
assert!(cache.map.get("new").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_blacklist_blocks_pattern() {
|
||||||
|
use chrono::Utc;
|
||||||
|
let cache = BlacklistCache::new();
|
||||||
|
let entry = BlacklistEntry {
|
||||||
|
id: 1,
|
||||||
|
pattern: "/api/users/**".into(),
|
||||||
|
method: None,
|
||||||
|
reason: None,
|
||||||
|
active: true,
|
||||||
|
bypass_mask: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
cache.load(vec![entry]).await;
|
||||||
|
assert!(cache.is_blocked("GET", "/api/users/42", 0).await);
|
||||||
|
assert!(!cache.is_blocked("GET", "/api/orders/1", 0).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_blacklist_method_specific() {
|
||||||
|
use chrono::Utc;
|
||||||
|
let cache = BlacklistCache::new();
|
||||||
|
let entry = BlacklistEntry {
|
||||||
|
id: 2,
|
||||||
|
pattern: "/api/secrets".into(),
|
||||||
|
method: Some("GET".into()),
|
||||||
|
reason: None,
|
||||||
|
active: true,
|
||||||
|
bypass_mask: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
};
|
||||||
|
cache.load(vec![entry]).await;
|
||||||
|
assert!(cache.is_blocked("GET", "/api/secrets", 0).await);
|
||||||
|
assert!(!cache.is_blocked("POST", "/api/secrets", 0).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ui/.gitignore
vendored
Normal file
10
ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# vue-tsc emit artifacts (should never appear in src/ — noEmit is set)
|
||||||
|
src/**/*.vue.js
|
||||||
|
src/**/*.vue.js.map
|
||||||
|
src/**/*.vue.d.ts
|
||||||
|
src/**/*.vue.d.ts.map
|
||||||
|
src/**/*.ts.js
|
||||||
|
src/**/*.ts.js.map
|
||||||
230
ui/bun.lock
Normal file
230
ui/bun.lock
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "mercury-ui",
|
||||||
|
"dependencies": {
|
||||||
|
"@nychthemeron/library": "latest",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@nychthemeron/library": ["@nychthemeron/library@0.0.1", "https://git.mcpeakdev.com/api/packages/McPeakDev/npm/%40nychthemeron%2Flibrary/-/0.0.1/library-0.0.1.tgz", { "peerDependencies": { "primevue": "^4.5.0", "vue": "^3.5.0" } }, "sha512-84pcTnF8Ead8D7TSLhKTS8ACpaOCTS0mylzkWqgGrNs4fnrH9tEJg874nFHegiFy5P1fuKCk4B7Yt/Pjc4NK/Q=="],
|
||||||
|
|
||||||
|
"@primeuix/styled": ["@primeuix/styled@0.7.4", "", { "dependencies": { "@primeuix/utils": "^0.6.1" } }, "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ=="],
|
||||||
|
|
||||||
|
"@primeuix/styles": ["@primeuix/styles@2.0.3", "", { "dependencies": { "@primeuix/styled": "^0.7.4" } }, "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw=="],
|
||||||
|
|
||||||
|
"@primeuix/utils": ["@primeuix/utils@0.6.4", "", {}, "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg=="],
|
||||||
|
|
||||||
|
"@primevue/core": ["@primevue/core@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/utils": "^0.6.2" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ=="],
|
||||||
|
|
||||||
|
"@primevue/icons": ["@primevue/icons@4.5.5", "", { "dependencies": { "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5" } }, "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.0", "", { "os": "android", "cpu": "arm64" }, "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||||
|
|
||||||
|
"@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
|
||||||
|
|
||||||
|
"@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="],
|
||||||
|
|
||||||
|
"@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="],
|
||||||
|
|
||||||
|
"@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="],
|
||||||
|
|
||||||
|
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="],
|
||||||
|
|
||||||
|
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="],
|
||||||
|
|
||||||
|
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="],
|
||||||
|
|
||||||
|
"@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
|
||||||
|
|
||||||
|
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||||
|
|
||||||
|
"@vue/language-core": ["@vue/language-core@2.2.12", "", { "dependencies": { "@volar/language-core": "2.4.15", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA=="],
|
||||||
|
|
||||||
|
"@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="],
|
||||||
|
|
||||||
|
"@vue/runtime-core": ["@vue/runtime-core@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw=="],
|
||||||
|
|
||||||
|
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/runtime-core": "3.5.38", "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A=="],
|
||||||
|
|
||||||
|
"@vue/server-renderer": ["@vue/server-renderer@3.5.38", "", { "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "vue": "3.5.38" } }, "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw=="],
|
||||||
|
|
||||||
|
"@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="],
|
||||||
|
|
||||||
|
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||||
|
|
||||||
|
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||||
|
|
||||||
|
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||||
|
|
||||||
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"pinia": ["pinia@2.3.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||||
|
|
||||||
|
"primevue": ["primevue@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/styles": "^2.0.3", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5", "@primevue/icons": "4.5.5" } }, "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.62.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.0", "@rollup/rollup-android-arm64": "4.62.0", "@rollup/rollup-darwin-arm64": "4.62.0", "@rollup/rollup-darwin-x64": "4.62.0", "@rollup/rollup-freebsd-arm64": "4.62.0", "@rollup/rollup-freebsd-x64": "4.62.0", "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", "@rollup/rollup-linux-arm-musleabihf": "4.62.0", "@rollup/rollup-linux-arm64-gnu": "4.62.0", "@rollup/rollup-linux-arm64-musl": "4.62.0", "@rollup/rollup-linux-loong64-gnu": "4.62.0", "@rollup/rollup-linux-loong64-musl": "4.62.0", "@rollup/rollup-linux-ppc64-gnu": "4.62.0", "@rollup/rollup-linux-ppc64-musl": "4.62.0", "@rollup/rollup-linux-riscv64-gnu": "4.62.0", "@rollup/rollup-linux-riscv64-musl": "4.62.0", "@rollup/rollup-linux-s390x-gnu": "4.62.0", "@rollup/rollup-linux-x64-gnu": "4.62.0", "@rollup/rollup-linux-x64-musl": "4.62.0", "@rollup/rollup-openbsd-x64": "4.62.0", "@rollup/rollup-openharmony-arm64": "4.62.0", "@rollup/rollup-win32-arm64-msvc": "4.62.0", "@rollup/rollup-win32-ia32-msvc": "4.62.0", "@rollup/rollup-win32-x64-gnu": "4.62.0", "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
|
||||||
|
"vue": ["vue@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", "@vue/runtime-dom": "3.5.38", "@vue/server-renderer": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A=="],
|
||||||
|
|
||||||
|
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||||
|
|
||||||
|
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||||
|
|
||||||
|
"vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mercury</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
ui/package.json
Normal file
23
ui/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "mercury-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nychthemeron/library": "latest",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ui/public/favicon.svg
Normal file
13
ui/public/favicon.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#1a1410"/>
|
||||||
|
<!-- Mercury symbol ☿ -->
|
||||||
|
<!-- Top crescent (horns) -->
|
||||||
|
<path d="M22 18 Q32 10 42 18" fill="none" stroke="#c9a84c" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
<!-- Circle for the head -->
|
||||||
|
<circle cx="32" cy="25" r="7" fill="none" stroke="#c9a84c" stroke-width="3.5"/>
|
||||||
|
<!-- Vertical staff -->
|
||||||
|
<line x1="32" y1="32" x2="32" y2="50" stroke="#c9a84c" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
<!-- Cross bar -->
|
||||||
|
<line x1="24" y1="40" x2="40" y2="40" stroke="#c9a84c" stroke-width="3.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
3
ui/src/App.vue
Normal file
3
ui/src/App.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
246
ui/src/assets/main.css
Normal file
246
ui/src/assets/main.css
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
background-color: var(--surface-0);
|
||||||
|
color: var(--text-body);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 600; /* Cinzel's actual heaviest weight — avoids synthetic bold */
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
color: var(--text-high);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-label);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border-lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page header ────────────────────────────────────────── */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title .subtitle {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table card ─────────────────────────────────────────── */
|
||||||
|
.table-card {
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.07),
|
||||||
|
0 1px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll wrapper sits inside the card so the header stays pinned */
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card-header .count {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: max-content;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
background-color: var(--surface-2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.8rem 1.25rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-lo);
|
||||||
|
color: var(--text-body);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr {
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover td {
|
||||||
|
background: color-mix(in srgb, var(--primary) 4%, var(--surface-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 0.35;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .empty-label {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .empty-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dialog form ────────────────────────────────────────── */
|
||||||
|
.dialog-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field .hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gold divider ───────────────────────────────────────── */
|
||||||
|
.gold-rule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.gold-rule::before,
|
||||||
|
.gold-rule::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--primary),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.nych-dialog {
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^="nych-button"] {
|
||||||
|
box-shadow: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class^="nych-button"]:hover {
|
||||||
|
box-shadow: 0 0 5px var(--text-high);
|
||||||
|
}
|
||||||
7
ui/src/env.d.ts
vendored
Normal file
7
ui/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent
|
||||||
|
export default component
|
||||||
|
}
|
||||||
16
ui/src/main.ts
Normal file
16
ui/src/main.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import '@nychthemeron/library/theme'
|
||||||
|
import './assets/main.css'
|
||||||
|
import './stores/theme'
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import PrimeVue from "@primevue/core/config";
|
||||||
|
import { createNychthemeron } from "@nychthemeron/library";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "./router";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
app.use(PrimeVue, { unstyled: true });
|
||||||
|
app.use(createNychthemeron());
|
||||||
|
app.mount("#app");
|
||||||
36
ui/src/router/index.ts
Normal file
36
ui/src/router/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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') },
|
||||||
|
],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{ path: '/', redirect: '/admin/queries' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
|
if (to.path === '/login' && auth.isAuthenticated) {
|
||||||
|
return '/admin/queries'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
70
ui/src/stores/auth.ts
Normal file
70
ui/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
const isAuthenticated = computed(() => {
|
||||||
|
if (!claims.value) return false;
|
||||||
|
return claims.value.exp * 1000 > Date.now();
|
||||||
|
});
|
||||||
|
const username = computed(() => claims.value?.sub ?? "");
|
||||||
|
|
||||||
|
function hasPermission(bit: bigint): boolean {
|
||||||
|
if (!claims.value) return false;
|
||||||
|
const mask = BigInt(claims.value.permissions);
|
||||||
|
return (mask & bit) !== 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperAdmin = computed(() => 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,
|
||||||
|
isAuthenticated,
|
||||||
|
username,
|
||||||
|
isSuperAdmin,
|
||||||
|
hasPermission,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
authHeaders,
|
||||||
|
};
|
||||||
|
});
|
||||||
24
ui/src/stores/theme.ts
Normal file
24
ui/src/stores/theme.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export type Theme = 'apollo' | 'hades'
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('mercury-theme') as Theme | null
|
||||||
|
const theme = ref<Theme>(stored ?? 'apollo')
|
||||||
|
|
||||||
|
function applyTheme(t: Theme) {
|
||||||
|
theme.value = t
|
||||||
|
document.documentElement.setAttribute('data-theme', t)
|
||||||
|
localStorage.setItem('mercury-theme', t)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(theme.value)
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
isDark: () => theme.value === 'hades',
|
||||||
|
toggle() {
|
||||||
|
applyTheme(theme.value === 'apollo' ? 'hades' : 'apollo')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
174
ui/src/views/Login.vue
Normal file
174
ui/src/views/Login.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="brand-ornament">◆</div>
|
||||||
|
<h1>Mercury</h1>
|
||||||
|
<p class="brand-sub">Administration Console</p>
|
||||||
|
<div class="brand-rule"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="login-form">
|
||||||
|
<div class="login-field">
|
||||||
|
<label>Username</label>
|
||||||
|
<NychInputText
|
||||||
|
v-model="username"
|
||||||
|
placeholder="username"
|
||||||
|
fluid
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="login-field">
|
||||||
|
<label>Password</label>
|
||||||
|
<NychInputText
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
fluid
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="login-error">{{ error }}</p>
|
||||||
|
<NychButton type="submit" :loading="loading"
|
||||||
|
>Sign In</NychButton
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const username = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value);
|
||||||
|
router.push("/admin/queries");
|
||||||
|
} catch {
|
||||||
|
error.value = "Invalid username or password";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--surface-0);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse 80% 60% at 50% 0%,
|
||||||
|
color-mix(in srgb, var(--primary) 8%, transparent),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 380px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px rgba(0, 0, 0, 0.04),
|
||||||
|
0 12px 40px rgba(0, 0, 0, 0.1),
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
padding: 2.5rem 2.25rem 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-ornament {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: var(--primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-rule {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
var(--border-hi),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--danger);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--danger-subtle);
|
||||||
|
border: 1px solid var(--danger-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nych-button-primary {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
ui/src/views/admin/ApiKeys.vue
Normal file
245
ui/src/views/admin/ApiKeys.vue
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>API Keys</h2>
|
||||||
|
<span class="subtitle">Long-lived tokens for non-interactive service access — scoped by permission mask</span>
|
||||||
|
</div>
|
||||||
|
<NychButton @click="showCreate = true">+ New Key</NychButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<span class="count">{{ keys.length }} {{ keys.length === 1 ? 'key' : 'keys' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="keys.length"><div class="table-scroll"><table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Prefix</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Mask</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="k in keys" :key="k.id">
|
||||||
|
<td class="name-cell">{{ k.name }}</td>
|
||||||
|
<td><code class="prefix-cell">mrc_{{ k.key_prefix }}…</code></td>
|
||||||
|
<td><span class="role-badge" :class="roleBadgeClass(k.permissions_mask)">{{ roleName(k.permissions_mask) }}</span></td>
|
||||||
|
<td><code>{{ k.permissions_mask }}</code></td>
|
||||||
|
<td class="date-cell">{{ fmtDate(k.created_at) }}</td>
|
||||||
|
<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="small" severity="danger" @click="revoke(k.id, k.name)">Revoke</NychButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></template>
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<span class="empty-icon">⚿</span>
|
||||||
|
<span class="empty-label">No API keys</span>
|
||||||
|
<span class="empty-hint">Create a key to allow service-to-service access without user credentials.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create dialog -->
|
||||||
|
<NychDialog v-model:visible="showCreate" header="New API Key" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
|
||||||
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<NychInputText v-model="form.name" placeholder="e.g. CI pipeline, data importer" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Role</label>
|
||||||
|
<NychSelect v-model="form.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" placeholder="Select a role" fluid />
|
||||||
|
<p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Expires <span class="optional">(optional — leave blank for no expiry)</span></label>
|
||||||
|
<NychInputText v-model="form.expires_at" type="datetime-local" fluid />
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" fluid>Generate Key</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<!-- Key reveal dialog — shown once after creation -->
|
||||||
|
<NychDialog v-model:visible="showReveal" header="API Key Created" :modal="true" :draggable="false" :closable="false" style="width: min(600px, 95vw)">
|
||||||
|
<div class="reveal-body">
|
||||||
|
<NychMessage severity="warn" class="reveal-warning">
|
||||||
|
Copy this key now — it will <strong>not</strong> be shown again.
|
||||||
|
</NychMessage>
|
||||||
|
<div class="key-display">
|
||||||
|
<code class="key-text">{{ newKey }}</code>
|
||||||
|
<button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
|
||||||
|
{{ copied ? '✓' : '⎘' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NychButton fluid @click="closeReveal">I've saved the key</NychButton>
|
||||||
|
</div>
|
||||||
|
</NychDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const keys = ref<any[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showReveal = ref(false)
|
||||||
|
const newKey = ref('')
|
||||||
|
const copied = ref(false)
|
||||||
|
const form = ref({ name: '', permissions_mask: '', expires_at: '' })
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ label: 'Viewer', value: '1', description: 'Read-only access to API data' },
|
||||||
|
{ label: 'Editor', value: '7', description: 'Read, write, and delete via API' },
|
||||||
|
{ label: 'Query Admin', value: '15', description: 'Editor + manage SQL query registry' },
|
||||||
|
{ label: 'Ops Admin', value: '23', description: 'Editor + manage query cache' },
|
||||||
|
{ label: 'Admin', value: '31', description: 'All above combined' },
|
||||||
|
{ label: 'Super Admin', value: '63', description: 'Full access including users, permissions, and blacklist' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function roleName(mask: string) {
|
||||||
|
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadgeClass(mask: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'1': 'role-viewer', '7': 'role-editor',
|
||||||
|
'15': 'role-query', '23': 'role-ops',
|
||||||
|
'31': 'role-admin', '63': 'role-super',
|
||||||
|
}
|
||||||
|
return map[mask] ?? 'role-custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
|
||||||
|
keys.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
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
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyKey() {
|
||||||
|
await navigator.clipboard.writeText(newKey.value)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReveal() {
|
||||||
|
showReveal.value = false
|
||||||
|
newKey.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.name-cell { font-weight: 600; color: var(--text-high); }
|
||||||
|
.prefix-cell { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
.date-cell { color: var(--text-muted); font-size: 0.82rem; font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.role-viewer { background: var(--info-subtle); color: var(--info); border: 1px solid var(--info-border); }
|
||||||
|
.role-editor { background: var(--success-subtle); color: var(--success); border: 1px solid var(--success-border); }
|
||||||
|
.role-query { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
||||||
|
.role-ops { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
||||||
|
.role-admin { background: color-mix(in srgb, var(--primary) 12%, var(--surface-1)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); }
|
||||||
|
.role-super { background: var(--primary); color: var(--primary-fg); border: 1px solid var(--primary); }
|
||||||
|
.role-custom { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* Key reveal */
|
||||||
|
.reveal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal-warning {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-text {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-high);
|
||||||
|
word-break: break-all;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.copy-btn.copied { border-color: var(--success); color: var(--success); background: var(--success-subtle); }
|
||||||
|
</style>
|
||||||
232
ui/src/views/admin/Blacklist.vue
Normal file
232
ui/src/views/admin/Blacklist.vue
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>Route Blacklist</h2>
|
||||||
|
<span class="subtitle">Glob patterns blocking API routes — matched against path and HTTP method</span>
|
||||||
|
</div>
|
||||||
|
<NychButton @click="showCreate = true">+ Add Pattern</NychButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<span class="count">{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="entries.length"><div class="table-scroll"><table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pattern</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Bypass mask</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="e in entries" :key="e.id">
|
||||||
|
<td><code>{{ e.pattern }}</code></td>
|
||||||
|
<td>
|
||||||
|
<div class="method-cell">
|
||||||
|
<template v-if="e.method">
|
||||||
|
<span v-for="m in e.method.split(',')" :key="m" class="method-badge">{{ m.trim() }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="dim">ALL</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" :class="e.active ? 'status-active' : 'status-inactive'">
|
||||||
|
{{ e.active ? 'Active' : 'Disabled' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="reason-cell">{{ e.reason ?? '—' }}</td>
|
||||||
|
<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="small" @click="openEdit(e)">Edit</NychButton>
|
||||||
|
<NychButton size="small" severity="danger" @click="deleteEntry(e.id)">Delete</NychButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></template>
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<span class="empty-icon">⊘</span>
|
||||||
|
<span class="empty-label">Blacklist is empty</span>
|
||||||
|
<span class="empty-hint">All routes are currently open. Add a pattern to block access.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showEdit" header="Edit Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
|
||||||
|
<form @submit.prevent="submitEdit" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Pattern</label>
|
||||||
|
<NychInputText v-model="editForm.pattern" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
|
||||||
|
<MethodSelect v-model="editForm.methods" :options="HTTP_METHODS" placeholder="All methods" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Reason <span class="optional">(optional)</span></label>
|
||||||
|
<NychInputText v-model="editForm.reason" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Bypass permission mask <span class="optional">(optional)</span></label>
|
||||||
|
<NychInputText v-model="editForm.bypass_mask" placeholder="32" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Status</label>
|
||||||
|
<NychSelect v-model="editForm.active" :options="[{ label: 'Active', value: true }, { label: 'Disabled', value: false }]" optionLabel="label" optionValue="value" fluid />
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Save Changes</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showCreate" header="New Blacklist Entry" :modal="true" :draggable="false" style="width: min(640px, 95vw)">
|
||||||
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Pattern</label>
|
||||||
|
<NychInputText v-model="form.pattern" placeholder="/api/sensitive/**" fluid />
|
||||||
|
<p class="hint">Use <code>*</code> for one segment, <code>**</code> for any depth.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>HTTP Methods <span class="optional">(none = all methods)</span></label>
|
||||||
|
<MethodSelect v-model="form.methods" :options="HTTP_METHODS" placeholder="All methods" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Reason <span class="optional">(optional)</span></label>
|
||||||
|
<NychInputText v-model="form.reason" placeholder="Why is this route blocked?" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Bypass permission mask <span class="optional">(optional)</span></label>
|
||||||
|
<NychInputText v-model="form.bypass_mask" placeholder="32" fluid />
|
||||||
|
<p class="hint">Permission bit that allows callers to bypass this rule. Leave blank to block everyone.</p>
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Add to Blacklist</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import MethodSelect from './MethodSelect.vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const entries = ref<any[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editId = ref<number | null>(null)
|
||||||
|
const editForm = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '', active: true })
|
||||||
|
const form = ref({ pattern: '', methods: [] as string[], reason: '', bypass_mask: '' })
|
||||||
|
|
||||||
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/blacklist', { headers: auth.authHeaders() })
|
||||||
|
entries.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodsToString(methods: string[]): string | null {
|
||||||
|
return methods.length ? methods.join(',') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringToMethods(s: string | null | undefined): string[] {
|
||||||
|
return s ? s.split(',').map(m => m.trim()).filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(e: any) {
|
||||||
|
editId.value = e.id
|
||||||
|
editForm.value = {
|
||||||
|
pattern: e.pattern,
|
||||||
|
methods: stringToMethods(e.method),
|
||||||
|
reason: e.reason ?? '',
|
||||||
|
bypass_mask: e.bypass_mask ?? '',
|
||||||
|
active: e.active,
|
||||||
|
}
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
showEdit.value = false
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEntry(id: number) {
|
||||||
|
if (!confirm('Delete this blacklist entry?')) return
|
||||||
|
await fetch(`/api/blacklist/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reason-cell { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
.dim { color: var(--text-dim); font-size: 0.8rem; font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.method-cell { display: flex; gap: 0.25rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
.bit-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-lo);
|
||||||
|
color: var(--text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-lo);
|
||||||
|
color: var(--text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.status-active { background: var(--danger-subtle); color: var(--danger); border: 1px solid var(--danger-border); }
|
||||||
|
.status-inactive { background: var(--surface-2); color: var(--text-dim); border: 1px solid var(--border-lo); }
|
||||||
|
</style>
|
||||||
154
ui/src/views/admin/Cache.vue
Normal file
154
ui/src/views/admin/Cache.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>Query Cache</h2>
|
||||||
|
<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 severity="danger" @click="flushCache">Flush Cache</NychButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid" v-if="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Entries</span>
|
||||||
|
<span class="stat-value">{{ stats.size }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Cache Hits</span>
|
||||||
|
<span class="stat-value success">{{ stats.hits }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">Cache Misses</span>
|
||||||
|
<span class="stat-value muted">{{ stats.misses }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card hit-rate-card">
|
||||||
|
<span class="stat-label">Hit Rate</span>
|
||||||
|
<span class="stat-value" :class="hitRateClass">{{ hitRate }}%</span>
|
||||||
|
<div class="hit-rate-bar">
|
||||||
|
<div class="hit-rate-fill" :style="{ width: hitRate + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="no-stats" v-else>
|
||||||
|
<span class="empty-icon">◈</span>
|
||||||
|
<span class="empty-label">Cache stats unavailable</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const stats = ref<{ size: number; hits: number; misses: number } | null>(null)
|
||||||
|
|
||||||
|
const hitRate = computed(() => {
|
||||||
|
if (!stats.value) return 0
|
||||||
|
const total = stats.value.hits + stats.value.misses
|
||||||
|
return total === 0 ? 0 : Math.round((stats.value.hits / total) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hitRateClass = computed(() => {
|
||||||
|
const r = hitRate.value
|
||||||
|
if (r >= 80) return 'good'
|
||||||
|
if (r >= 50) return 'ok'
|
||||||
|
return 'poor'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/admin/cache/stats', { headers: auth.authHeaders() })
|
||||||
|
stats.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushCache() {
|
||||||
|
if (!confirm('Flush the entire query cache?')) return
|
||||||
|
await fetch('/api/admin/cache/', { method: 'DELETE', headers: auth.authHeaders() })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-actions { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.stat-value.success { color: var(--success); }
|
||||||
|
.stat-value.muted { color: var(--text-muted); }
|
||||||
|
.stat-value.good { color: var(--success); }
|
||||||
|
.stat-value.ok { color: var(--warn); }
|
||||||
|
.stat-value.poor { color: var(--danger); }
|
||||||
|
|
||||||
|
.hit-rate-card { gap: 0.75rem; }
|
||||||
|
|
||||||
|
.hit-rate-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hit-rate-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 4rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-stats .empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-stats .empty-label {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
265
ui/src/views/admin/Layout.vue
Normal file
265
ui/src/views/admin/Layout.vue
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
<template>
|
||||||
|
<div class="admin-layout">
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<img src="/favicon.svg" class="brand-mark" alt="" />
|
||||||
|
<span class="brand-name">Mercury</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-rule"></div>
|
||||||
|
|
||||||
|
<ul class="nav-list">
|
||||||
|
<li v-for="item in navItems" :key="item.to">
|
||||||
|
<RouterLink :to="item.to">
|
||||||
|
<span class="nav-icon">{{ item.icon }}</span>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-avatar">{{ usernameInitial }}</span>
|
||||||
|
<span class="user-name">{{ auth.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
:title="isDark ? 'Light mode' : 'Dark mode'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
{{ isDark ? "☀" : "☾" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn danger-btn"
|
||||||
|
title="Sign out"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
⏻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useAuthStore } from "../../stores/auth";
|
||||||
|
import { useTheme } from "../../stores/theme";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme, toggle: toggleTheme } = useTheme();
|
||||||
|
const isDark = computed(() => theme.value === "hades");
|
||||||
|
|
||||||
|
const usernameInitial = computed(() =>
|
||||||
|
(auth.username?.[0] ?? "?").toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/admin/queries", label: "Queries", icon: "⌗" },
|
||||||
|
{ to: "/admin/tables", label: "Tables", icon: "▦" },
|
||||||
|
{ to: "/admin/users", label: "Users", icon: "◉" },
|
||||||
|
{ to: "/admin/permissions", label: "Permissions", icon: "⬡" },
|
||||||
|
{ to: "/admin/blacklist", label: "Blacklist", icon: "⊘" },
|
||||||
|
{ to: "/admin/api-keys", label: "API Keys", icon: "⚿" },
|
||||||
|
{ to: "/admin/cache", label: "Cache", icon: "◈" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ──────────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
width: 224px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 1.5rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-rule {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0 1.25rem 0.75rem;
|
||||||
|
background: linear-gradient(90deg, var(--border-lo), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nav ──────────────────────────────────────────── */
|
||||||
|
.nav-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-label);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background-color 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-high);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a.router-link-active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--surface-1));
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.65;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li a.router-link-active .nav-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ───────────────────────────────────────── */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-top: 1px solid var(--border-lo);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, var(--surface-2));
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 25%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.12s ease,
|
||||||
|
color 0.12s ease,
|
||||||
|
border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-high);
|
||||||
|
border-color: var(--border-lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn:hover {
|
||||||
|
background: var(--danger-subtle);
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Content ──────────────────────────────────────── */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2.25rem 2.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
ui/src/views/admin/MethodSelect.vue
Normal file
122
ui/src/views/admin/MethodSelect.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<template>
|
||||||
|
<div ref="triggerRef">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nych-select ms-trigger"
|
||||||
|
@click="toggleOpen"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="nych-select-label"
|
||||||
|
:data-p="modelValue.length === 0 ? 'placeholder' : undefined"
|
||||||
|
>
|
||||||
|
{{ modelValue.length === 0 ? placeholder : modelValue.join(', ') }}
|
||||||
|
</span>
|
||||||
|
<span class="nych-select-dropdown">
|
||||||
|
<svg
|
||||||
|
class="nych-select-dropdownIcon ms-chevron"
|
||||||
|
:class="{ 'ms-chevron-open': isOpen }"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<template v-if="isOpen">
|
||||||
|
<div class="ms-backdrop" @click="close" />
|
||||||
|
<div class="nych-select-overlay ms-panel" :style="overlayStyle">
|
||||||
|
<ul class="nych-select-list">
|
||||||
|
<li
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt"
|
||||||
|
class="nych-select-option"
|
||||||
|
:data-p-selected="modelValue.includes(opt) ? 'true' : undefined"
|
||||||
|
@click="toggleOption(opt)"
|
||||||
|
>
|
||||||
|
{{ opt }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
options: string[]
|
||||||
|
placeholder?: string
|
||||||
|
}>(), { placeholder: 'Select…' })
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
|
||||||
|
|
||||||
|
const triggerRef = ref<HTMLElement>()
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const overlayStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
const rect = triggerRef.value!.getBoundingClientRect()
|
||||||
|
overlayStyle.value = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${rect.bottom + 4}px`,
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
}
|
||||||
|
isOpen.value = true
|
||||||
|
window.addEventListener('scroll', close, { capture: true, once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
isOpen.value ? close() : open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOption(opt: string) {
|
||||||
|
const cur = props.modelValue
|
||||||
|
emit('update:modelValue', cur.includes(opt) ? cur.filter(x => x !== opt) : [...cur, opt])
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(close)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Must be global — teleported content renders outside this component's scoped tree */
|
||||||
|
.ms-trigger {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
text-align: left;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-panel {
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-chevron {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.ms-chevron-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
147
ui/src/views/admin/Permissions.vue
Normal file
147
ui/src/views/admin/Permissions.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>Permissions</h2>
|
||||||
|
<span class="subtitle">Bitmask permission flags assigned to users via their permissions mask</span>
|
||||||
|
</div>
|
||||||
|
<NychButton @click="showCreate = true">+ New Permission</NychButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<span class="count">{{ permissions.length }} {{ permissions.length === 1 ? 'permission' : 'permissions' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="permissions.length"><div class="table-scroll"><table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Bit Value</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in permissions" :key="p.id">
|
||||||
|
<td class="id-cell">{{ p.id }}</td>
|
||||||
|
<td><code>{{ p.name }}</code></td>
|
||||||
|
<td><span class="bit-badge">{{ p.bit_value }}</span></td>
|
||||||
|
<td class="desc-cell">{{ p.description ?? '—' }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<NychButton size="small" @click="openEdit(p)">Edit</NychButton>
|
||||||
|
<NychButton size="small" severity="danger" @click="deletePermission(p.id)">Delete</NychButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></template>
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<span class="empty-icon">⬡</span>
|
||||||
|
<span class="empty-label">No permissions defined</span>
|
||||||
|
<span class="empty-hint">Define bitmask flags to control fine-grained API access.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.name}`" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
|
||||||
|
<form @submit.prevent="submitEdit" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<NychInputText v-model="editForm.name" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<NychInputText v-model="editForm.description" fluid />
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Save Changes</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showCreate" header="New Permission" :modal="true" :draggable="false" style="width: min(600px, 95vw)">
|
||||||
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<NychInputText v-model="form.name" placeholder="PERMISSION_NAME" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Bit value</label>
|
||||||
|
<NychInputText v-model="form.bit_value" placeholder="64" fluid />
|
||||||
|
<p class="hint">Must be a power of 2 not already in use (1, 2, 4, 8, 16, 32, 64 …)</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<NychInputText v-model="form.description" placeholder="What this permission grants" fluid />
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Create Permission</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const permissions = ref<any[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editId = ref<number | null>(null)
|
||||||
|
const editForm = ref({ name: '', description: '' })
|
||||||
|
const form = ref({ name: '', bit_value: '', description: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/permissions', { headers: auth.authHeaders() })
|
||||||
|
permissions.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(p: any) {
|
||||||
|
editId.value = p.id
|
||||||
|
editForm.value = { name: p.name, description: p.description ?? '' }
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePermission(id: number) {
|
||||||
|
if (!confirm('Delete this permission?')) return
|
||||||
|
await fetch(`/api/permissions/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.id-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; }
|
||||||
|
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.bit-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-lo);
|
||||||
|
color: var(--text-label);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
137
ui/src/views/admin/Queries.vue
Normal file
137
ui/src/views/admin/Queries.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>Query Registry</h2>
|
||||||
|
<span class="subtitle">Named SQL templates executed via the API using bind parameters</span>
|
||||||
|
</div>
|
||||||
|
<NychButton @click="showCreate = true">+ New Query</NychButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<span class="count">{{ queries.length }} {{ queries.length === 1 ? 'query' : 'queries' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="queries.length"><div class="table-scroll"><table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Identifier</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="q in queries" :key="q.identifier">
|
||||||
|
<td><code>{{ q.identifier }}</code></td>
|
||||||
|
<td class="desc-cell">{{ q.description ?? '—' }}</td>
|
||||||
|
<td class="date-cell">{{ new Date(q.updated_at).toLocaleString() }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<NychButton size="small" @click="openEdit(q)">Edit</NychButton>
|
||||||
|
<NychButton size="small" severity="danger" @click="deleteQuery(q)">Delete</NychButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></template>
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<span class="empty-icon">⌗</span>
|
||||||
|
<span class="empty-label">No queries registered</span>
|
||||||
|
<span class="empty-hint">Add your first named SQL template to get started.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showCreate" header="New Query" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
|
||||||
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Identifier</label>
|
||||||
|
<NychInputText v-model="form.identifier" placeholder="e.g. get-user-orders" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description <span class="optional">(optional)</span></label>
|
||||||
|
<NychInputText v-model="form.description" placeholder="Brief description of what this query does" fluid />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>SQL Template</label>
|
||||||
|
<NychTextarea v-model="form.sql_template" placeholder="SELECT * FROM orders WHERE user_id = :user_id" :rows="12" fluid />
|
||||||
|
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Save Query</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editIdentifier}`" :modal="true" :draggable="false" style="width: min(800px, 95vw)">
|
||||||
|
<form @submit.prevent="submitEdit" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>SQL Template</label>
|
||||||
|
<NychTextarea v-model="editForm.sql_template" :rows="12" fluid />
|
||||||
|
<p class="hint">Use <code>:param_name</code> for named bind parameters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description <span class="optional">(optional)</span></label>
|
||||||
|
<NychInputText v-model="editForm.description" placeholder="Brief description" fluid />
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Update Query</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const queries = ref<any[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editId = ref('') // UUID used for PUT/DELETE
|
||||||
|
const editIdentifier = ref('') // identifier used for display and execute URL
|
||||||
|
const form = ref({ identifier: '', sql_template: '', description: '' })
|
||||||
|
const editForm = ref({ sql_template: '', description: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/queries', { headers: auth.authHeaders() })
|
||||||
|
queries.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: any) {
|
||||||
|
editId.value = row.id
|
||||||
|
editIdentifier.value = row.identifier
|
||||||
|
editForm.value = { sql_template: row.sql_template, description: row.description ?? '' }
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQuery(row: any) {
|
||||||
|
if (!confirm(`Delete query "${row.identifier}"?`)) return
|
||||||
|
await fetch(`/api/queries/${row.id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desc-cell { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
|
.date-cell { color: var(--text-dim); font-family: var(--font-mono); font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
</style>
|
||||||
571
ui/src/views/admin/Tables.vue
Normal file
571
ui/src/views/admin/Tables.vue
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>Tables</h2>
|
||||||
|
<span class="subtitle">Create and manage PostgreSQL tables in the public schema</span>
|
||||||
|
</div>
|
||||||
|
<NychButton @click="showCreate = true">+ New Table</NychButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<span class="count">{{ tables.length }} {{ tables.length === 1 ? 'table' : 'tables' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="tables.length"><div class="table-scroll"><table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Columns</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in tables" :key="t.table_name">
|
||||||
|
<td class="name-cell"><code>{{ t.table_name }}</code></td>
|
||||||
|
<td class="count-cell">{{ t.column_count }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<NychButton size="small" severity="secondary" @click="openInspect(t.table_name)">Inspect</NychButton>
|
||||||
|
<NychButton size="small" severity="danger" @click="openDropPreview(t.table_name)">Drop</NychButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></template>
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<span class="empty-icon">▦</span>
|
||||||
|
<span class="empty-label">No tables yet</span>
|
||||||
|
<span class="empty-hint">Create a table to start storing data via the CRUD API.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Table Dialog -->
|
||||||
|
<NychDialog v-model:visible="showCreate" header="New Table" :modal="true" :draggable="false" style="width: min(700px, 95vw)">
|
||||||
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Table Name</label>
|
||||||
|
<NychInputText v-model="createForm.name" placeholder="my_table" fluid autocomplete="off" />
|
||||||
|
<p class="hint">Lowercase letters, numbers, and underscores only.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns-section">
|
||||||
|
<div class="columns-header">
|
||||||
|
<label>Columns</label>
|
||||||
|
<NychButton type="button" size="small" @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="column-row header-row">
|
||||||
|
<span>Name</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Nullable</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(col, i) in createForm.columns" :key="i" class="column-row">
|
||||||
|
<NychInputText v-model="col.name" placeholder="column_name" fluid />
|
||||||
|
<NychSelect
|
||||||
|
v-model="col.col_type"
|
||||||
|
:options="COLUMN_TYPES"
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="empty-columns" v-if="createForm.columns.length === 0">
|
||||||
|
<span>Add at least one column.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NychButton type="submit" fluid :disabled="!canSubmitCreate">Create Table</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<!-- Inspect Dialog (read-only) -->
|
||||||
|
<NychDialog v-model:visible="showInspect" :header="`Inspect — ${inspectPreview?.table_name ?? ''}`" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
|
||||||
|
<div v-if="inspectLoading" class="preview-loading">Loading table data…</div>
|
||||||
|
<div v-else-if="inspectPreview" class="preview-body">
|
||||||
|
<div class="preview-meta">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Table</span>
|
||||||
|
<code class="meta-value">{{ inspectPreview.table_name }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Rows</span>
|
||||||
|
<span class="meta-value">{{ inspectPreview.row_count.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">CRUD endpoint</span>
|
||||||
|
<code class="meta-value endpoint">/api/{{ inspectPreview.table_name }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="schema-section">
|
||||||
|
<div class="section-label">Schema</div>
|
||||||
|
<table class="schema-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Column</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Nullable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="col in inspectPreview.columns" :key="col.column_name">
|
||||||
|
<td><code>{{ col.column_name }}</code></td>
|
||||||
|
<td><span class="type-badge">{{ col.data_type }}</span></td>
|
||||||
|
<td>{{ col.is_nullable === 'YES' ? 'yes' : 'no' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sample-section" v-if="inspectPreview.sample_rows.length > 0">
|
||||||
|
<div class="section-label">
|
||||||
|
Sample data
|
||||||
|
<span class="sample-count">(first {{ inspectPreview.sample_rows.length }} of {{ inspectPreview.row_count.toLocaleString() }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="sample-scroll">
|
||||||
|
<table class="data-table sample-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="col in inspectPreview.columns" :key="col.column_name">{{ col.column_name }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<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] ?? '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state sample-empty" v-else>
|
||||||
|
<span class="empty-icon">▦</span>
|
||||||
|
<span class="empty-label">Empty table</span>
|
||||||
|
<span class="empty-hint">Use <code>POST /api/{{ inspectPreview.table_name }}</code> to insert rows.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<!-- Drop Preview Dialog -->
|
||||||
|
<NychDialog v-model:visible="showDropPreview" header="Drop Table" :modal="true" :draggable="false" style="width: min(760px, 95vw)">
|
||||||
|
<div v-if="dropPreviewLoading" class="preview-loading">Loading table data…</div>
|
||||||
|
<div v-else-if="dropPreview" class="preview-body">
|
||||||
|
<div class="preview-meta">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Table</span>
|
||||||
|
<code class="meta-value">{{ dropPreview.table_name }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">Rows</span>
|
||||||
|
<span class="meta-value row-count" :class="dropPreview.row_count > 0 ? 'has-data' : 'no-data'">
|
||||||
|
{{ dropPreview.row_count.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning-banner" v-if="dropPreview.row_count > 0">
|
||||||
|
⚠ This table contains {{ dropPreview.row_count.toLocaleString() }} {{ dropPreview.row_count === 1 ? 'row' : 'rows' }} that will be permanently deleted.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="schema-section">
|
||||||
|
<div class="section-label">Schema</div>
|
||||||
|
<table class="schema-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Column</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Nullable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="col in dropPreview.columns" :key="col.column_name">
|
||||||
|
<td><code>{{ col.column_name }}</code></td>
|
||||||
|
<td><span class="type-badge">{{ col.data_type }}</span></td>
|
||||||
|
<td>{{ col.is_nullable === 'YES' ? 'yes' : 'no' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sample-section" v-if="dropPreview.sample_rows.length > 0">
|
||||||
|
<div class="section-label">
|
||||||
|
Sample data
|
||||||
|
<span class="sample-count">(first {{ dropPreview.sample_rows.length }} of {{ dropPreview.row_count.toLocaleString() }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="sample-scroll">
|
||||||
|
<table class="data-table sample-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="col in dropPreview.columns" :key="col.column_name">{{ col.column_name }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<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] ?? '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="confirm-section">
|
||||||
|
<label class="confirm-label">Type <strong>{{ dropPreview.table_name }}</strong> to confirm:</label>
|
||||||
|
<NychInputText v-model="dropConfirmName" :placeholder="dropPreview.table_name" fluid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drop-actions">
|
||||||
|
<NychButton severity="secondary" @click="showDropPreview = false">Cancel</NychButton>
|
||||||
|
<NychButton
|
||||||
|
severity="danger"
|
||||||
|
:disabled="dropConfirmName !== dropPreview.table_name"
|
||||||
|
@click="confirmDrop"
|
||||||
|
>
|
||||||
|
Drop Table
|
||||||
|
</NychButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NychDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const COLUMN_TYPES = [
|
||||||
|
'TEXT', 'INTEGER', 'BIGINT', 'SMALLINT', 'BOOLEAN',
|
||||||
|
'NUMERIC', 'FLOAT4', 'FLOAT8', 'UUID', 'TIMESTAMPTZ', 'DATE', 'JSONB',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
table_name: string
|
||||||
|
column_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
column_name: string
|
||||||
|
data_type: string
|
||||||
|
is_nullable: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TablePreview {
|
||||||
|
table_name: string
|
||||||
|
row_count: number
|
||||||
|
columns: ColumnInfo[]
|
||||||
|
sample_rows: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnDef {
|
||||||
|
name: string
|
||||||
|
col_type: string
|
||||||
|
nullable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = ref<TableInfo[]>([])
|
||||||
|
|
||||||
|
// create
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const createForm = ref<{ name: string; columns: ColumnDef[] }>({ name: '', columns: [] })
|
||||||
|
|
||||||
|
const canSubmitCreate = computed(
|
||||||
|
() => createForm.value.name.trim() !== '' && createForm.value.columns.length > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function addColumn() {
|
||||||
|
createForm.value.columns.push({ name: '', col_type: 'TEXT', nullable: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeColumn(i: number) {
|
||||||
|
createForm.value.columns.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspect
|
||||||
|
const showInspect = ref(false)
|
||||||
|
const inspectLoading = ref(false)
|
||||||
|
const inspectPreview = ref<TablePreview | null>(null)
|
||||||
|
|
||||||
|
// drop
|
||||||
|
const showDropPreview = ref(false)
|
||||||
|
const dropPreviewLoading = ref(false)
|
||||||
|
const dropPreview = ref<TablePreview | null>(null)
|
||||||
|
const dropConfirmName = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/admin/tables', { headers: auth.authHeaders() })
|
||||||
|
tables.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPreview(tableName: string): Promise<TablePreview> {
|
||||||
|
const res = await fetch(`/api/admin/tables/${tableName}`, { headers: auth.authHeaders() })
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInspect(tableName: string) {
|
||||||
|
showInspect.value = true
|
||||||
|
inspectLoading.value = true
|
||||||
|
inspectPreview.value = null
|
||||||
|
inspectPreview.value = await fetchPreview(tableName)
|
||||||
|
inspectLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDropPreview(tableName: string) {
|
||||||
|
showDropPreview.value = true
|
||||||
|
dropPreviewLoading.value = true
|
||||||
|
dropPreview.value = null
|
||||||
|
dropConfirmName.value = ''
|
||||||
|
dropPreview.value = await fetchPreview(tableName)
|
||||||
|
dropPreviewLoading.value = 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.name-cell { font-family: var(--font-mono); }
|
||||||
|
.count-cell { color: var(--text-muted); font-family: var(--font-mono); font-size: 0.82rem; }
|
||||||
|
.actions-cell { display: flex; gap: 0.4rem; }
|
||||||
|
|
||||||
|
/* ── Create form ────────────────────────────────── */
|
||||||
|
.columns-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-col-hint {
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 5rem 2rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nullable-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-columns {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared preview ─────────────────────────────── */
|
||||||
|
.preview-loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-count.has-data { color: var(--danger); font-weight: 700; }
|
||||||
|
.row-count.no-data { color: var(--success); }
|
||||||
|
|
||||||
|
.warning-banner {
|
||||||
|
background: var(--danger-subtle);
|
||||||
|
border: 1px solid var(--danger-border);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-count {
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-table th,
|
||||||
|
.schema-table td {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border-lo);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema-table th {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-lo);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
color: var(--text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-lo);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-table {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-empty {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px dashed var(--border-lo);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop confirm ───────────────────────────────── */
|
||||||
|
.confirm-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
ui/src/views/admin/Users.vue
Normal file
198
ui/src/views/admin/Users.vue
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<span class="subtitle">Manage API access accounts and their permission roles</span>
|
||||||
|
</div>
|
||||||
|
<NychButton @click="showCreate = true">+ New User</NychButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-card-header">
|
||||||
|
<span class="count">{{ users.length }} {{ users.length === 1 ? 'user' : 'users' }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="users.length"><div class="table-scroll"><table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Mask</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td class="id-cell">{{ u.id }}</td>
|
||||||
|
<td class="username-cell">{{ u.username }}</td>
|
||||||
|
<td><span class="role-badge" :class="roleBadgeClass(u.permissions_mask)">{{ roleName(u.permissions_mask) }}</span></td>
|
||||||
|
<td><code>{{ u.permissions_mask }}</code></td>
|
||||||
|
<td class="date-cell">{{ new Date(u.created_at).toLocaleDateString() }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<NychButton size="small" @click="openEdit(u)">Edit</NychButton>
|
||||||
|
<NychButton size="small" severity="danger" @click="deleteUser(u.id)">Delete</NychButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></template>
|
||||||
|
<div class="empty-state" v-else>
|
||||||
|
<span class="empty-icon">◉</span>
|
||||||
|
<span class="empty-label">No users yet</span>
|
||||||
|
<span class="empty-hint">Create the first user to grant API access.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showEdit" :header="`Edit — ${editForm.username}`" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
|
||||||
|
<form @submit.prevent="submitEdit" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<NychInputText v-model="editForm.username" fluid autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>New password <span class="optional">(leave blank to keep current)</span></label>
|
||||||
|
<NychInputText v-model="editForm.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Role</label>
|
||||||
|
<NychSelect v-model="editForm.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" fluid />
|
||||||
|
<p class="hint">{{ ROLES.find(r => r.value === editForm.permissions_mask)?.description ?? '' }}</p>
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Save Changes</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
|
||||||
|
<NychDialog v-model:visible="showCreate" header="New User" :modal="true" :draggable="false" style="width: min(680px, 95vw)">
|
||||||
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Username</label>
|
||||||
|
<NychInputText v-model="form.username" placeholder="username" fluid autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Password</label>
|
||||||
|
<NychInputText v-model="form.password" type="password" placeholder="••••••••" fluid autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Role</label>
|
||||||
|
<NychSelect
|
||||||
|
v-model="selectedRole"
|
||||||
|
:options="ROLES"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Select a role"
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
<p class="hint">{{ roleDescription }}</p>
|
||||||
|
</div>
|
||||||
|
<NychButton type="submit" fluid>Create User</NychButton>
|
||||||
|
</form>
|
||||||
|
</NychDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const users = ref<any[]>([])
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const showEdit = ref(false)
|
||||||
|
const editId = ref<number | null>(null)
|
||||||
|
const editForm = ref({ username: '', password: '', permissions_mask: '1' })
|
||||||
|
const form = ref({ username: '', password: '' })
|
||||||
|
const selectedRole = ref('1')
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ label: 'Viewer', value: '1', description: 'Read-only access to API data' },
|
||||||
|
{ label: 'Editor', value: '7', description: 'Read, write, and delete via API' },
|
||||||
|
{ label: 'Query Admin', value: '15', description: 'Editor + manage SQL query registry' },
|
||||||
|
{ label: 'Ops Admin', value: '23', description: 'Editor + manage query cache' },
|
||||||
|
{ label: 'Admin', value: '31', description: 'All above combined' },
|
||||||
|
{ label: 'Super Admin', value: '63', description: 'Full access including users, permissions, and blacklist' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const roleDescription = computed(() =>
|
||||||
|
ROLES.find(r => r.value === selectedRole.value)?.description ?? ''
|
||||||
|
)
|
||||||
|
|
||||||
|
function roleName(mask: string) {
|
||||||
|
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadgeClass(mask: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'1': 'role-viewer', '7': 'role-editor',
|
||||||
|
'15': 'role-query', '23': 'role-ops',
|
||||||
|
'31': 'role-admin', '63': 'role-super',
|
||||||
|
}
|
||||||
|
return map[mask] ?? 'role-custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/users', { headers: auth.authHeaders() })
|
||||||
|
users.value = await res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(u: any) {
|
||||||
|
editId.value = u.id
|
||||||
|
editForm.value = { username: u.username, password: '', permissions_mask: u.permissions_mask }
|
||||||
|
showEdit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id: number) {
|
||||||
|
if (!confirm('Delete this user?')) return
|
||||||
|
await fetch(`/api/users/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.role-viewer { background: var(--info-subtle); color: var(--info); border: 1px solid var(--info-border); }
|
||||||
|
.role-editor { background: var(--success-subtle); color: var(--success); border: 1px solid var(--success-border); }
|
||||||
|
.role-query { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
||||||
|
.role-ops { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
||||||
|
.role-admin { background: color-mix(in srgb, var(--primary) 12%, var(--surface-1)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); }
|
||||||
|
.role-super { background: var(--primary); color: var(--primary-fg); border: 1px solid var(--primary); }
|
||||||
|
.role-custom { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||||
|
</style>
|
||||||
16
ui/tsconfig.json
Normal file
16
ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"useDefineForClassFields": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
3
ui/vite.config.d.ts
vendored
Normal file
3
ui/vite.config.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
declare const _default: any;
|
||||||
|
export default _default;
|
||||||
|
//# sourceMappingURL=vite.config.d.ts.map
|
||||||
1
ui/vite.config.d.ts.map
Normal file
1
ui/vite.config.d.ts.map
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":";AAGA,wBAYE"}
|
||||||
16
ui/vite.config.js
Normal file
16
ui/vite.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/auth': 'http://localhost:3000',
|
||||||
|
'/admin': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=vite.config.js.map
|
||||||
1
ui/vite.config.js.map
Normal file
1
ui/vite.config.js.map
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":3,"file":"vite.config.js","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,GAAG,MAAM,oBAAoB,CAAA;AAEpC,eAAe,YAAY,CAAC;IAC1B,OAAO,EAAE,CAAC,GAAG,EAAE,CAAC;IAChB,MAAM,EAAE;QACN,KAAK,EAAE;YACL,MAAM,EAAE,uBAAuB;YAC/B,OAAO,EAAE,uBAAuB;YAChC,QAAQ,EAAE,uBAAuB;SAClC;KACF;IACD,KAAK,EAAE;QACL,MAAM,EAAE,MAAM;KACf;CACF,CAAC,CAAA"}
|
||||||
29
ui/vite.config.ts
Normal file
29
ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
'/auth': 'http://localhost:3000',
|
||||||
|
'/admin': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
cleanOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vue: ['vue', 'vue-router', 'pinia'],
|
||||||
|
primevue: ['@primevue/core'],
|
||||||
|
nychthemeron: ['@nychthemeron/library'],
|
||||||
|
},
|
||||||
|
entryFileNames: 'js/[name]-[hash].js',
|
||||||
|
chunkFileNames: 'js/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue