1111
This commit is contained in:
147
.dockerignore
Normal file
147
.dockerignore
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Git & Version Control
|
||||||
|
# ============================================================================
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.gitlab
|
||||||
|
.gitattributes
|
||||||
|
.gitignore
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# IDE & Editor
|
||||||
|
# ============================================================================
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
.editorconfig
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Documentation & Configuration
|
||||||
|
# ============================================================================
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
ARCHITECTURE.md
|
||||||
|
DEVELOPMENT.md
|
||||||
|
LICENSE
|
||||||
|
LICENSE.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
.prettierrc*
|
||||||
|
.eslintrc*
|
||||||
|
.stylelintrc*
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Build & Distribution
|
||||||
|
# ============================================================================
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Testing
|
||||||
|
# ============================================================================
|
||||||
|
__tests__/
|
||||||
|
__test__/
|
||||||
|
test/
|
||||||
|
tests/
|
||||||
|
*.test.js
|
||||||
|
*.spec.js
|
||||||
|
.coverage
|
||||||
|
.nyc_output/
|
||||||
|
jest.config.js
|
||||||
|
karma.conf.js
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Environment & Secrets
|
||||||
|
# ============================================================================
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
.envrc
|
||||||
|
.env-cmdrc.json
|
||||||
|
.secrets/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Temporary & Cache Files
|
||||||
|
# ============================================================================
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
.tmp/
|
||||||
|
.cache/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logs
|
||||||
|
# ============================================================================
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
log/
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Database & Data
|
||||||
|
# ============================================================================
|
||||||
|
postgres_data/
|
||||||
|
database_backups/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.sql
|
||||||
|
*.sql.gz
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Docker & Container Files (these files should not be in the container)
|
||||||
|
# ============================================================================
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CI/CD
|
||||||
|
# ============================================================================
|
||||||
|
.github/
|
||||||
|
.gitlab-ci.yml
|
||||||
|
.circleci/
|
||||||
|
Jenkinsfile
|
||||||
|
.travis.yml
|
||||||
|
.appveyor.yml
|
||||||
|
azure-pipelines.yml
|
||||||
|
.drone.yml
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Misc
|
||||||
|
# ============================================================================
|
||||||
|
tsconfig.json
|
||||||
|
babel.config.js
|
||||||
|
webpack.config.js
|
||||||
|
rollup.config.js
|
||||||
|
gulpfile.js
|
||||||
|
Makefile
|
||||||
|
.nvmrc
|
||||||
|
.node-version
|
||||||
|
.npmrc
|
||||||
|
|
||||||
|
|
||||||
|
node_modules
|
||||||
65
.editorconfig
Normal file
65
.editorconfig
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global settings
|
||||||
|
# =============================================================================
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
tab_width = 4
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Python
|
||||||
|
# =============================================================================
|
||||||
|
[*.py]
|
||||||
|
max_line_length = 88
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# YAML (Docker, CI, compose)
|
||||||
|
# =============================================================================
|
||||||
|
[*.yml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.yaml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# JSON
|
||||||
|
# =============================================================================
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TOML (pyproject.toml, poetry)
|
||||||
|
# =============================================================================
|
||||||
|
[*.toml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Markdown
|
||||||
|
# =============================================================================
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Shell scripts
|
||||||
|
# =============================================================================
|
||||||
|
[*.sh]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Makefile (tabs required)
|
||||||
|
# =============================================================================
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# INI / config files
|
||||||
|
# =============================================================================
|
||||||
|
[*.ini]
|
||||||
|
indent_size = 2
|
||||||
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
APP_PORT=4000
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
FRONTEND_ORIGIN=http://localhost:5173
|
||||||
|
SESSION_COOKIE_NAME=pg_admin_sid
|
||||||
|
SESSION_TTL_HOURS=12
|
||||||
|
SESSION_SECRET=change-me
|
||||||
|
TRUST_PROXY=false
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_SOURCE_PATH=/var/log/postgresql/postgresql.log
|
||||||
|
FEATURE_SQL_CONSOLE=true
|
||||||
|
FEATURE_LOG_VIEWER=true
|
||||||
|
FEATURE_SCHEMA_MUTATIONS=true
|
||||||
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
RATE_LIMIT_MAX=120
|
||||||
|
AUTH_RATE_LIMIT_MAX=10
|
||||||
|
CONTROL_DB_HOST=postgres-control
|
||||||
|
CONTROL_DB_PORT=5432
|
||||||
|
CONTROL_DB_NAME=pg_admin_control
|
||||||
|
CONTROL_DB_USER=pgadmin
|
||||||
|
CONTROL_DB_PASSWORD=pgadmin
|
||||||
|
TARGET_DB_HOST=postgres-target
|
||||||
|
TARGET_DB_PORT=5432
|
||||||
|
TARGET_DB_NAME=appdb
|
||||||
|
TARGET_DB_USER=postgres
|
||||||
|
TARGET_DB_PASSWORD=postgres
|
||||||
83
.gitattributes
vendored
Normal file
83
.gitattributes
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Global text normalization
|
||||||
|
# =============================================================================
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Shell scripts (must stay LF)
|
||||||
|
# =============================================================================
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.bash text eol=lf
|
||||||
|
*.zsh text eol=lf
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Windows scripts
|
||||||
|
# =============================================================================
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Binary images
|
||||||
|
# =============================================================================
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.bmp binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
|
||||||
|
# SVG is text
|
||||||
|
*.svg text
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Media
|
||||||
|
# =============================================================================
|
||||||
|
*.mp3 binary
|
||||||
|
*.wav binary
|
||||||
|
*.ogg binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mov binary
|
||||||
|
*.avi binary
|
||||||
|
*.mkv binary
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Fonts
|
||||||
|
# =============================================================================
|
||||||
|
*.eot binary
|
||||||
|
*.ttf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.otf binary
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Documents
|
||||||
|
# =============================================================================
|
||||||
|
*.pdf binary
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WebAssembly
|
||||||
|
# =============================================================================
|
||||||
|
*.wasm binary
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Jupyter
|
||||||
|
# =============================================================================
|
||||||
|
*.ipynb binary
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Git LFS (ML / large artifacts)
|
||||||
|
# =============================================================================
|
||||||
|
*.pt filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pth filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GitHub linguist hints
|
||||||
|
# =============================================================================
|
||||||
|
docs/** linguist-documentation
|
||||||
|
generated/** linguist-generated
|
||||||
|
vendor/** linguist-vendored
|
||||||
166
.gitignore
vendored
Normal file
166
.gitignore
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# OS
|
||||||
|
# =============================================================================
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# IDE / Editors
|
||||||
|
# =============================================================================
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.sublime-*
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Logs
|
||||||
|
# =============================================================================
|
||||||
|
*.log
|
||||||
|
*.logs
|
||||||
|
*.logs.*
|
||||||
|
*.log.*
|
||||||
|
logs/
|
||||||
|
log/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Environment / Secrets
|
||||||
|
# =============================================================================
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.sample
|
||||||
|
!.env.template
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Security keys
|
||||||
|
# =============================================================================
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Python
|
||||||
|
# =============================================================================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Packaging
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.eggs/
|
||||||
|
*.egg-info/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# Testing / coverage
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Tool caches
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pyre/
|
||||||
|
.pytype/
|
||||||
|
.pyright/
|
||||||
|
|
||||||
|
# Jupyter
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Node / Frontend
|
||||||
|
# =============================================================================
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Java / Kotlin
|
||||||
|
# =============================================================================
|
||||||
|
.gradle/
|
||||||
|
out/
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Go
|
||||||
|
# =============================================================================
|
||||||
|
bin/
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Rust
|
||||||
|
# =============================================================================
|
||||||
|
target/
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# C / C++ / CMake
|
||||||
|
# =============================================================================
|
||||||
|
cmake-build-*/
|
||||||
|
CMakeFiles/
|
||||||
|
CMakeCache.txt
|
||||||
|
compile_commands.json
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Docker
|
||||||
|
# =============================================================================
|
||||||
|
docker-compose.override.yml
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Databases
|
||||||
|
# =============================================================================
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ML / Data artifacts
|
||||||
|
# =============================================================================
|
||||||
|
*.pt
|
||||||
|
*.pth
|
||||||
|
*.onnx
|
||||||
|
*.h5
|
||||||
|
*.ckpt
|
||||||
|
*.safetensors
|
||||||
|
*.npy
|
||||||
|
*.npz
|
||||||
|
*.parquet
|
||||||
|
*.joblib
|
||||||
|
*.pkl
|
||||||
|
*.pickle
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Archives
|
||||||
|
# =============================================================================
|
||||||
|
*.zip
|
||||||
|
*.tar.*
|
||||||
|
*.gz
|
||||||
|
*.7z
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Temporary
|
||||||
|
# =============================================================================
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
.cache/
|
||||||
55
.pre-commit-config.yaml
Normal file
55
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
repos:
|
||||||
|
# =============================================================================
|
||||||
|
# Ruff (lint + import sorting + formatting)
|
||||||
|
# =============================================================================
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.4.4
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Base repository hygiene
|
||||||
|
# =============================================================================
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.6.0
|
||||||
|
hooks:
|
||||||
|
- id: check-yaml
|
||||||
|
args: [--allow-multiple-documents]
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: detect-private-key
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: debug-statements
|
||||||
|
- id: check-executables-have-shebangs
|
||||||
|
- id: requirements-txt-fixer
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Static typing
|
||||||
|
# =============================================================================
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.10.0
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
args: [--ignore-missing-imports]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Security checks
|
||||||
|
# =============================================================================
|
||||||
|
- repo: https://github.com/PyCQA/bandit
|
||||||
|
rev: 1.7.8
|
||||||
|
hooks:
|
||||||
|
- id: bandit
|
||||||
|
args: ["-r", "src"]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Secret detection
|
||||||
|
# =============================================================================
|
||||||
|
- repo: https://github.com/Yelp/detect-secrets
|
||||||
|
rev: v1.5.0
|
||||||
|
hooks:
|
||||||
|
- id: detect-secrets
|
||||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:24-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY backend/package.json /app/backend/package.json
|
||||||
|
RUN npm install --workspaces --include-workspace-root=false
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
COPY backend /app/backend
|
||||||
|
WORKDIR /app/backend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:24-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=base /app/node_modules /app/node_modules
|
||||||
|
COPY --from=build /app/backend/dist /app/backend/dist
|
||||||
|
COPY backend/package.json /app/backend/package.json
|
||||||
|
WORKDIR /app/backend
|
||||||
|
USER node
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
32
backend/package.json
Normal file
32
backend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@pg-admin/backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "tsx --test tests/**/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"argon2": "^0.41.1",
|
||||||
|
"cookie": "^1.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"zod": "^3.25.67"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie": "^1.0.0",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/node": "^24.0.10",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/src/app/create-app.ts
Normal file
36
backend/src/app/create-app.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import cors from "cors";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { requestIdMiddleware } from "../middleware/request-id.js";
|
||||||
|
import { sessionMiddleware } from "../middleware/auth.js";
|
||||||
|
import { errorHandler } from "../middleware/error-handler.js";
|
||||||
|
import { apiLimiter, authLimiter } from "./rate-limit.js";
|
||||||
|
import { healthRouter } from "./health.routes.js";
|
||||||
|
import { apiRouter } from "./routes.js";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
if (env.TRUST_PROXY) {
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(requestIdMiddleware);
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: env.FRONTEND_ORIGIN,
|
||||||
|
credentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
app.use("/health", healthRouter);
|
||||||
|
app.use("/api/v1/auth", authLimiter);
|
||||||
|
app.use("/api/v1", apiLimiter);
|
||||||
|
app.use("/api/v1", apiRouter);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
12
backend/src/app/health.routes.ts
Normal file
12
backend/src/app/health.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../lib/api-response.js";
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
|
||||||
|
healthRouter.get("/live", (_req, res) => {
|
||||||
|
res.json(ok({ status: "live" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
healthRouter.get("/ready", (_req, res) => {
|
||||||
|
res.json(ok({ status: "ready" }));
|
||||||
|
});
|
||||||
16
backend/src/app/rate-limit.ts
Normal file
16
backend/src/app/rate-limit.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
export const apiLimiter = rateLimit({
|
||||||
|
windowMs: env.RATE_LIMIT_WINDOW_MS,
|
||||||
|
max: env.RATE_LIMIT_MAX,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export const authLimiter = rateLimit({
|
||||||
|
windowMs: env.RATE_LIMIT_WINDOW_MS,
|
||||||
|
max: env.AUTH_RATE_LIMIT_MAX,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
});
|
||||||
33
backend/src/app/routes.ts
Normal file
33
backend/src/app/routes.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { authRouter } from "../modules/auth/auth.routes.js";
|
||||||
|
import { navigationRouter } from "../modules/navigation/navigation.routes.js";
|
||||||
|
import { tablesRouter } from "../modules/tables/tables.routes.js";
|
||||||
|
import { recordsRouter } from "../modules/records/records.routes.js";
|
||||||
|
import { schemaRouter } from "../modules/schema/schema.routes.js";
|
||||||
|
import { indexesRouter, globalIndexesRouter } from "../modules/indexes/indexes.routes.js";
|
||||||
|
import { sqlConsoleRouter } from "../modules/sql-console/sql-console.routes.js";
|
||||||
|
import { auditRouter } from "../modules/audit/audit.routes.js";
|
||||||
|
import { usersRouter } from "../modules/users/users.routes.js";
|
||||||
|
import { rolesRouter } from "../modules/roles/roles.routes.js";
|
||||||
|
import { permissionsRouter } from "../modules/permissions/permissions.routes.js";
|
||||||
|
import { connectionsRouter } from "../modules/connections/connections.routes.js";
|
||||||
|
import { logsRouter } from "../modules/logs/logs.routes.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export const apiRouter = Router();
|
||||||
|
|
||||||
|
apiRouter.use("/auth", authRouter);
|
||||||
|
apiRouter.use(requireAuth);
|
||||||
|
apiRouter.use("/navigation", navigationRouter);
|
||||||
|
apiRouter.use("/tables", tablesRouter);
|
||||||
|
apiRouter.use("/tables/:table/records", recordsRouter);
|
||||||
|
apiRouter.use("/tables/:table/columns", schemaRouter);
|
||||||
|
apiRouter.use("/tables/:table/indexes", indexesRouter);
|
||||||
|
apiRouter.use("/indexes", globalIndexesRouter);
|
||||||
|
apiRouter.use("/sql", sqlConsoleRouter);
|
||||||
|
apiRouter.use("/audit", auditRouter);
|
||||||
|
apiRouter.use("/users", usersRouter);
|
||||||
|
apiRouter.use("/roles", rolesRouter);
|
||||||
|
apiRouter.use("/permissions", permissionsRouter);
|
||||||
|
apiRouter.use("/connections", connectionsRouter);
|
||||||
|
apiRouter.use("/logs", logsRouter);
|
||||||
47
backend/src/config/env.ts
Normal file
47
backend/src/config/env.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
APP_PORT: z.coerce.number().default(4000),
|
||||||
|
APP_HOST: z.string().default("0.0.0.0"),
|
||||||
|
FRONTEND_ORIGIN: z.string().url().default("http://localhost:5173"),
|
||||||
|
SESSION_COOKIE_NAME: z.string().default("pg_admin_sid"),
|
||||||
|
SESSION_TTL_HOURS: z.coerce.number().positive().default(12),
|
||||||
|
SESSION_SECRET: z.string().min(8),
|
||||||
|
TRUST_PROXY: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => value === "true")
|
||||||
|
.default("false"),
|
||||||
|
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||||
|
LOG_SOURCE_PATH: z.string().default("/var/log/postgresql/postgresql.log"),
|
||||||
|
FEATURE_SQL_CONSOLE: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => value !== "false")
|
||||||
|
.default("true"),
|
||||||
|
FEATURE_LOG_VIEWER: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => value !== "false")
|
||||||
|
.default("true"),
|
||||||
|
FEATURE_SCHEMA_MUTATIONS: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => value !== "false")
|
||||||
|
.default("true"),
|
||||||
|
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000),
|
||||||
|
RATE_LIMIT_MAX: z.coerce.number().default(120),
|
||||||
|
AUTH_RATE_LIMIT_MAX: z.coerce.number().default(10),
|
||||||
|
CONTROL_DB_HOST: z.string(),
|
||||||
|
CONTROL_DB_PORT: z.coerce.number().default(5432),
|
||||||
|
CONTROL_DB_NAME: z.string(),
|
||||||
|
CONTROL_DB_USER: z.string(),
|
||||||
|
CONTROL_DB_PASSWORD: z.string(),
|
||||||
|
TARGET_DB_HOST: z.string(),
|
||||||
|
TARGET_DB_PORT: z.coerce.number().default(5432),
|
||||||
|
TARGET_DB_NAME: z.string(),
|
||||||
|
TARGET_DB_USER: z.string(),
|
||||||
|
TARGET_DB_PASSWORD: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
|
|
||||||
|
export const isProduction = env.NODE_ENV === "production";
|
||||||
11
backend/src/db/control.ts
Normal file
11
backend/src/db/control.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
export const controlPool = new Pool({
|
||||||
|
host: env.CONTROL_DB_HOST,
|
||||||
|
port: env.CONTROL_DB_PORT,
|
||||||
|
database: env.CONTROL_DB_NAME,
|
||||||
|
user: env.CONTROL_DB_USER,
|
||||||
|
password: env.CONTROL_DB_PASSWORD,
|
||||||
|
max: 10
|
||||||
|
});
|
||||||
18
backend/src/db/target.ts
Normal file
18
backend/src/db/target.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
let targetPool: Pool | null = null;
|
||||||
|
|
||||||
|
export function getTargetPool() {
|
||||||
|
if (!targetPool) {
|
||||||
|
targetPool = new Pool({
|
||||||
|
host: env.TARGET_DB_HOST,
|
||||||
|
port: env.TARGET_DB_PORT,
|
||||||
|
database: env.TARGET_DB_NAME,
|
||||||
|
user: env.TARGET_DB_USER,
|
||||||
|
password: env.TARGET_DB_PASSWORD,
|
||||||
|
max: 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return targetPool;
|
||||||
|
}
|
||||||
12
backend/src/index.ts
Normal file
12
backend/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from "./app/create-app.js";
|
||||||
|
import { env } from "./config/env.js";
|
||||||
|
import { logger } from "./lib/logger.js";
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
app.listen(env.APP_PORT, env.APP_HOST, () => {
|
||||||
|
logger.info("Backend started", {
|
||||||
|
host: env.APP_HOST,
|
||||||
|
port: env.APP_PORT
|
||||||
|
});
|
||||||
|
});
|
||||||
19
backend/src/lib/api-response.ts
Normal file
19
backend/src/lib/api-response.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function ok<T>(data: T, meta: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
meta
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(code: string, message: string, requestId: string, details?: unknown) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
requestId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
16
backend/src/lib/errors.ts
Normal file
16
backend/src/lib/errors.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
code: string;
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
constructor(statusCode: number, code: string, message: string, details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppError(error: unknown): error is AppError {
|
||||||
|
return error instanceof AppError;
|
||||||
|
}
|
||||||
6
backend/src/lib/http.ts
Normal file
6
backend/src/lib/http.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function getSingleParam(value: string | string[] | undefined) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0] ?? "";
|
||||||
|
}
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
22
backend/src/lib/identifiers.ts
Normal file
22
backend/src/lib/identifiers.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { AppError } from "./errors.js";
|
||||||
|
|
||||||
|
const identifierPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
|
export function assertSafeIdentifier(value: string, label = "identifier") {
|
||||||
|
if (!identifierPattern.test(value)) {
|
||||||
|
throw new AppError(400, "INVALID_IDENTIFIER", `Unsafe ${label}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quoteIdentifier(identifier: string) {
|
||||||
|
assertSafeIdentifier(identifier);
|
||||||
|
return `"${identifier}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quoteQualifiedName(name: string) {
|
||||||
|
const parts = name.split(".");
|
||||||
|
if (parts.length > 2) {
|
||||||
|
throw new AppError(400, "INVALID_IDENTIFIER", `Unsupported qualified name: ${name}`);
|
||||||
|
}
|
||||||
|
return parts.map((part) => quoteIdentifier(part)).join(".");
|
||||||
|
}
|
||||||
49
backend/src/lib/logger.ts
Normal file
49
backend/src/lib/logger.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
const order: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLevel = (process.env.LOG_LEVEL as LogLevel | undefined) ?? "info";
|
||||||
|
|
||||||
|
function write(level: LogLevel, message: string, meta?: unknown) {
|
||||||
|
if (order[level] < order[currentLevel]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
meta
|
||||||
|
};
|
||||||
|
|
||||||
|
const line = JSON.stringify(payload);
|
||||||
|
if (level === "error") {
|
||||||
|
console.error(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (level === "warn") {
|
||||||
|
console.warn(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug(message: string, meta?: unknown) {
|
||||||
|
write("debug", message, meta);
|
||||||
|
},
|
||||||
|
info(message: string, meta?: unknown) {
|
||||||
|
write("info", message, meta);
|
||||||
|
},
|
||||||
|
warn(message: string, meta?: unknown) {
|
||||||
|
write("warn", message, meta);
|
||||||
|
},
|
||||||
|
error(message: string, meta?: unknown) {
|
||||||
|
write("error", message, meta);
|
||||||
|
}
|
||||||
|
};
|
||||||
9
backend/src/lib/pagination.ts
Normal file
9
backend/src/lib/pagination.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function getPagination(page: number, limit: number) {
|
||||||
|
const safePage = Math.max(page, 1);
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 100);
|
||||||
|
return {
|
||||||
|
page: safePage,
|
||||||
|
limit: safeLimit,
|
||||||
|
offset: (safePage - 1) * safeLimit
|
||||||
|
};
|
||||||
|
}
|
||||||
64
backend/src/lib/sql-guard.ts
Normal file
64
backend/src/lib/sql-guard.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { AppError } from "./errors.js";
|
||||||
|
|
||||||
|
const blockedPatterns = [
|
||||||
|
/\bdrop\s+database\b/i,
|
||||||
|
/\balter\s+system\b/i,
|
||||||
|
/\bcopy\b[\s\S]*\bprogram\b/i,
|
||||||
|
/\bcreate\s+role\b/i,
|
||||||
|
/\balter\s+role\b/i,
|
||||||
|
/\bdrop\s+role\b/i,
|
||||||
|
/\bcreate\s+extension\b/i
|
||||||
|
];
|
||||||
|
|
||||||
|
const mutatingKeywords = /\b(insert|update|delete|alter|create|drop|truncate|grant|revoke)\b/i;
|
||||||
|
const selectOnlyPattern = /^\s*(with\b[\s\S]+?\bselect\b|select\b)/i;
|
||||||
|
|
||||||
|
export type SqlGuardOptions = {
|
||||||
|
allowMultiStatement: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
allowSchemaChanges: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeSql(sql: string) {
|
||||||
|
return sql.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferStatementType(sql: string) {
|
||||||
|
const normalized = normalizeSql(sql).toLowerCase();
|
||||||
|
return normalized.split(/\s+/)[0] ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guardSql(sql: string, options: SqlGuardOptions) {
|
||||||
|
const normalized = normalizeSql(sql);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new AppError(400, "SQL_EMPTY", "SQL query cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.allowMultiStatement && normalized.includes(";")) {
|
||||||
|
const statements = normalized.split(";").filter((part) => part.trim().length > 0);
|
||||||
|
if (statements.length > 1) {
|
||||||
|
throw new AppError(403, "SQL_MULTI_STATEMENT_BLOCKED", "Multiple statements are restricted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of blockedPatterns) {
|
||||||
|
if (pattern.test(normalized)) {
|
||||||
|
throw new AppError(403, "SQL_BLOCKED", "SQL contains a blocked operation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.readOnly && !selectOnlyPattern.test(normalized)) {
|
||||||
|
throw new AppError(403, "SQL_READ_ONLY", "Read-only access allows SELECT statements only");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.allowSchemaChanges && /\b(alter|create|drop|truncate)\b/i.test(normalized)) {
|
||||||
|
throw new AppError(403, "SQL_SCHEMA_BLOCKED", "Schema-changing statements are not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalized,
|
||||||
|
statementType: inferStatementType(normalized),
|
||||||
|
isMutating: mutatingKeywords.test(normalized)
|
||||||
|
};
|
||||||
|
}
|
||||||
20
backend/src/middleware/auth.ts
Normal file
20
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import { AppError } from "../lib/errors.js";
|
||||||
|
import { resolveSession } from "../modules/auth/auth.service.js";
|
||||||
|
|
||||||
|
export const sessionMiddleware: RequestHandler = async (req, _res, next) => {
|
||||||
|
try {
|
||||||
|
await resolveSession(req);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAuth: RequestHandler = (req, _res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
next(new AppError(401, "UNAUTHORIZED", "Authentication is required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
17
backend/src/middleware/error-handler.ts
Normal file
17
backend/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ErrorRequestHandler } from "express";
|
||||||
|
import { fail } from "../lib/api-response.js";
|
||||||
|
import { isAppError } from "../lib/errors.js";
|
||||||
|
import { logger } from "../lib/logger.js";
|
||||||
|
|
||||||
|
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
||||||
|
if (isAppError(error)) {
|
||||||
|
return res.status(error.statusCode).json(fail(error.code, error.message, req.requestId, error.details));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error("Unhandled error", {
|
||||||
|
requestId: req.requestId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(500).json(fail("INTERNAL_ERROR", "Internal server error", req.requestId));
|
||||||
|
};
|
||||||
67
backend/src/middleware/permission.ts
Normal file
67
backend/src/middleware/permission.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import { AppError } from "../lib/errors.js";
|
||||||
|
import type { PermissionAction, PermissionResource } from "../types/auth.js";
|
||||||
|
|
||||||
|
function extractTableGroup(tableName: string) {
|
||||||
|
if (tableName.includes("__")) {
|
||||||
|
return tableName.split("__")[0];
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
req: Express.Request,
|
||||||
|
resource: PermissionResource,
|
||||||
|
action: PermissionAction,
|
||||||
|
scope?: { type: "group" | "table"; value: string }
|
||||||
|
) {
|
||||||
|
if (req.user?.isRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = req.user?.permissions ?? [];
|
||||||
|
return permissions.some((grant) => {
|
||||||
|
if (grant.resource !== resource || grant.action !== action) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (grant.scopeType === "global") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!scope) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return grant.scopeType === scope.type && grant.scopeValue === scope.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requirePermission(resource: PermissionResource, action: PermissionAction): RequestHandler {
|
||||||
|
return (req, _res, next) => {
|
||||||
|
if (hasPermission(req, resource, action)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next(new AppError(403, "FORBIDDEN", "Permission denied"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireTableAccess(action: PermissionAction): RequestHandler {
|
||||||
|
return (req, _res, next) => {
|
||||||
|
const tableName = String(req.params.table ?? "");
|
||||||
|
if (!tableName) {
|
||||||
|
next(new AppError(400, "MISSING_TABLE", "Table parameter is required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = extractTableGroup(tableName);
|
||||||
|
if (
|
||||||
|
hasPermission(req, "table", action, { type: "table", value: tableName }) ||
|
||||||
|
hasPermission(req, "group", action, { type: "group", value: group }) ||
|
||||||
|
hasPermission(req, "database", action)
|
||||||
|
) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next(new AppError(403, "FORBIDDEN", "Table access denied"));
|
||||||
|
};
|
||||||
|
}
|
||||||
7
backend/src/middleware/request-id.ts
Normal file
7
backend/src/middleware/request-id.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export const requestIdMiddleware: RequestHandler = (req, _res, next) => {
|
||||||
|
req.requestId = crypto.randomUUID();
|
||||||
|
next();
|
||||||
|
};
|
||||||
27
backend/src/middleware/validate.ts
Normal file
27
backend/src/middleware/validate.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RequestHandler } from "express";
|
||||||
|
import type { ZodTypeAny } from "zod";
|
||||||
|
import { AppError } from "../lib/errors.js";
|
||||||
|
|
||||||
|
export function validateBody(schema: ZodTypeAny): RequestHandler {
|
||||||
|
return (req, _res, next) => {
|
||||||
|
const parsed = schema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
next(new AppError(400, "VALIDATION_ERROR", "Invalid request body", parsed.error.flatten()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.body = parsed.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateQuery(schema: ZodTypeAny): RequestHandler {
|
||||||
|
return (req, _res, next) => {
|
||||||
|
const parsed = schema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
next(new AppError(400, "VALIDATION_ERROR", "Invalid query parameters", parsed.error.flatten()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.query = parsed.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
67
backend/src/modules/audit/audit.routes.ts
Normal file
67
backend/src/modules/audit/audit.routes.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getPagination } from "../../lib/pagination.js";
|
||||||
|
import { requirePermission } from "../../middleware/permission.js";
|
||||||
|
import { validateQuery } from "../../middleware/validate.js";
|
||||||
|
import { auditQuerySchema } from "./audit.schemas.js";
|
||||||
|
|
||||||
|
export const auditRouter = Router();
|
||||||
|
|
||||||
|
auditRouter.get("/", requirePermission("audit", "read"), validateQuery(auditQuerySchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const query = req.query as unknown as {
|
||||||
|
action?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
status?: string;
|
||||||
|
userId?: string;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
const pagination = getPagination(query.page, query.limit);
|
||||||
|
const values: unknown[] = [];
|
||||||
|
const where: string[] = [];
|
||||||
|
|
||||||
|
if (query.action) {
|
||||||
|
values.push(query.action);
|
||||||
|
where.push(`action = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (query.resourceType) {
|
||||||
|
values.push(query.resourceType);
|
||||||
|
where.push(`resource_type = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
values.push(query.status);
|
||||||
|
where.push(`status = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (query.userId) {
|
||||||
|
values.push(query.userId);
|
||||||
|
where.push(`actor_user_id = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.length > 0 ? `where ${where.join(" and ")}` : "";
|
||||||
|
const countResult = await controlPool.query(`select count(*)::int as total from audit_events ${whereSql}`, values);
|
||||||
|
values.push(pagination.limit, pagination.offset);
|
||||||
|
const dataResult = await controlPool.query(
|
||||||
|
`
|
||||||
|
select *
|
||||||
|
from audit_events
|
||||||
|
${whereSql}
|
||||||
|
order by created_at desc
|
||||||
|
limit $${values.length - 1}
|
||||||
|
offset $${values.length}
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
ok(dataResult.rows, {
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
total: countResult.rows[0]?.total ?? 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
10
backend/src/modules/audit/audit.schemas.ts
Normal file
10
backend/src/modules/audit/audit.schemas.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const auditQuerySchema = z.object({
|
||||||
|
action: z.string().optional(),
|
||||||
|
resourceType: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
page: z.coerce.number().default(1),
|
||||||
|
limit: z.coerce.number().default(25)
|
||||||
|
});
|
||||||
53
backend/src/modules/audit/audit.service.ts
Normal file
53
backend/src/modules/audit/audit.service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
|
||||||
|
export type AuditInput = {
|
||||||
|
actorUserId: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceName: string | null;
|
||||||
|
groupId?: string | null;
|
||||||
|
targetConnectionId?: string | null;
|
||||||
|
sqlTextMasked?: string | null;
|
||||||
|
payloadBefore?: unknown;
|
||||||
|
payloadAfter?: unknown;
|
||||||
|
ip?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
status: "success" | "failure";
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createAuditEvent(input: AuditInput) {
|
||||||
|
await controlPool.query(
|
||||||
|
`
|
||||||
|
insert into audit_events (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
resource_type,
|
||||||
|
resource_name,
|
||||||
|
group_id,
|
||||||
|
target_connection_id,
|
||||||
|
sql_text_masked,
|
||||||
|
payload_before,
|
||||||
|
payload_after,
|
||||||
|
ip,
|
||||||
|
user_agent,
|
||||||
|
status
|
||||||
|
) values (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $11, $12
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.actorUserId,
|
||||||
|
input.action,
|
||||||
|
input.resourceType,
|
||||||
|
input.resourceName,
|
||||||
|
input.groupId ?? null,
|
||||||
|
input.targetConnectionId ?? null,
|
||||||
|
input.sqlTextMasked ?? null,
|
||||||
|
input.payloadBefore ? JSON.stringify(input.payloadBefore) : null,
|
||||||
|
input.payloadAfter ? JSON.stringify(input.payloadAfter) : null,
|
||||||
|
input.ip ?? null,
|
||||||
|
input.userAgent ?? null,
|
||||||
|
input.status
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
30
backend/src/modules/auth/auth.routes.ts
Normal file
30
backend/src/modules/auth/auth.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { requireAuth } from "../../middleware/auth.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { loginSchema } from "./auth.schemas.js";
|
||||||
|
import { login, logout } from "./auth.service.js";
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
authRouter.post("/login", validateBody(loginSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await login(req, res, req.body.username, req.body.password);
|
||||||
|
res.json(ok({ user }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post("/logout", requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await logout(req, res);
|
||||||
|
res.json(ok({ loggedOut: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get("/session", requireAuth, async (req, res) => {
|
||||||
|
res.json(ok({ user: req.user }));
|
||||||
|
});
|
||||||
6
backend/src/modules/auth/auth.schemas.ts
Normal file
6
backend/src/modules/auth/auth.schemas.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1)
|
||||||
|
});
|
||||||
238
backend/src/modules/auth/auth.service.ts
Normal file
238
backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import argon2 from "argon2";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
import { env, isProduction } from "../../config/env.js";
|
||||||
|
import type { PermissionGrant, SessionUser } from "../../types/auth.js";
|
||||||
|
import { AppError } from "../../lib/errors.js";
|
||||||
|
import { createAuditEvent } from "../audit/audit.service.js";
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password_hash: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_locked: boolean;
|
||||||
|
role_slug: string;
|
||||||
|
is_root: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCookie(token: string) {
|
||||||
|
return serializeCookie(env.SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: isProduction,
|
||||||
|
path: "/",
|
||||||
|
maxAge: env.SESSION_TTL_HOURS * 60 * 60
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string) {
|
||||||
|
return crypto.createHash("sha256").update(token + env.SESSION_SECRET).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPermissions(userId: string): Promise<PermissionGrant[]> {
|
||||||
|
const result = await controlPool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
p.resource,
|
||||||
|
p.action,
|
||||||
|
coalesce(rp.scope_type, 'global') as scope_type,
|
||||||
|
rp.scope_value
|
||||||
|
from user_roles ur
|
||||||
|
join roles r on r.id = ur.role_id
|
||||||
|
join role_permissions rp on rp.role_id = r.id
|
||||||
|
join permissions p on p.id = rp.permission_id
|
||||||
|
where ur.user_id = $1
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
resource: row.resource,
|
||||||
|
action: row.action,
|
||||||
|
scopeType: row.scope_type,
|
||||||
|
scopeValue: row.scope_value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPassword(passwordHash: string, password: string) {
|
||||||
|
if (passwordHash.startsWith("pbkdf2$")) {
|
||||||
|
const [, digest, iterationsRaw, salt, expectedHash] = passwordHash.split("$");
|
||||||
|
const derived = crypto
|
||||||
|
.pbkdf2Sync(password, salt, Number(iterationsRaw), expectedHash.length / 2, digest)
|
||||||
|
.toString("hex");
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(derived), Buffer.from(expectedHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
return argon2.verify(passwordHash, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(req: Request, res: Response, username: string, password: string) {
|
||||||
|
const userResult = await controlPool.query<UserRow>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.password_hash,
|
||||||
|
u.is_active,
|
||||||
|
u.is_locked,
|
||||||
|
coalesce(r.slug, 'user') as role_slug,
|
||||||
|
coalesce(bool_or(r.slug = 'root'), false) as is_root
|
||||||
|
from users u
|
||||||
|
left join user_roles ur on ur.user_id = u.id
|
||||||
|
left join roles r on r.id = ur.role_id
|
||||||
|
where u.username = $1
|
||||||
|
group by u.id
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
if (!user) {
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: null,
|
||||||
|
action: "auth.login",
|
||||||
|
resourceType: "session",
|
||||||
|
resourceName: username,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "failure"
|
||||||
|
});
|
||||||
|
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.is_active || user.is_locked) {
|
||||||
|
throw new AppError(403, "ACCOUNT_DISABLED", "Account is disabled or locked");
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(user.password_hash, password);
|
||||||
|
if (!valid) {
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: "auth.login",
|
||||||
|
resourceType: "session",
|
||||||
|
resourceName: username,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "failure"
|
||||||
|
});
|
||||||
|
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await getPermissions(user.id);
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
const sessionResult = await controlPool.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
insert into sessions (user_id, token_hash, expires_at, ip, user_agent)
|
||||||
|
values ($1, $2, now() + ($3 || ' hour')::interval, $4, $5)
|
||||||
|
returning id
|
||||||
|
`,
|
||||||
|
[user.id, tokenHash, env.SESSION_TTL_HOURS, req.ip, req.headers["user-agent"] ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionUser: SessionUser = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roleSlug: user.role_slug,
|
||||||
|
isRoot: user.is_root,
|
||||||
|
permissions
|
||||||
|
};
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", buildCookie(token));
|
||||||
|
req.user = sessionUser;
|
||||||
|
req.sessionId = sessionResult.rows[0]?.id;
|
||||||
|
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: "auth.login",
|
||||||
|
resourceType: "session",
|
||||||
|
resourceName: user.username,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSession(req: Request) {
|
||||||
|
const cookies = parseCookie(req.headers.cookie ?? "");
|
||||||
|
const token = cookies[env.SESSION_COOKIE_NAME];
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionResult = await controlPool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
s.id as session_id,
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
coalesce(bool_or(r.slug = 'root'), false) as is_root,
|
||||||
|
coalesce(max(r.slug), 'user') as role_slug
|
||||||
|
from sessions s
|
||||||
|
join users u on u.id = s.user_id
|
||||||
|
left join user_roles ur on ur.user_id = u.id
|
||||||
|
left join roles r on r.id = ur.role_id
|
||||||
|
where s.token_hash = $1
|
||||||
|
and s.expires_at > now()
|
||||||
|
and u.is_active = true
|
||||||
|
and u.is_locked = false
|
||||||
|
group by s.id, u.id
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[hashToken(token)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = sessionResult.rows[0];
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await getPermissions(row.id);
|
||||||
|
const sessionUser: SessionUser = {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
roleSlug: row.role_slug,
|
||||||
|
isRoot: row.is_root,
|
||||||
|
permissions
|
||||||
|
};
|
||||||
|
|
||||||
|
req.user = sessionUser;
|
||||||
|
req.sessionId = row.session_id;
|
||||||
|
return sessionUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(req: Request, res: Response) {
|
||||||
|
if (req.sessionId) {
|
||||||
|
await controlPool.query(`delete from sessions where id = $1`, [req.sessionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader(
|
||||||
|
"Set-Cookie",
|
||||||
|
serializeCookie(env.SESSION_COOKIE_NAME, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: isProduction,
|
||||||
|
path: "/",
|
||||||
|
expires: new Date(0)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (req.user) {
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user.id,
|
||||||
|
action: "auth.logout",
|
||||||
|
resourceType: "session",
|
||||||
|
resourceName: req.user.username,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/src/modules/connections/connections.routes.ts
Normal file
17
backend/src/modules/connections/connections.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { requirePermission } from "../../middleware/permission.js";
|
||||||
|
|
||||||
|
export const connectionsRouter = Router();
|
||||||
|
|
||||||
|
connectionsRouter.get("/", requirePermission("database", "read"), async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await controlPool.query(
|
||||||
|
`select id, name, host, port, database_name, is_default, created_at from db_connections order by is_default desc, name`
|
||||||
|
);
|
||||||
|
res.json(ok(result.rows));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
40
backend/src/modules/indexes/indexes.routes.ts
Normal file
40
backend/src/modules/indexes/indexes.routes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getSingleParam } from "../../lib/http.js";
|
||||||
|
import { requirePermission, requireTableAccess } from "../../middleware/permission.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { createIndexSchema } from "./indexes.schemas.js";
|
||||||
|
import { createIndex, dropIndex, listIndexes } from "./indexes.service.js";
|
||||||
|
|
||||||
|
export const indexesRouter = Router({ mergeParams: true });
|
||||||
|
export const globalIndexesRouter = Router();
|
||||||
|
|
||||||
|
indexesRouter.get("/", requireTableAccess("read"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await listIndexes(getSingleParam(req.params.table))));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
indexesRouter.post("/", requireTableAccess("schema_change"), validateBody(createIndexSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await createIndex(getSingleParam(req.params.table), req.body.name, req.body.columns, req.body.unique);
|
||||||
|
res.status(201).json(ok({ created: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
globalIndexesRouter.delete(
|
||||||
|
"/:name",
|
||||||
|
requirePermission("database", "schema_change"),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await dropIndex(getSingleParam(req.params.name));
|
||||||
|
res.json(ok({ deleted: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
7
backend/src/modules/indexes/indexes.schemas.ts
Normal file
7
backend/src/modules/indexes/indexes.schemas.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createIndexSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
columns: z.array(z.string().min(1)).min(1),
|
||||||
|
unique: z.boolean().default(false)
|
||||||
|
});
|
||||||
45
backend/src/modules/indexes/indexes.service.ts
Normal file
45
backend/src/modules/indexes/indexes.service.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { getTargetPool } from "../../db/target.js";
|
||||||
|
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
|
||||||
|
|
||||||
|
export async function listIndexes(table: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
indexname as name,
|
||||||
|
indexdef as definition
|
||||||
|
from pg_indexes
|
||||||
|
where schemaname = 'public'
|
||||||
|
and tablename = $1
|
||||||
|
order by indexname
|
||||||
|
`,
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
name: row.name,
|
||||||
|
definition: row.definition,
|
||||||
|
unique: /\bunique\b/i.test(row.definition),
|
||||||
|
type: row.definition.includes(" using ") ? row.definition.split(" using ")[1].split(" ")[0] : "btree"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIndex(table: string, name: string, columns: string[], unique: boolean) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
assertSafeIdentifier(name, "index");
|
||||||
|
const quotedColumns = columns.map((column) => {
|
||||||
|
assertSafeIdentifier(column, "column");
|
||||||
|
return quoteIdentifier(column);
|
||||||
|
});
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(
|
||||||
|
`create ${unique ? "unique " : ""}index ${quoteIdentifier(name)} on ${quoteIdentifier(table)} (${quotedColumns.join(", ")})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropIndex(name: string) {
|
||||||
|
assertSafeIdentifier(name, "index");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(`drop index ${quoteIdentifier(name)}`);
|
||||||
|
}
|
||||||
15
backend/src/modules/navigation/navigation.routes.ts
Normal file
15
backend/src/modules/navigation/navigation.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { requireAuth } from "../../middleware/auth.js";
|
||||||
|
import { getSidebarTree } from "./navigation.service.js";
|
||||||
|
|
||||||
|
export const navigationRouter = Router();
|
||||||
|
|
||||||
|
navigationRouter.get("/sidebar", requireAuth, async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tree = await getSidebarTree();
|
||||||
|
res.json(ok(tree));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
79
backend/src/modules/navigation/navigation.service.ts
Normal file
79
backend/src/modules/navigation/navigation.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
import { getTargetPool } from "../../db/target.js";
|
||||||
|
|
||||||
|
type SidebarTable = {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
group_slug: string;
|
||||||
|
display_name: string;
|
||||||
|
estimated_rows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSidebarTree() {
|
||||||
|
const groupsResult = await controlPool.query<{
|
||||||
|
group_id: string;
|
||||||
|
group_slug: string;
|
||||||
|
group_name: string;
|
||||||
|
table_name: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
g.id as group_id,
|
||||||
|
g.slug as group_slug,
|
||||||
|
g.name as group_name,
|
||||||
|
rgt.table_name
|
||||||
|
from resource_groups g
|
||||||
|
left join resource_group_tables rgt on rgt.group_id = g.id
|
||||||
|
order by g.name, rgt.table_name
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetPool = getTargetPool();
|
||||||
|
const tablesResult = await targetPool.query<SidebarTable>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
t.table_name as name,
|
||||||
|
t.table_schema as schema,
|
||||||
|
coalesce(g.slug, split_part(t.table_name, '__', 1), 'default') as group_slug,
|
||||||
|
case
|
||||||
|
when position('__' in t.table_name) > 0 then split_part(t.table_name, '__', 2)
|
||||||
|
else t.table_name
|
||||||
|
end as display_name,
|
||||||
|
coalesce(s.n_live_tup, 0)::bigint as estimated_rows
|
||||||
|
from information_schema.tables t
|
||||||
|
left join pg_stat_user_tables s
|
||||||
|
on s.relname = t.table_name
|
||||||
|
left join resource_group_tables rgt
|
||||||
|
on rgt.table_name = t.table_name
|
||||||
|
left join resource_groups g
|
||||||
|
on g.id = rgt.group_id
|
||||||
|
where t.table_schema = 'public'
|
||||||
|
and t.table_type = 'BASE TABLE'
|
||||||
|
order by group_slug, display_name
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapped = new Map<string, { slug: string; name: string; tables: SidebarTable[] }>();
|
||||||
|
for (const row of groupsResult.rows) {
|
||||||
|
if (!mapped.has(row.group_slug)) {
|
||||||
|
mapped.set(row.group_slug, {
|
||||||
|
slug: row.group_slug,
|
||||||
|
name: row.group_name,
|
||||||
|
tables: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tablesResult.rows) {
|
||||||
|
if (!mapped.has(table.group_slug)) {
|
||||||
|
mapped.set(table.group_slug, {
|
||||||
|
slug: table.group_slug,
|
||||||
|
name: table.group_slug === "default" ? "General" : table.group_slug,
|
||||||
|
tables: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mapped.get(table.group_slug)?.tables.push(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(mapped.values());
|
||||||
|
}
|
||||||
15
backend/src/modules/permissions/permissions.routes.ts
Normal file
15
backend/src/modules/permissions/permissions.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { requirePermission } from "../../middleware/permission.js";
|
||||||
|
|
||||||
|
export const permissionsRouter = Router();
|
||||||
|
|
||||||
|
permissionsRouter.get("/", requirePermission("roles", "manage_roles"), async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await controlPool.query(`select * from permissions order by resource, action`);
|
||||||
|
res.json(ok(result.rows));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
112
backend/src/modules/records/records.routes.ts
Normal file
112
backend/src/modules/records/records.routes.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getSingleParam } from "../../lib/http.js";
|
||||||
|
import { requireTableAccess } from "../../middleware/permission.js";
|
||||||
|
import { validateQuery } from "../../middleware/validate.js";
|
||||||
|
import { createAuditEvent } from "../audit/audit.service.js";
|
||||||
|
import { recordsQuerySchema } from "./records.schemas.js";
|
||||||
|
import { createRecord, deleteRecord, getRecordById, listRecords, updateRecord } from "./records.service.js";
|
||||||
|
|
||||||
|
export const recordsRouter = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
recordsRouter.get("/", requireTableAccess("read"), validateQuery(recordsQuerySchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
const query = req.query as unknown as {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
search: string;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: string;
|
||||||
|
filters: string;
|
||||||
|
};
|
||||||
|
const result = await listRecords({
|
||||||
|
table: tableName,
|
||||||
|
...query
|
||||||
|
});
|
||||||
|
res.json(ok(result.data, {
|
||||||
|
page: result.page,
|
||||||
|
limit: result.limit,
|
||||||
|
total: result.total,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
primaryKey: result.primaryKey
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recordsRouter.get("/:id", requireTableAccess("read"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await getRecordById(getSingleParam(req.params.table), getSingleParam(req.params.id))));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recordsRouter.post("/", requireTableAccess("write"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
await createRecord(tableName, req.body as Record<string, unknown>);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "record.create",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: tableName,
|
||||||
|
payloadAfter: req.body,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.status(201).json(ok({ created: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recordsRouter.put("/:id", requireTableAccess("write"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
const recordId = getSingleParam(req.params.id);
|
||||||
|
await updateRecord(tableName, recordId, req.body as Record<string, unknown>);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "record.update",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: tableName,
|
||||||
|
payloadAfter: {
|
||||||
|
id: recordId,
|
||||||
|
changes: req.body
|
||||||
|
},
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.json(ok({ updated: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
recordsRouter.delete("/:id", requireTableAccess("delete"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
const recordId = getSingleParam(req.params.id);
|
||||||
|
await deleteRecord(tableName, recordId);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "record.delete",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: tableName,
|
||||||
|
payloadAfter: {
|
||||||
|
id: recordId
|
||||||
|
},
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.json(ok({ deleted: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
10
backend/src/modules/records/records.schemas.ts
Normal file
10
backend/src/modules/records/records.schemas.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const recordsQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().default(1),
|
||||||
|
limit: z.coerce.number().default(25),
|
||||||
|
search: z.string().optional().default(""),
|
||||||
|
sortColumn: z.string().optional(),
|
||||||
|
sortDirection: z.enum(["asc", "desc", "ASC", "DESC"]).optional().default("ASC"),
|
||||||
|
filters: z.string().optional().default("{}")
|
||||||
|
});
|
||||||
195
backend/src/modules/records/records.service.ts
Normal file
195
backend/src/modules/records/records.service.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { getTargetPool } from "../../db/target.js";
|
||||||
|
import { AppError } from "../../lib/errors.js";
|
||||||
|
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
|
||||||
|
import { getPagination } from "../../lib/pagination.js";
|
||||||
|
|
||||||
|
type Filters = Record<string, string>;
|
||||||
|
|
||||||
|
async function getColumns(table: string) {
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query<{ column_name: string }>(
|
||||||
|
`
|
||||||
|
select column_name
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = $1
|
||||||
|
order by ordinal_position
|
||||||
|
`,
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => row.column_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPrimaryKey(table: string) {
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query<{ column_name: string }>(
|
||||||
|
`
|
||||||
|
select kcu.column_name
|
||||||
|
from information_schema.table_constraints tc
|
||||||
|
join information_schema.key_column_usage kcu
|
||||||
|
on tc.constraint_name = kcu.constraint_name
|
||||||
|
and tc.table_schema = kcu.table_schema
|
||||||
|
where tc.constraint_type = 'PRIMARY KEY'
|
||||||
|
and tc.table_schema = 'public'
|
||||||
|
and tc.table_name = $1
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.column_name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFilters(filters: string): Filters {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(filters);
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return parsed as Filters;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecords(query: {
|
||||||
|
table: string;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
search: string;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: string;
|
||||||
|
filters: string;
|
||||||
|
}) {
|
||||||
|
assertSafeIdentifier(query.table, "table");
|
||||||
|
const columns = await getColumns(query.table);
|
||||||
|
const primaryKey = await getPrimaryKey(query.table);
|
||||||
|
const pagination = getPagination(query.page, query.limit);
|
||||||
|
const parsedFilters = parseFilters(query.filters);
|
||||||
|
|
||||||
|
if (query.sortColumn && !columns.includes(query.sortColumn)) {
|
||||||
|
throw new AppError(400, "INVALID_SORT", "Invalid sort column");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const whereParts: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (query.search.trim()) {
|
||||||
|
const searchValue = `%${query.search.trim()}%`;
|
||||||
|
const conditions = columns.map((column) => {
|
||||||
|
values.push(searchValue);
|
||||||
|
return `cast(${quoteIdentifier(column)} as text) ilike $${values.length}`;
|
||||||
|
});
|
||||||
|
whereParts.push(`(${conditions.join(" or ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [column, value] of Object.entries(parsedFilters)) {
|
||||||
|
if (!columns.includes(column)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
values.push(`%${value}%`);
|
||||||
|
whereParts.push(`cast(${quoteIdentifier(column)} as text) ilike $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = whereParts.length > 0 ? `where ${whereParts.join(" and ")}` : "";
|
||||||
|
const orderSql = query.sortColumn
|
||||||
|
? `order by ${quoteIdentifier(query.sortColumn)} ${query.sortDirection?.toUpperCase() === "DESC" ? "DESC" : "ASC"}`
|
||||||
|
: primaryKey
|
||||||
|
? `order by ${quoteIdentifier(primaryKey)} desc`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const countResult = await pool.query(
|
||||||
|
`select count(*)::int as total from ${quoteIdentifier(query.table)} ${whereSql}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
values.push(pagination.limit, pagination.offset);
|
||||||
|
const dataResult = await pool.query(
|
||||||
|
`
|
||||||
|
select *
|
||||||
|
from ${quoteIdentifier(query.table)}
|
||||||
|
${whereSql}
|
||||||
|
${orderSql}
|
||||||
|
limit $${values.length - 1}
|
||||||
|
offset $${values.length}
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = countResult.rows[0]?.total ?? 0;
|
||||||
|
return {
|
||||||
|
data: dataResult.rows,
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pagination.limit)),
|
||||||
|
primaryKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecordById(table: string, id: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const primaryKey = await getPrimaryKey(table);
|
||||||
|
if (!primaryKey) {
|
||||||
|
throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key");
|
||||||
|
}
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`select * from ${quoteIdentifier(table)} where ${quoteIdentifier(primaryKey)} = $1 limit 1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecord(table: string, payload: Record<string, unknown>) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const entries = Object.entries(payload).filter(([, value]) => value !== undefined);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new AppError(400, "EMPTY_PAYLOAD", "Record payload cannot be empty");
|
||||||
|
}
|
||||||
|
const columns = entries.map(([key]) => {
|
||||||
|
assertSafeIdentifier(key, "column");
|
||||||
|
return quoteIdentifier(key);
|
||||||
|
});
|
||||||
|
const values = entries.map(([, value]) => value);
|
||||||
|
const placeholders = values.map((_, index) => `$${index + 1}`);
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(
|
||||||
|
`insert into ${quoteIdentifier(table)} (${columns.join(", ")}) values (${placeholders.join(", ")})`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecord(table: string, id: string, payload: Record<string, unknown>) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const primaryKey = await getPrimaryKey(table);
|
||||||
|
if (!primaryKey) {
|
||||||
|
throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key");
|
||||||
|
}
|
||||||
|
const entries = Object.entries(payload).filter(([key, value]) => key !== primaryKey && value !== undefined);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new AppError(400, "EMPTY_PAYLOAD", "Record payload cannot be empty");
|
||||||
|
}
|
||||||
|
const values: unknown[] = [];
|
||||||
|
const sets = entries.map(([key, value]) => {
|
||||||
|
assertSafeIdentifier(key, "column");
|
||||||
|
values.push(value);
|
||||||
|
return `${quoteIdentifier(key)} = $${values.length}`;
|
||||||
|
});
|
||||||
|
values.push(id);
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(
|
||||||
|
`update ${quoteIdentifier(table)} set ${sets.join(", ")} where ${quoteIdentifier(primaryKey)} = $${values.length}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecord(table: string, id: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const primaryKey = await getPrimaryKey(table);
|
||||||
|
if (!primaryKey) {
|
||||||
|
throw new AppError(400, "PRIMARY_KEY_REQUIRED", "Table does not have a primary key");
|
||||||
|
}
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(`delete from ${quoteIdentifier(table)} where ${quoteIdentifier(primaryKey)} = $1`, [id]);
|
||||||
|
}
|
||||||
35
backend/src/modules/roles/roles.routes.ts
Normal file
35
backend/src/modules/roles/roles.routes.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getSingleParam } from "../../lib/http.js";
|
||||||
|
import { requirePermission } from "../../middleware/permission.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { upsertRoleSchema } from "./roles.schemas.js";
|
||||||
|
import { createRole, listRoles, updateRole } from "./roles.service.js";
|
||||||
|
|
||||||
|
export const rolesRouter = Router();
|
||||||
|
|
||||||
|
rolesRouter.get("/", requirePermission("roles", "manage_roles"), async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await listRoles()));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rolesRouter.post("/", requirePermission("roles", "manage_roles"), validateBody(upsertRoleSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const roleId = await createRole(req.body);
|
||||||
|
res.status(201).json(ok({ id: roleId }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rolesRouter.put("/:id", requirePermission("roles", "manage_roles"), validateBody(upsertRoleSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await updateRole(getSingleParam(req.params.id), req.body);
|
||||||
|
res.json(ok({ updated: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
16
backend/src/modules/roles/roles.schemas.ts
Normal file
16
backend/src/modules/roles/roles.schemas.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const upsertRoleSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1),
|
||||||
|
description: z.string().optional().default(""),
|
||||||
|
permissions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
permissionId: z.string(),
|
||||||
|
scopeType: z.enum(["global", "group", "table"]).default("global"),
|
||||||
|
scopeValue: z.string().nullable().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.default([])
|
||||||
|
});
|
||||||
83
backend/src/modules/roles/roles.service.ts
Normal file
83
backend/src/modules/roles/roles.service.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
|
||||||
|
export async function listRoles() {
|
||||||
|
const rolesResult = await controlPool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.slug,
|
||||||
|
r.description,
|
||||||
|
coalesce(
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'permissionId', p.id,
|
||||||
|
'resource', p.resource,
|
||||||
|
'action', p.action,
|
||||||
|
'scopeType', rp.scope_type,
|
||||||
|
'scopeValue', rp.scope_value
|
||||||
|
)
|
||||||
|
) filter (where p.id is not null),
|
||||||
|
'[]'::json
|
||||||
|
) as permissions
|
||||||
|
from roles r
|
||||||
|
left join role_permissions rp on rp.role_id = r.id
|
||||||
|
left join permissions p on p.id = rp.permission_id
|
||||||
|
group by r.id
|
||||||
|
order by r.name
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return rolesResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRole(input: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
permissions: Array<{ permissionId: string; scopeType: string; scopeValue?: string | null }>;
|
||||||
|
}) {
|
||||||
|
const roleResult = await controlPool.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
insert into roles (name, slug, description)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
returning id
|
||||||
|
`,
|
||||||
|
[input.name, input.slug, input.description]
|
||||||
|
);
|
||||||
|
const roleId = roleResult.rows[0]?.id;
|
||||||
|
for (const permission of input.permissions) {
|
||||||
|
await controlPool.query(
|
||||||
|
`
|
||||||
|
insert into role_permissions (role_id, permission_id, scope_type, scope_value)
|
||||||
|
values ($1, $2, $3, $4)
|
||||||
|
`,
|
||||||
|
[roleId, permission.permissionId, permission.scopeType, permission.scopeValue ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRole(
|
||||||
|
id: string,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
permissions: Array<{ permissionId: string; scopeType: string; scopeValue?: string | null }>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
await controlPool.query(
|
||||||
|
`update roles set name = $2, slug = $3, description = $4 where id = $1`,
|
||||||
|
[id, input.name, input.slug, input.description]
|
||||||
|
);
|
||||||
|
await controlPool.query(`delete from role_permissions where role_id = $1`, [id]);
|
||||||
|
for (const permission of input.permissions) {
|
||||||
|
await controlPool.query(
|
||||||
|
`
|
||||||
|
insert into role_permissions (role_id, permission_id, scope_type, scope_value)
|
||||||
|
values ($1, $2, $3, $4)
|
||||||
|
`,
|
||||||
|
[id, permission.permissionId, permission.scopeType, permission.scopeValue ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/modules/schema/schema.routes.ts
Normal file
79
backend/src/modules/schema/schema.routes.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getSingleParam } from "../../lib/http.js";
|
||||||
|
import { requireTableAccess } from "../../middleware/permission.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { createAuditEvent } from "../audit/audit.service.js";
|
||||||
|
import { addColumn, dropColumn, ensureSchemaMutationsEnabled, updateColumn } from "./schema.service.js";
|
||||||
|
import { createColumnSchema, updateColumnSchema } from "./schema.schemas.js";
|
||||||
|
|
||||||
|
export const schemaRouter = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
schemaRouter.post("/", requireTableAccess("schema_change"), validateBody(createColumnSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
await ensureSchemaMutationsEnabled();
|
||||||
|
await addColumn(tableName, req.body);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "schema.column.create",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: tableName,
|
||||||
|
payloadAfter: req.body,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.status(201).json(ok({ created: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
schemaRouter.put(
|
||||||
|
"/:column",
|
||||||
|
requireTableAccess("schema_change"),
|
||||||
|
validateBody(updateColumnSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
const columnName = getSingleParam(req.params.column);
|
||||||
|
await ensureSchemaMutationsEnabled();
|
||||||
|
await updateColumn(tableName, columnName, req.body);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "schema.column.update",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: `${tableName}.${columnName}`,
|
||||||
|
payloadAfter: req.body,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.json(ok({ updated: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
schemaRouter.delete("/:column", requireTableAccess("schema_change"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
const columnName = getSingleParam(req.params.column);
|
||||||
|
await ensureSchemaMutationsEnabled();
|
||||||
|
await dropColumn(tableName, columnName);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "schema.column.delete",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: `${tableName}.${columnName}`,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.json(ok({ deleted: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
15
backend/src/modules/schema/schema.schemas.ts
Normal file
15
backend/src/modules/schema/schema.schemas.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createColumnSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
nullable: z.boolean().default(true),
|
||||||
|
defaultValue: z.string().nullable().optional(),
|
||||||
|
primaryKey: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateColumnSchema = z.object({
|
||||||
|
type: z.string().optional(),
|
||||||
|
nullable: z.boolean().optional(),
|
||||||
|
defaultValue: z.string().nullable().optional()
|
||||||
|
});
|
||||||
82
backend/src/modules/schema/schema.service.ts
Normal file
82
backend/src/modules/schema/schema.service.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { getTargetPool } from "../../db/target.js";
|
||||||
|
import { AppError } from "../../lib/errors.js";
|
||||||
|
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
|
||||||
|
|
||||||
|
type CreateColumnInput = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
primaryKey: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateColumnInput = {
|
||||||
|
type?: string;
|
||||||
|
nullable?: boolean;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function addColumn(table: string, input: CreateColumnInput) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
assertSafeIdentifier(input.name, "column");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(table)} add column ${quoteIdentifier(input.name)} ${input.type}`
|
||||||
|
);
|
||||||
|
if (!input.nullable) {
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(input.name)} set not null`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.defaultValue) {
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(input.name)} set default ${input.defaultValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.primaryKey) {
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(table)} add primary key (${quoteIdentifier(input.name)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateColumn(table: string, column: string, input: UpdateColumnInput) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
assertSafeIdentifier(column, "column");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
|
||||||
|
if (input.type) {
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} type ${input.type}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input.nullable === "boolean") {
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} ${
|
||||||
|
input.nullable ? "drop not null" : "set not null"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.defaultValue !== undefined) {
|
||||||
|
await pool.query(
|
||||||
|
input.defaultValue === null || input.defaultValue === ""
|
||||||
|
? `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} drop default`
|
||||||
|
: `alter table ${quoteIdentifier(table)} alter column ${quoteIdentifier(column)} set default ${input.defaultValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropColumn(table: string, column: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
assertSafeIdentifier(column, "column");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(`alter table ${quoteIdentifier(table)} drop column ${quoteIdentifier(column)} cascade`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSchemaMutationsEnabled() {
|
||||||
|
if (process.env.FEATURE_SCHEMA_MUTATIONS === "false") {
|
||||||
|
throw new AppError(403, "SCHEMA_MUTATIONS_DISABLED", "Schema mutation feature is disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/modules/sql-console/sql-console.routes.ts
Normal file
34
backend/src/modules/sql-console/sql-console.routes.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { AppError } from "../../lib/errors.js";
|
||||||
|
import { requireAuth } from "../../middleware/auth.js";
|
||||||
|
import { requirePermission } from "../../middleware/permission.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { executeSqlSchema } from "./sql-console.schemas.js";
|
||||||
|
import { executeSql } from "./sql-console.service.js";
|
||||||
|
|
||||||
|
export const sqlConsoleRouter = Router();
|
||||||
|
|
||||||
|
sqlConsoleRouter.post(
|
||||||
|
"/execute",
|
||||||
|
requireAuth,
|
||||||
|
requirePermission("sql_console", "execute_sql"),
|
||||||
|
validateBody(executeSqlSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new AppError(401, "UNAUTHORIZED", "Authentication is required");
|
||||||
|
}
|
||||||
|
res.json(
|
||||||
|
ok(
|
||||||
|
await executeSql(req.body.sql, req.user, {
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] as string | undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
5
backend/src/modules/sql-console/sql-console.schemas.ts
Normal file
5
backend/src/modules/sql-console/sql-console.schemas.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const executeSqlSchema = z.object({
|
||||||
|
sql: z.string().min(1)
|
||||||
|
});
|
||||||
58
backend/src/modules/sql-console/sql-console.service.ts
Normal file
58
backend/src/modules/sql-console/sql-console.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { getTargetPool } from "../../db/target.js";
|
||||||
|
import { AppError } from "../../lib/errors.js";
|
||||||
|
import { guardSql } from "../../lib/sql-guard.js";
|
||||||
|
import type { SessionUser } from "../../types/auth.js";
|
||||||
|
import { createAuditEvent } from "../audit/audit.service.js";
|
||||||
|
|
||||||
|
function isReadOnly(user: SessionUser) {
|
||||||
|
if (user.isRoot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWrite = user.permissions.some((grant) => grant.action === "write" || grant.action === "schema_change");
|
||||||
|
return !hasWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeSql(sql: string, user: SessionUser, context: { ip?: string; userAgent?: string }) {
|
||||||
|
if (process.env.FEATURE_SQL_CONSOLE === "false") {
|
||||||
|
throw new AppError(403, "SQL_CONSOLE_DISABLED", "SQL console feature is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const guard = guardSql(sql, {
|
||||||
|
allowMultiStatement: user.isRoot,
|
||||||
|
readOnly: isReadOnly(user),
|
||||||
|
allowSchemaChanges: user.isRoot || user.permissions.some((grant) => grant.action === "schema_change")
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result = await pool.query(guard.normalized);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const maskedSql = guard.normalized.slice(0, 4000);
|
||||||
|
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: "sql.execute",
|
||||||
|
resourceType: "sql_console",
|
||||||
|
resourceName: guard.statementType,
|
||||||
|
sqlTextMasked: maskedSql,
|
||||||
|
payloadAfter: {
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
durationMs,
|
||||||
|
statementHash: createHash("sha256").update(guard.normalized).digest("hex")
|
||||||
|
},
|
||||||
|
ip: context.ip ?? null,
|
||||||
|
userAgent: context.userAgent ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: result.rows,
|
||||||
|
fields: result.fields.map((field) => ({ name: field.name, dataTypeId: field.dataTypeID })),
|
||||||
|
rowCount: result.rowCount ?? 0,
|
||||||
|
durationMs,
|
||||||
|
statementType: guard.statementType,
|
||||||
|
notice: guard.isMutating ? "Mutation executed" : "Query executed"
|
||||||
|
};
|
||||||
|
}
|
||||||
17
backend/src/modules/tables/table.schemas.ts
Normal file
17
backend/src/modules/tables/table.schemas.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createTableSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
columns: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
nullable: z.boolean().default(true),
|
||||||
|
primaryKey: z.boolean().default(false),
|
||||||
|
defaultValue: z.string().nullable().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
groupSlug: z.string().min(1).optional()
|
||||||
|
});
|
||||||
77
backend/src/modules/tables/tables.routes.ts
Normal file
77
backend/src/modules/tables/tables.routes.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getSingleParam } from "../../lib/http.js";
|
||||||
|
import { requirePermission, requireTableAccess } from "../../middleware/permission.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { createAuditEvent } from "../audit/audit.service.js";
|
||||||
|
import { createTableSchema } from "./table.schemas.js";
|
||||||
|
import { createTable, dropTable, getTableRelations, getTableStructure, listTables } from "./tables.service.js";
|
||||||
|
|
||||||
|
export const tablesRouter = Router();
|
||||||
|
|
||||||
|
tablesRouter.get("/", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await listTables()));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tablesRouter.post(
|
||||||
|
"/",
|
||||||
|
requirePermission("database", "schema_change"),
|
||||||
|
validateBody(createTableSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await createTable(req.body);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "schema.table.create",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: req.body.name,
|
||||||
|
payloadAfter: req.body,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.status(201).json(ok({ created: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
tablesRouter.delete("/:table", requireTableAccess("schema_change"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tableName = getSingleParam(req.params.table);
|
||||||
|
await dropTable(tableName);
|
||||||
|
await createAuditEvent({
|
||||||
|
actorUserId: req.user?.id ?? null,
|
||||||
|
action: "schema.table.delete",
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: tableName,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"] ?? null,
|
||||||
|
status: "success"
|
||||||
|
});
|
||||||
|
res.json(ok({ deleted: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tablesRouter.get("/:table/structure", requireTableAccess("read"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await getTableStructure(getSingleParam(req.params.table))));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tablesRouter.get("/:table/relations", requireTableAccess("read"), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await getTableRelations(getSingleParam(req.params.table))));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
148
backend/src/modules/tables/tables.service.ts
Normal file
148
backend/src/modules/tables/tables.service.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { getTargetPool } from "../../db/target.js";
|
||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
import { AppError } from "../../lib/errors.js";
|
||||||
|
import { assertSafeIdentifier, quoteIdentifier } from "../../lib/identifiers.js";
|
||||||
|
|
||||||
|
type CreateTableInput = {
|
||||||
|
name: string;
|
||||||
|
columns: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
primaryKey: boolean;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
}>;
|
||||||
|
groupSlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listTables() {
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
t.table_name as name,
|
||||||
|
t.table_schema as schema,
|
||||||
|
coalesce(s.n_live_tup, 0)::bigint as rows,
|
||||||
|
coalesce(g.slug, split_part(t.table_name, '__', 1), 'default') as group_slug
|
||||||
|
from information_schema.tables t
|
||||||
|
left join pg_stat_user_tables s
|
||||||
|
on s.relname = t.table_name
|
||||||
|
left join resource_group_tables rgt
|
||||||
|
on rgt.table_name = t.table_name
|
||||||
|
left join resource_groups g
|
||||||
|
on g.id = rgt.group_id
|
||||||
|
where t.table_schema = 'public'
|
||||||
|
and t.table_type = 'BASE TABLE'
|
||||||
|
order by t.table_name
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTableStructure(table: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
c.column_name as name,
|
||||||
|
c.data_type as type,
|
||||||
|
c.is_nullable = 'YES' as nullable,
|
||||||
|
c.column_default as default_value,
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.table_constraints tc
|
||||||
|
join information_schema.key_column_usage kcu
|
||||||
|
on tc.constraint_name = kcu.constraint_name
|
||||||
|
and tc.table_schema = kcu.table_schema
|
||||||
|
where tc.constraint_type = 'PRIMARY KEY'
|
||||||
|
and tc.table_schema = c.table_schema
|
||||||
|
and tc.table_name = c.table_name
|
||||||
|
and kcu.column_name = c.column_name
|
||||||
|
) as is_primary
|
||||||
|
from information_schema.columns c
|
||||||
|
where c.table_schema = 'public'
|
||||||
|
and c.table_name = $1
|
||||||
|
order by c.ordinal_position
|
||||||
|
`,
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTableRelations(table: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
tc.constraint_name,
|
||||||
|
kcu.column_name,
|
||||||
|
ccu.table_name as foreign_table_name,
|
||||||
|
ccu.column_name as foreign_column_name
|
||||||
|
from information_schema.table_constraints tc
|
||||||
|
join information_schema.key_column_usage kcu
|
||||||
|
on tc.constraint_name = kcu.constraint_name
|
||||||
|
join information_schema.constraint_column_usage ccu
|
||||||
|
on ccu.constraint_name = tc.constraint_name
|
||||||
|
where tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
and tc.table_schema = 'public'
|
||||||
|
and tc.table_name = $1
|
||||||
|
order by tc.constraint_name
|
||||||
|
`,
|
||||||
|
[table]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTable(input: CreateTableInput) {
|
||||||
|
assertSafeIdentifier(input.name, "table");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
|
||||||
|
const primaryKeys = input.columns.filter((column) => column.primaryKey);
|
||||||
|
if (primaryKeys.length > 1) {
|
||||||
|
throw new AppError(400, "INVALID_PRIMARY_KEY", "Composite primary keys are not supported in v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
const definitions = input.columns.map((column) => {
|
||||||
|
assertSafeIdentifier(column.name, "column");
|
||||||
|
const pieces = [`${quoteIdentifier(column.name)} ${column.type}`];
|
||||||
|
if (!column.nullable) {
|
||||||
|
pieces.push("NOT NULL");
|
||||||
|
}
|
||||||
|
if (column.defaultValue) {
|
||||||
|
pieces.push(`DEFAULT ${column.defaultValue}`);
|
||||||
|
}
|
||||||
|
if (column.primaryKey) {
|
||||||
|
pieces.push("PRIMARY KEY");
|
||||||
|
}
|
||||||
|
return pieces.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query(`create table ${quoteIdentifier(input.name)} (${definitions.join(", ")})`);
|
||||||
|
|
||||||
|
if (input.groupSlug) {
|
||||||
|
const groupResult = await controlPool.query<{ id: string }>(
|
||||||
|
`select id from resource_groups where slug = $1 limit 1`,
|
||||||
|
[input.groupSlug]
|
||||||
|
);
|
||||||
|
if (groupResult.rows[0]) {
|
||||||
|
await controlPool.query(
|
||||||
|
`
|
||||||
|
insert into resource_group_tables (group_id, table_name)
|
||||||
|
values ($1, $2)
|
||||||
|
on conflict (group_id, table_name) do nothing
|
||||||
|
`,
|
||||||
|
[groupResult.rows[0].id, input.name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropTable(table: string) {
|
||||||
|
assertSafeIdentifier(table, "table");
|
||||||
|
const pool = getTargetPool();
|
||||||
|
await pool.query(`drop table ${quoteIdentifier(table)} cascade`);
|
||||||
|
}
|
||||||
40
backend/src/modules/users/users.routes.ts
Normal file
40
backend/src/modules/users/users.routes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { ok } from "../../lib/api-response.js";
|
||||||
|
import { getSingleParam } from "../../lib/http.js";
|
||||||
|
import { requirePermission } from "../../middleware/permission.js";
|
||||||
|
import { validateBody } from "../../middleware/validate.js";
|
||||||
|
import { createUserSchema, updateUserSchema } from "./users.schemas.js";
|
||||||
|
import { createUser, listUsers, updateUser } from "./users.service.js";
|
||||||
|
|
||||||
|
export const usersRouter = Router();
|
||||||
|
|
||||||
|
usersRouter.get("/", requirePermission("users", "manage_users"), async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(ok(await listUsers()));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.post("/", requirePermission("users", "manage_users"), validateBody(createUserSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = await createUser(req.body);
|
||||||
|
res.status(201).json(ok({ id: userId }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
usersRouter.put(
|
||||||
|
"/:id",
|
||||||
|
requirePermission("users", "manage_users"),
|
||||||
|
validateBody(updateUserSchema),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await updateUser(getSingleParam(req.params.id), req.body);
|
||||||
|
res.json(ok({ updated: true }));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
13
backend/src/modules/users/users.schemas.ts
Normal file
13
backend/src/modules/users/users.schemas.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createUserSchema = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(8),
|
||||||
|
roleIds: z.array(z.string()).default([])
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
isLocked: z.boolean().optional(),
|
||||||
|
roleIds: z.array(z.string()).optional()
|
||||||
|
});
|
||||||
63
backend/src/modules/users/users.service.ts
Normal file
63
backend/src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import argon2 from "argon2";
|
||||||
|
import { controlPool } from "../../db/control.js";
|
||||||
|
|
||||||
|
export async function listUsers() {
|
||||||
|
const result = await controlPool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.is_active,
|
||||||
|
u.is_locked,
|
||||||
|
u.created_at,
|
||||||
|
coalesce(json_agg(json_build_object('id', r.id, 'slug', r.slug, 'name', r.name))
|
||||||
|
filter (where r.id is not null), '[]'::json) as roles
|
||||||
|
from users u
|
||||||
|
left join user_roles ur on ur.user_id = u.id
|
||||||
|
left join roles r on r.id = ur.role_id
|
||||||
|
group by u.id
|
||||||
|
order by u.created_at desc
|
||||||
|
`
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(input: { username: string; password: string; roleIds: string[] }) {
|
||||||
|
const passwordHash = await argon2.hash(input.password);
|
||||||
|
const userResult = await controlPool.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
insert into users (username, password_hash, is_active, is_locked)
|
||||||
|
values ($1, $2, true, false)
|
||||||
|
returning id
|
||||||
|
`,
|
||||||
|
[input.username, passwordHash]
|
||||||
|
);
|
||||||
|
const userId = userResult.rows[0]?.id;
|
||||||
|
for (const roleId of input.roleIds) {
|
||||||
|
await controlPool.query(
|
||||||
|
`insert into user_roles (user_id, role_id) values ($1, $2) on conflict do nothing`,
|
||||||
|
[userId, roleId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, input: { isActive?: boolean; isLocked?: boolean; roleIds?: string[] }) {
|
||||||
|
await controlPool.query(
|
||||||
|
`
|
||||||
|
update users
|
||||||
|
set
|
||||||
|
is_active = coalesce($2, is_active),
|
||||||
|
is_locked = coalesce($3, is_locked)
|
||||||
|
where id = $1
|
||||||
|
`,
|
||||||
|
[id, input.isActive ?? null, input.isLocked ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (input.roleIds) {
|
||||||
|
await controlPool.query(`delete from user_roles where user_id = $1`, [id]);
|
||||||
|
for (const roleId of input.roleIds) {
|
||||||
|
await controlPool.query(`insert into user_roles (user_id, role_id) values ($1, $2)`, [id, roleId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
backend/src/types/auth.ts
Normal file
34
backend/src/types/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type PermissionAction =
|
||||||
|
| "read"
|
||||||
|
| "write"
|
||||||
|
| "delete"
|
||||||
|
| "schema_change"
|
||||||
|
| "execute_sql"
|
||||||
|
| "view_logs"
|
||||||
|
| "manage_users"
|
||||||
|
| "manage_roles";
|
||||||
|
|
||||||
|
export type PermissionResource =
|
||||||
|
| "database"
|
||||||
|
| "group"
|
||||||
|
| "table"
|
||||||
|
| "sql_console"
|
||||||
|
| "logs"
|
||||||
|
| "users"
|
||||||
|
| "roles"
|
||||||
|
| "audit";
|
||||||
|
|
||||||
|
export type PermissionGrant = {
|
||||||
|
resource: PermissionResource;
|
||||||
|
action: PermissionAction;
|
||||||
|
scopeType: "global" | "group" | "table";
|
||||||
|
scopeValue: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
roleSlug: string;
|
||||||
|
isRoot: boolean;
|
||||||
|
permissions: PermissionGrant[];
|
||||||
|
};
|
||||||
13
backend/src/types/express.d.ts
vendored
Normal file
13
backend/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { SessionUser } from "./auth.js";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
requestId: string;
|
||||||
|
user?: SessionUser;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
15
backend/tests/identifiers.test.ts
Normal file
15
backend/tests/identifiers.test.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { assertSafeIdentifier, quoteQualifiedName } from "../src/lib/identifiers.js";
|
||||||
|
|
||||||
|
test("safe identifier accepts public_table", () => {
|
||||||
|
assert.doesNotThrow(() => assertSafeIdentifier("finance_table"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safe identifier rejects SQL injection attempts", () => {
|
||||||
|
assert.throws(() => assertSafeIdentifier("users; drop table users"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quoteQualifiedName supports schema-qualified names", () => {
|
||||||
|
assert.equal(quoteQualifiedName("public.users"), "\"public\".\"users\"");
|
||||||
|
});
|
||||||
27
backend/tests/sql-guard.test.ts
Normal file
27
backend/tests/sql-guard.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { guardSql } from "../src/lib/sql-guard.js";
|
||||||
|
|
||||||
|
test("guardSql blocks DROP DATABASE", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
guardSql("DROP DATABASE appdb", {
|
||||||
|
allowMultiStatement: false,
|
||||||
|
readOnly: false,
|
||||||
|
allowSchemaChanges: true
|
||||||
|
}),
|
||||||
|
/blocked/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("guardSql blocks writes for read-only users", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
guardSql("update users set name = 'x'", {
|
||||||
|
allowMultiStatement: false,
|
||||||
|
readOnly: true,
|
||||||
|
allowSchemaChanges: false
|
||||||
|
}),
|
||||||
|
/Read-only/i
|
||||||
|
);
|
||||||
|
});
|
||||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
postgres-control:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pg_admin_control
|
||||||
|
POSTGRES_USER: pgadmin
|
||||||
|
POSTGRES_PASSWORD: pgadmin
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- ./infra/init/001-control.sql:/docker-entrypoint-initdb.d/001-control.sql:ro
|
||||||
|
- ./infra/init/002-seed-root.sql:/docker-entrypoint-initdb.d/002-seed-root.sql:ro
|
||||||
|
|
||||||
|
postgres-target:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: appdb
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ./infra/init/010-target.sql:/docker-entrypoint-initdb.d/010-target.sql:ro
|
||||||
|
- ./docker/postgres/postgresql.log:/var/log/postgresql/postgresql.log
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- .env.example
|
||||||
|
depends_on:
|
||||||
|
- postgres-control
|
||||||
|
- postgres-target
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
24
docker/nginx/default.conf
Normal file
24
docker/nginx/default.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:4000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health/ {
|
||||||
|
proxy_pass http://backend:4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
docs/architecture.md
Normal file
16
docs/architecture.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# PostgreSQL Admin Control Plane
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This repository contains a production-oriented PostgreSQL admin panel with:
|
||||||
|
|
||||||
|
- `backend`: Express + TypeScript API, RBAC, audit, session management, SQL guard
|
||||||
|
- `frontend`: React + TypeScript admin console
|
||||||
|
- `infra`: SQL bootstrap for control and target databases
|
||||||
|
- `docker`: production-style images and reverse proxy
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
- Metadata, sessions, RBAC, and audit live in a dedicated control database.
|
||||||
|
- Managed PostgreSQL access is mediated by the backend only.
|
||||||
|
- Dynamic schema operations use `pg` with validated identifiers instead of an ORM.
|
||||||
|
- Permissions are enforced on the backend for every sensitive route.
|
||||||
|
- The UI preserves the visual direction of the original `index.html`, but is split into modular React components.
|
||||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:24-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY frontend/package.json /app/frontend/package.json
|
||||||
|
RUN npm install --workspaces --include-workspace-root=false
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
COPY frontend /app/frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.29-alpine AS runtime
|
||||||
|
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
18
frontend/index.html
Normal file
18
frontend/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PostgreSQL SensoLab Panel</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@pg-admin/frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "node -e \"console.log('frontend tests are scaffolded for future setup')\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/src/app/App.tsx
Normal file
6
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
import { router } from "./router";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
8
frontend/src/app/providers.tsx
Normal file
8
frontend/src/app/providers.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export function AppProviders({ children }: PropsWithChildren) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
33
frontend/src/app/router.tsx
Normal file
33
frontend/src/app/router.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
|
import { DashboardPage } from "../pages/DashboardPage";
|
||||||
|
import { LoginPage } from "../pages/LoginPage";
|
||||||
|
import { UsersPage } from "../pages/UsersPage";
|
||||||
|
import { RolesPage } from "../pages/RolesPage";
|
||||||
|
import { AuditPage } from "../pages/AuditPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
element: <LoginPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <DashboardPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/users",
|
||||||
|
element: <UsersPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/roles",
|
||||||
|
element: <RolesPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/audit",
|
||||||
|
element: <AuditPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
]);
|
||||||
297
frontend/src/app/styles.css
Normal file
297
frontend/src/app/styles.css
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--panel: rgba(255, 255, 255, 0.96);
|
||||||
|
--panel-solid: #ffffff;
|
||||||
|
--sidebar: #0f172a;
|
||||||
|
--sidebar-muted: #94a3b8;
|
||||||
|
--line: #dbe3ef;
|
||||||
|
--text: #1e293b;
|
||||||
|
--muted: #64748b;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-soft: #dbeafe;
|
||||||
|
--success: #16a34a;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--shadow: 0 30px 70px rgba(15, 23, 42, 0.12);
|
||||||
|
--radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(37, 99, 235, 0.18), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background: rgba(37, 99, 235, 0.18);
|
||||||
|
color: #bfdbfe;
|
||||||
|
border: 1px solid rgba(191, 219, 254, 0.24);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
color: var(--sidebar-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item,
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover,
|
||||||
|
.nav-link:hover,
|
||||||
|
.sidebar-item.active,
|
||||||
|
.nav-link.active {
|
||||||
|
background: rgba(30, 41, 59, 0.9);
|
||||||
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.panel,
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(219, 227, 239, 0.9);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 18px 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 340px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-height: 180px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.secondary {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #edf2f7;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(100%, 420px);
|
||||||
|
padding: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/src/features/auth/use-session.ts
Normal file
22
frontend/src/features/auth/use-session.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../../shared/api/client";
|
||||||
|
import type { SessionUser } from "../../shared/types";
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["session"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<{ user: SessionUser }>("/auth/session");
|
||||||
|
return response.data.user;
|
||||||
|
},
|
||||||
|
retry: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return async () => {
|
||||||
|
await api.post("/auth/logout");
|
||||||
|
queryClient.clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { App } from "./app/App";
|
||||||
|
import { AppProviders } from "./app/providers";
|
||||||
|
import "./app/styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<AppProviders>
|
||||||
|
<App />
|
||||||
|
</AppProviders>
|
||||||
|
);
|
||||||
50
frontend/src/pages/AuditPage.tsx
Normal file
50
frontend/src/pages/AuditPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../shared/api/client";
|
||||||
|
import { useLogout, useSession } from "../features/auth/use-session";
|
||||||
|
import { Sidebar } from "../widgets/Sidebar";
|
||||||
|
import { Topbar } from "../widgets/Topbar";
|
||||||
|
import { AuditTable } from "../widgets/AuditTable";
|
||||||
|
|
||||||
|
export function AuditPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const session = useSession();
|
||||||
|
const logout = useLogout();
|
||||||
|
const auditQuery = useQuery({
|
||||||
|
queryKey: ["audit"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<Array<Record<string, unknown>>>("/audit?page=1&limit=50");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: session.isSuccess
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.isError) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.isLoading || !session.data) {
|
||||||
|
return <main className="login-page"><section className="panel login-card">Loading audit log...</section></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
|
||||||
|
<main className="main-area">
|
||||||
|
<Topbar
|
||||||
|
user={session.data}
|
||||||
|
onOpenSql={() => navigate("/")}
|
||||||
|
onLogout={async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section className="panel">
|
||||||
|
<h1 className="section-title">Audit Trail</h1>
|
||||||
|
<p className="muted">Authentication events, SQL executions, and administrative changes are collected here.</p>
|
||||||
|
<AuditTable rows={auditQuery.data ?? []} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
frontend/src/pages/DashboardPage.tsx
Normal file
184
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../shared/api/client";
|
||||||
|
import type { SidebarGroup, TableColumn, TableIndex } from "../shared/types";
|
||||||
|
import { useLogout, useSession } from "../features/auth/use-session";
|
||||||
|
import { Sidebar } from "../widgets/Sidebar";
|
||||||
|
import { Topbar } from "../widgets/Topbar";
|
||||||
|
import { DataGrid } from "../widgets/DataGrid";
|
||||||
|
import { SchemaEditor } from "../widgets/SchemaEditor";
|
||||||
|
import { SqlConsole } from "../widgets/SqlConsole";
|
||||||
|
import { LogViewer } from "../widgets/LogViewer";
|
||||||
|
|
||||||
|
type RecordResponse = {
|
||||||
|
data: Array<Record<string, unknown>>;
|
||||||
|
meta: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const session = useSession();
|
||||||
|
const logout = useLogout();
|
||||||
|
const [currentTable, setCurrentTable] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<"data" | "structure" | "sql" | "logs">("data");
|
||||||
|
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: ["sidebar"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<SidebarGroup[]>("/navigation/sidebar");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: session.isSuccess
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnsQuery = useQuery({
|
||||||
|
queryKey: ["structure", currentTable],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<TableColumn[]>(`/tables/${currentTable}/structure`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(currentTable)
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexesQuery = useQuery({
|
||||||
|
queryKey: ["indexes", currentTable],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<TableIndex[]>(`/tables/${currentTable}/indexes`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(currentTable)
|
||||||
|
});
|
||||||
|
|
||||||
|
const relationsQuery = useQuery({
|
||||||
|
queryKey: ["relations", currentTable],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<Array<Record<string, unknown>>>(`/tables/${currentTable}/relations`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: Boolean(currentTable)
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordsQuery = useQuery({
|
||||||
|
queryKey: ["records", currentTable],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<Array<Record<string, unknown>>>(
|
||||||
|
`/tables/${currentTable}/records?page=1&limit=25&search=&filters={}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: response.data,
|
||||||
|
meta: response.meta as RecordResponse["meta"]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: Boolean(currentTable)
|
||||||
|
});
|
||||||
|
|
||||||
|
const logsQuery = useQuery({
|
||||||
|
queryKey: ["logs"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<string[]>("/logs?q=&severity=&page=1&limit=50");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: session.isSuccess
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableCount = useMemo(
|
||||||
|
() => groupsQuery.data?.reduce((sum, group) => sum + group.tables.length, 0) ?? 0,
|
||||||
|
[groupsQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (session.isError) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.isLoading || !session.data) {
|
||||||
|
return <main className="login-page"><section className="panel login-card">Loading session...</section></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Sidebar groups={groupsQuery.data ?? []} currentTable={currentTable} onSelectTable={setCurrentTable} />
|
||||||
|
<main className="main-area">
|
||||||
|
<Topbar
|
||||||
|
user={session.data}
|
||||||
|
onOpenSql={() => setActiveTab("sql")}
|
||||||
|
onLogout={async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<div>
|
||||||
|
<h1 className="section-title">{currentTable ?? "Choose a table from the sidebar"}</h1>
|
||||||
|
<div className="muted">
|
||||||
|
{tableCount} tables across managed groups. Current role: <span className="code">{session.data.roleSlug}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="status-pill">Control DB + target DB mediated through backend API</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
{[
|
||||||
|
["data", "Data"],
|
||||||
|
["structure", "Structure"],
|
||||||
|
["sql", "SQL"],
|
||||||
|
["logs", "Logs"]
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
className={`tab ${activeTab === value ? "active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(value as typeof activeTab)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content-grid">
|
||||||
|
<div className="stack">
|
||||||
|
{activeTab === "data" ? <DataGrid rows={recordsQuery.data?.data ?? []} /> : null}
|
||||||
|
{activeTab === "structure" ? (
|
||||||
|
<SchemaEditor
|
||||||
|
columns={columnsQuery.data ?? []}
|
||||||
|
indexes={indexesQuery.data ?? []}
|
||||||
|
relations={relationsQuery.data ?? []}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SqlConsole visible={activeTab === "sql"} />
|
||||||
|
{activeTab === "logs" ? <LogViewer logs={logsQuery.data ?? []} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="stack">
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">Current Table Summary</h3>
|
||||||
|
<div className="stack muted">
|
||||||
|
<div>Columns: {columnsQuery.data?.length ?? 0}</div>
|
||||||
|
<div>Indexes: {indexesQuery.data?.length ?? 0}</div>
|
||||||
|
<div>Relations: {relationsQuery.data?.length ?? 0}</div>
|
||||||
|
<div>Loaded rows: {recordsQuery.data?.data.length ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">Operational Notes</h3>
|
||||||
|
<div className="stack muted">
|
||||||
|
<div>Backend enforces RBAC and table/group scoping.</div>
|
||||||
|
<div>SQL console is mediated by a guard layer and fully audited.</div>
|
||||||
|
<div>Schema and record mutations are intended to flow only through API policies.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/src/pages/LoginPage.tsx
Normal file
54
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Database } from "lucide-react";
|
||||||
|
import { api } from "../shared/api/client";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("root");
|
||||||
|
const [password, setPassword] = useState("root12345");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="panel login-card stack">
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<div className="brand" style={{ justifyContent: "center", color: "var(--text)" }}>
|
||||||
|
<Database size={36} />
|
||||||
|
<div>PostgreSQL SensoLab</div>
|
||||||
|
</div>
|
||||||
|
<p className="muted">Secure PostgreSQL administration with RBAC, audit, and SQL guardrails.</p>
|
||||||
|
</div>
|
||||||
|
<label className="stack">
|
||||||
|
<span>Username</span>
|
||||||
|
<input className="input" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="stack">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
await api.post("/auth/login", { username, password });
|
||||||
|
navigate("/");
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "Login failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/pages/RolesPage.tsx
Normal file
69
frontend/src/pages/RolesPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../shared/api/client";
|
||||||
|
import { useLogout, useSession } from "../features/auth/use-session";
|
||||||
|
import { Sidebar } from "../widgets/Sidebar";
|
||||||
|
import { Topbar } from "../widgets/Topbar";
|
||||||
|
|
||||||
|
export function RolesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const session = useSession();
|
||||||
|
const logout = useLogout();
|
||||||
|
const rolesQuery = useQuery({
|
||||||
|
queryKey: ["roles"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<Array<Record<string, unknown>>>("/roles");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: session.isSuccess
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.isError) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.isLoading || !session.data) {
|
||||||
|
return <main className="login-page"><section className="panel login-card">Loading roles...</section></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
|
||||||
|
<main className="main-area">
|
||||||
|
<Topbar
|
||||||
|
user={session.data}
|
||||||
|
onOpenSql={() => navigate("/")}
|
||||||
|
onLogout={async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section className="panel">
|
||||||
|
<h1 className="section-title">Roles and Permissions</h1>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(rolesQuery.data ?? []).map((row) => (
|
||||||
|
<tr key={String(row.id)}>
|
||||||
|
<td>{String(row.name ?? "")}</td>
|
||||||
|
<td>{String(row.slug ?? "")}</td>
|
||||||
|
<td>{String(row.description ?? "")}</td>
|
||||||
|
<td className="code">{JSON.stringify(row.permissions ?? [])}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
frontend/src/pages/UsersPage.tsx
Normal file
69
frontend/src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api } from "../shared/api/client";
|
||||||
|
import { useLogout, useSession } from "../features/auth/use-session";
|
||||||
|
import { Sidebar } from "../widgets/Sidebar";
|
||||||
|
import { Topbar } from "../widgets/Topbar";
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const session = useSession();
|
||||||
|
const logout = useLogout();
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get<Array<Record<string, unknown>>>("/users");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: session.isSuccess
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.isError) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.isLoading || !session.data) {
|
||||||
|
return <main className="login-page"><section className="panel login-card">Loading users...</section></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
|
||||||
|
<main className="main-area">
|
||||||
|
<Topbar
|
||||||
|
user={session.data}
|
||||||
|
onOpenSql={() => navigate("/")}
|
||||||
|
onLogout={async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<section className="panel">
|
||||||
|
<h1 className="section-title">User Management</h1>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Locked</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(usersQuery.data ?? []).map((row) => (
|
||||||
|
<tr key={String(row.id)}>
|
||||||
|
<td>{String(row.username ?? "")}</td>
|
||||||
|
<td>{String(row.is_active ?? "")}</td>
|
||||||
|
<td>{String(row.is_locked ?? "")}</td>
|
||||||
|
<td className="code">{JSON.stringify(row.roles ?? [])}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/shared/api/client.ts
Normal file
55
frontend/src/shared/api/client.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export type ApiSuccess<T> = {
|
||||||
|
success: true;
|
||||||
|
data: T;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiError = {
|
||||||
|
success: false;
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiResponse<T> = ApiSuccess<T> | ApiError;
|
||||||
|
|
||||||
|
const API_BASE = "/api/v1";
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<ApiSuccess<T>> {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers ?? {})
|
||||||
|
},
|
||||||
|
...init
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as ApiResponse<T>;
|
||||||
|
if (!response.ok || !payload.success) {
|
||||||
|
throw new Error(payload.success ? "Request failed" : payload.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
}),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: "PUT",
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
}),
|
||||||
|
delete: <T>(path: string) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
};
|
||||||
41
frontend/src/shared/types.ts
Normal file
41
frontend/src/shared/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type PermissionGrant = {
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
scopeType: string;
|
||||||
|
scopeValue: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
roleSlug: string;
|
||||||
|
isRoot: boolean;
|
||||||
|
permissions: PermissionGrant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SidebarGroup = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
tables: Array<{
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
group_slug: string;
|
||||||
|
display_name: string;
|
||||||
|
estimated_rows: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableColumn = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
nullable: boolean;
|
||||||
|
default_value: string | null;
|
||||||
|
is_primary: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TableIndex = {
|
||||||
|
name: string;
|
||||||
|
definition: string;
|
||||||
|
unique: boolean;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
32
frontend/src/widgets/AuditTable.tsx
Normal file
32
frontend/src/widgets/AuditTable.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
type AuditTableProps = {
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuditTable({ rows }: AuditTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Resource</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={String(row.id)}>
|
||||||
|
<td>{String(row.created_at ?? "")}</td>
|
||||||
|
<td>{String(row.action ?? "")}</td>
|
||||||
|
<td>{String(row.resource_type ?? "")}</td>
|
||||||
|
<td>{String(row.status ?? "")}</td>
|
||||||
|
<td>{String(row.actor_user_id ?? "")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/widgets/DataGrid.tsx
Normal file
44
frontend/src/widgets/DataGrid.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
type DataGridProps = {
|
||||||
|
rows: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DataGrid({ rows }: DataGridProps) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <div className="empty-state">No rows found for the current query.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column}>{column}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column}>{renderValue(row[column])}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return <span className="muted">null</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return <code className="code">{JSON.stringify(value)}</code>;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
27
frontend/src/widgets/LogViewer.tsx
Normal file
27
frontend/src/widgets/LogViewer.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
type LogViewerProps = {
|
||||||
|
logs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogViewer({ logs }: LogViewerProps) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">PostgreSQL Logs</h3>
|
||||||
|
<div className="table-wrap" style={{ maxHeight: 360 }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((line, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="code">{line}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/widgets/SchemaEditor.tsx
Normal file
93
frontend/src/widgets/SchemaEditor.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { TableColumn, TableIndex } from "../shared/types";
|
||||||
|
|
||||||
|
type SchemaEditorProps = {
|
||||||
|
columns: TableColumn[];
|
||||||
|
indexes: TableIndex[];
|
||||||
|
relations: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SchemaEditor({ columns, indexes, relations }: SchemaEditorProps) {
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">Columns</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Nullable</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th>PK</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<tr key={column.name}>
|
||||||
|
<td>{column.name}</td>
|
||||||
|
<td>{column.type}</td>
|
||||||
|
<td>{column.nullable ? "Yes" : "No"}</td>
|
||||||
|
<td>{column.default_value ?? "-"}</td>
|
||||||
|
<td>{column.is_primary ? "Yes" : "No"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">Indexes</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Unique</th>
|
||||||
|
<th>Definition</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{indexes.map((index) => (
|
||||||
|
<tr key={index.name}>
|
||||||
|
<td>{index.name}</td>
|
||||||
|
<td>{index.type}</td>
|
||||||
|
<td>{index.unique ? "Yes" : "No"}</td>
|
||||||
|
<td className="code">{index.definition}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">Relations</h3>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Constraint</th>
|
||||||
|
<th>Column</th>
|
||||||
|
<th>Foreign Table</th>
|
||||||
|
<th>Foreign Column</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{relations.map((relation, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{String(relation.constraint_name ?? "")}</td>
|
||||||
|
<td>{String(relation.column_name ?? "")}</td>
|
||||||
|
<td>{String(relation.foreign_table_name ?? "")}</td>
|
||||||
|
<td>{String(relation.foreign_column_name ?? "")}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/widgets/Sidebar.tsx
Normal file
65
frontend/src/widgets/Sidebar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Database, FileClock, Shield, Users } from "lucide-react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import type { SidebarGroup } from "../shared/types";
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
groups: SidebarGroup[];
|
||||||
|
currentTable: string | null;
|
||||||
|
onSelectTable: (tableName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Sidebar({ groups, currentTable, onSelectTable }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="brand">
|
||||||
|
<Database size={28} />
|
||||||
|
<div>
|
||||||
|
<div>PostgreSQL SensoLab</div>
|
||||||
|
<div className="badge">Production control plane</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-group">
|
||||||
|
<div className="sidebar-title">Navigation</div>
|
||||||
|
<NavLink className="nav-link" to="/">
|
||||||
|
<span>Dashboard</span>
|
||||||
|
<Database size={16} />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className="nav-link" to="/users">
|
||||||
|
<span>Users</span>
|
||||||
|
<Users size={16} />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className="nav-link" to="/roles">
|
||||||
|
<span>Roles</span>
|
||||||
|
<Shield size={16} />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className="nav-link" to="/audit">
|
||||||
|
<span>Audit</span>
|
||||||
|
<FileClock size={16} />
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-group" style={{ flex: 1, overflow: "auto" }}>
|
||||||
|
<div className="sidebar-title">Table Groups</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.slug} className="sidebar-group">
|
||||||
|
<div className="muted" style={{ fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
{group.tables.map((table) => (
|
||||||
|
<button
|
||||||
|
key={table.name}
|
||||||
|
className={`sidebar-item ${currentTable === table.name ? "active" : ""}`}
|
||||||
|
onClick={() => onSelectTable(table.name)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{table.display_name}</span>
|
||||||
|
<span className="badge">{table.estimated_rows}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/widgets/SqlConsole.tsx
Normal file
87
frontend/src/widgets/SqlConsole.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../shared/api/client";
|
||||||
|
|
||||||
|
type SqlConsoleProps = {
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SqlConsole({ visible }: SqlConsoleProps) {
|
||||||
|
const [sql, setSql] = useState("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';");
|
||||||
|
const [result, setResult] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [meta, setMeta] = useState<{
|
||||||
|
rowCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
statementType: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<h3 className="section-title">SQL Console</h3>
|
||||||
|
<div className="stack">
|
||||||
|
<textarea className="textarea" value={sql} onChange={(event) => setSql(event.target.value)} />
|
||||||
|
<div className="row">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setError("");
|
||||||
|
const response = await api.post<{
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
rowCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
statementType: string;
|
||||||
|
fields: Array<{ name: string }>;
|
||||||
|
notice?: string;
|
||||||
|
}>("/sql/execute", { sql });
|
||||||
|
setResult(response.data.rows);
|
||||||
|
setMeta({
|
||||||
|
rowCount: response.data.rowCount,
|
||||||
|
durationMs: response.data.durationMs,
|
||||||
|
statementType: response.data.statementType
|
||||||
|
});
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestError instanceof Error ? requestError.message : "SQL execution failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Execute
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
|
||||||
|
{meta ? (
|
||||||
|
<div className="muted">
|
||||||
|
{meta.statementType} · {meta.rowCount} rows · {meta.durationMs} ms
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result.length > 0 ? (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{Object.keys(result[0]).map((key) => (
|
||||||
|
<th key={key}>{key}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
{Object.keys(result[0]).map((key) => (
|
||||||
|
<td key={key}>{String(row[key] ?? "")}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/widgets/Topbar.tsx
Normal file
33
frontend/src/widgets/Topbar.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { LogOut, ShieldCheck, Terminal } from "lucide-react";
|
||||||
|
import type { SessionUser } from "../shared/types";
|
||||||
|
|
||||||
|
type TopbarProps = {
|
||||||
|
user: SessionUser;
|
||||||
|
onOpenSql: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Topbar({ user, onOpenSql, onLogout }: TopbarProps) {
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<div className="status-pill">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<span>
|
||||||
|
{user.username} · {user.roleSlug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="button secondary" type="button" onClick={onOpenSql}>
|
||||||
|
<Terminal size={16} style={{ marginRight: 8 }} />
|
||||||
|
SQL Console
|
||||||
|
</button>
|
||||||
|
<button className="button danger" type="button" onClick={onLogout}>
|
||||||
|
<LogOut size={16} style={{ marginRight: 8 }} />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:4000",
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
target: "http://localhost:4000",
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
1452
index.html
Normal file
1452
index.html
Normal file
File diff suppressed because it is too large
Load Diff
137
infra/init/001-control.sql
Normal file
137
infra/init/001-control.sql
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
create extension if not exists "pgcrypto";
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
username text unique not null,
|
||||||
|
password_hash text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
is_locked boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists roles (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
slug text unique not null,
|
||||||
|
name text not null,
|
||||||
|
description text not null default '',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists permissions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
resource text not null,
|
||||||
|
action text not null,
|
||||||
|
unique (resource, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists role_permissions (
|
||||||
|
role_id uuid not null references roles(id) on delete cascade,
|
||||||
|
permission_id uuid not null references permissions(id) on delete cascade,
|
||||||
|
scope_type text not null default 'global',
|
||||||
|
scope_value text null
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists role_permissions_scope_uidx
|
||||||
|
on role_permissions (role_id, permission_id, scope_type, coalesce(scope_value, ''));
|
||||||
|
|
||||||
|
create table if not exists user_roles (
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
role_id uuid not null references roles(id) on delete cascade,
|
||||||
|
primary key (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists resource_groups (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
slug text unique not null,
|
||||||
|
name text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists resource_group_tables (
|
||||||
|
group_id uuid not null references resource_groups(id) on delete cascade,
|
||||||
|
table_name text not null,
|
||||||
|
primary key (group_id, table_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists sessions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id uuid not null references users(id) on delete cascade,
|
||||||
|
token_hash text not null unique,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
ip text null,
|
||||||
|
user_agent text null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists audit_events (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
actor_user_id uuid null references users(id) on delete set null,
|
||||||
|
action text not null,
|
||||||
|
resource_type text not null,
|
||||||
|
resource_name text null,
|
||||||
|
group_id text null,
|
||||||
|
target_connection_id text null,
|
||||||
|
sql_text_masked text null,
|
||||||
|
payload_before jsonb null,
|
||||||
|
payload_after jsonb null,
|
||||||
|
ip text null,
|
||||||
|
user_agent text null,
|
||||||
|
status text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists db_connections (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
host text not null,
|
||||||
|
port integer not null,
|
||||||
|
database_name text not null,
|
||||||
|
is_default boolean not null default false,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into permissions (resource, action) values
|
||||||
|
('database', 'read'),
|
||||||
|
('database', 'write'),
|
||||||
|
('database', 'delete'),
|
||||||
|
('database', 'schema_change'),
|
||||||
|
('group', 'read'),
|
||||||
|
('group', 'write'),
|
||||||
|
('group', 'delete'),
|
||||||
|
('group', 'schema_change'),
|
||||||
|
('table', 'read'),
|
||||||
|
('table', 'write'),
|
||||||
|
('table', 'delete'),
|
||||||
|
('table', 'schema_change'),
|
||||||
|
('sql_console', 'execute_sql'),
|
||||||
|
('logs', 'view_logs'),
|
||||||
|
('users', 'manage_users'),
|
||||||
|
('roles', 'manage_roles'),
|
||||||
|
('audit', 'read')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into roles (slug, name, description) values
|
||||||
|
('root', 'Root', 'Full access to the control plane'),
|
||||||
|
('folder_admin', 'Folder Admin', 'Admin for selected groups'),
|
||||||
|
('user', 'User', 'Read-only or restricted access')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into resource_groups (slug, name) values
|
||||||
|
('finance', 'Finance'),
|
||||||
|
('users', 'Users'),
|
||||||
|
('logs', 'Logs')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into resource_group_tables (group_id, table_name)
|
||||||
|
select g.id, x.table_name
|
||||||
|
from (
|
||||||
|
values
|
||||||
|
('finance', 'finance__invoices'),
|
||||||
|
('users', 'users__accounts'),
|
||||||
|
('logs', 'logs__events')
|
||||||
|
) as x(group_slug, table_name)
|
||||||
|
join resource_groups g on g.slug = x.group_slug
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into db_connections (name, host, port, database_name, is_default)
|
||||||
|
values ('Default Target DB', 'postgres-target', 5432, 'appdb', true)
|
||||||
|
on conflict do nothing;
|
||||||
25
infra/init/002-seed-root.sql
Normal file
25
infra/init/002-seed-root.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
do $$
|
||||||
|
declare
|
||||||
|
root_role_id uuid;
|
||||||
|
root_user_id uuid;
|
||||||
|
begin
|
||||||
|
select id into root_role_id from roles where slug = 'root';
|
||||||
|
|
||||||
|
insert into users (username, password_hash)
|
||||||
|
values (
|
||||||
|
'root',
|
||||||
|
'pbkdf2$sha256$210000$pgadmin-root-seed$2dd2c2adeb3a7f89d8dac01f5f991bdf8d674231ac200f4281552e506452df95'
|
||||||
|
)
|
||||||
|
on conflict (username) do nothing;
|
||||||
|
|
||||||
|
select id into root_user_id from users where username = 'root';
|
||||||
|
|
||||||
|
insert into user_roles (user_id, role_id)
|
||||||
|
values (root_user_id, root_role_id)
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into role_permissions (role_id, permission_id, scope_type, scope_value)
|
||||||
|
select root_role_id, id, 'global', null
|
||||||
|
from permissions
|
||||||
|
on conflict do nothing;
|
||||||
|
end $$;
|
||||||
43
infra/init/010-target.sql
Normal file
43
infra/init/010-target.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
create extension if not exists "pgcrypto";
|
||||||
|
|
||||||
|
create table if not exists finance__invoices (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
customer_name text not null,
|
||||||
|
amount numeric(12,2) not null,
|
||||||
|
status text not null default 'draft',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists users__accounts (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text not null unique,
|
||||||
|
full_name text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists logs__events (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
source text not null,
|
||||||
|
level text not null,
|
||||||
|
message text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into finance__invoices (customer_name, amount, status)
|
||||||
|
values
|
||||||
|
('Acme Corp', 1200.50, 'paid'),
|
||||||
|
('Northwind', 340.00, 'draft')
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into users__accounts (email, full_name, is_active)
|
||||||
|
values
|
||||||
|
('root@example.com', 'Root Operator', true),
|
||||||
|
('analyst@example.com', 'Financial Analyst', true)
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into logs__events (source, level, message)
|
||||||
|
values
|
||||||
|
('system', 'info', 'Bootstrap completed'),
|
||||||
|
('postgres', 'warn', 'Autovacuum threshold reached')
|
||||||
|
on conflict do nothing;
|
||||||
3247
package-lock.json
generated
Normal file
3247
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "postgres-admin-control-plane",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev -w backend",
|
||||||
|
"dev:frontend": "npm run dev -w frontend",
|
||||||
|
"dev:backend": "npm run dev -w backend",
|
||||||
|
"build": "npm run build -w backend && npm run build -w frontend",
|
||||||
|
"test": "npm run test -w backend && npm run test -w frontend"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user