123
This commit is contained in:
144
.dockerignore
Normal file
144
.dockerignore
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# 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
|
||||||
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
|
||||||
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
|
||||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# PostgreSQL Control Center
|
||||||
|
|
||||||
|
Production-oriented admin panel for PostgreSQL with custom RBAC, audit logging, schema management, SQL console controls, and a separated backend/frontend architecture.
|
||||||
|
|
||||||
|
## Monorepo layout
|
||||||
|
|
||||||
|
- `backend` - Express + TypeScript API, RBAC, audit, PostgreSQL metadata/data access
|
||||||
|
- `frontend` - Vite + Vanilla JS SPA with modular components
|
||||||
|
- `infra` - Docker, PostgreSQL bootstrap SQL, container helpers
|
||||||
|
- `docs` - architecture and operational notes
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
|
||||||
|
- Frontend: `http://localhost:5173`
|
||||||
|
- Backend API: `http://localhost:4000/api`
|
||||||
|
- PostgreSQL: `localhost:5432`
|
||||||
|
|
||||||
|
Seeded root user:
|
||||||
|
|
||||||
|
- login: `root`
|
||||||
|
- password: `ChangeMe123!`
|
||||||
|
|
||||||
|
## Delivery scope
|
||||||
|
|
||||||
|
Implemented foundation:
|
||||||
|
|
||||||
|
- Separated backend/frontend architecture
|
||||||
|
- Session-based auth
|
||||||
|
- RBAC with group-scoped permissions
|
||||||
|
- Audit logging
|
||||||
|
- Table/data/schema APIs
|
||||||
|
- SQL console with safety guardrails
|
||||||
|
- PostgreSQL log viewing from Docker container
|
||||||
|
- Dockerized local production-like setup
|
||||||
|
|
||||||
|
See [docs/architecture.md](C:\Users\admin\Desktop\TEst\docs\architecture.md) for details.
|
||||||
8
backend/.env.example
Normal file
8
backend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app_admin
|
||||||
|
SESSION_SECRET=change-me
|
||||||
|
SESSION_NAME=pgcc.sid
|
||||||
|
ALLOWED_ORIGIN=http://localhost:5173
|
||||||
|
POSTGRES_CONTAINER_NAME=pg-control-postgres
|
||||||
|
COOKIE_SECURE=false
|
||||||
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package.json
|
||||||
|
COPY tsconfig.json tsconfig.json
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY src src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /app/package.json package.json
|
||||||
|
COPY --from=build /app/node_modules node_modules
|
||||||
|
COPY --from=build /app/dist dist
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@pg-control/backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"pg": "^8.11.5",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/pg": "^8.11.6",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/app.ts
Normal file
55
backend/src/app.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import cors from "cors";
|
||||||
|
import express from "express";
|
||||||
|
import session from "express-session";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { env } from "./config/env.js";
|
||||||
|
import { sessionStore } from "./db/session-store.js";
|
||||||
|
import { attachRequestContext } from "./middleware/request-context.js";
|
||||||
|
import { errorHandler } from "./middleware/error-handler.js";
|
||||||
|
import routes from "./routes/index.js";
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: env.ALLOWED_ORIGIN,
|
||||||
|
credentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 200,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
store: sessionStore,
|
||||||
|
secret: env.SESSION_SECRET,
|
||||||
|
name: env.SESSION_NAME,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: env.COOKIE_SECURE || env.NODE_ENV === "production",
|
||||||
|
maxAge: 1000 * 60 * 60 * 8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(attachRequestContext);
|
||||||
|
app.get("/health", (_request, response) => {
|
||||||
|
response.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
app.use("/api", routes);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
20
backend/src/config/env.ts
Normal file
20
backend/src/config/env.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
PORT: z.coerce.number().default(4000),
|
||||||
|
DATABASE_URL: z.string().min(1),
|
||||||
|
SESSION_SECRET: z.string().min(12),
|
||||||
|
SESSION_NAME: z.string().default("pgcc.sid"),
|
||||||
|
ALLOWED_ORIGIN: z.string().default("http://localhost:5173"),
|
||||||
|
POSTGRES_CONTAINER_NAME: z.string().default("pg-control-postgres"),
|
||||||
|
COOKIE_SECURE: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value === "true")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const env = envSchema.parse(process.env);
|
||||||
16
backend/src/constants/permissions.ts
Normal file
16
backend/src/constants/permissions.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const SYSTEM_ROLE_CODES = {
|
||||||
|
ROOT: "root",
|
||||||
|
GROUP_ADMIN: "group_admin",
|
||||||
|
EDITOR: "editor",
|
||||||
|
VIEWER: "viewer"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const AUDIT_ACTIONS = {
|
||||||
|
LOGIN_SUCCESS: "login_success",
|
||||||
|
LOGIN_FAILURE: "login_failure",
|
||||||
|
SQL_EXECUTE: "sql_execute",
|
||||||
|
DATA_MUTATION: "data_mutation",
|
||||||
|
SCHEMA_CHANGE: "schema_change",
|
||||||
|
USER_MANAGEMENT: "user_management",
|
||||||
|
ROLE_MANAGEMENT: "role_management"
|
||||||
|
} as const;
|
||||||
26
backend/src/controllers/admin.controller.ts
Normal file
26
backend/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { AuditService } from "../services/audit.service.js";
|
||||||
|
import { LogsService } from "../services/logs.service.js";
|
||||||
|
import { RbacService } from "../services/rbac.service.js";
|
||||||
|
|
||||||
|
const rbacService = new RbacService();
|
||||||
|
const auditService = new AuditService();
|
||||||
|
const logsService = new LogsService();
|
||||||
|
|
||||||
|
export class AdminController {
|
||||||
|
async users(_request: Request, response: Response) {
|
||||||
|
response.json({ users: await rbacService.listUsers() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async roles(_request: Request, response: Response) {
|
||||||
|
response.json({ roles: await rbacService.listRoles() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async audit(request: Request, response: Response) {
|
||||||
|
response.json({ logs: await auditService.list(request.query.search?.toString()) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async postgresLogs(request: Request, response: Response) {
|
||||||
|
response.json({ logs: await logsService.readLogs(request.query.search?.toString()) });
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/src/controllers/auth.controller.ts
Normal file
22
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { AuthService } from "../services/auth.service.js";
|
||||||
|
|
||||||
|
const authService = new AuthService();
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
async login(request: Request, response: Response) {
|
||||||
|
const user = await authService.login(request.body.username, request.body.password);
|
||||||
|
request.session.user = user;
|
||||||
|
response.json({ user });
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(request: Request, response: Response) {
|
||||||
|
request.session.destroy(() => {
|
||||||
|
response.status(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(request: Request, response: Response) {
|
||||||
|
response.json({ user: request.session.user ?? null });
|
||||||
|
}
|
||||||
|
}
|
||||||
78
backend/src/controllers/metadata.controller.ts
Normal file
78
backend/src/controllers/metadata.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { MetadataService } from "../services/metadata.service.js";
|
||||||
|
|
||||||
|
const service = new MetadataService();
|
||||||
|
|
||||||
|
export class MetadataController {
|
||||||
|
async listTables(_request: Request, response: Response) {
|
||||||
|
response.json({ tables: await service.listTables() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTableDetails(request: Request, response: Response) {
|
||||||
|
response.json(await service.getTableDetails(request.params.tableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRows(request: Request, response: Response) {
|
||||||
|
response.json(
|
||||||
|
await service.listRows({
|
||||||
|
userId: request.session.user!.id,
|
||||||
|
tableName: request.params.tableName,
|
||||||
|
page: Number(request.query.page ?? 1),
|
||||||
|
pageSize: Number(request.query.pageSize ?? 25),
|
||||||
|
search: request.query.search?.toString(),
|
||||||
|
sortBy: request.query.sortBy?.toString(),
|
||||||
|
sortDirection: request.query.sortDirection === "desc" ? "desc" : "asc"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTable(request: Request, response: Response) {
|
||||||
|
await service.createTable(request.session.user!.id, request.body);
|
||||||
|
response.status(201).json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTable(request: Request, response: Response) {
|
||||||
|
await service.deleteTable(request.session.user!.id, request.params.tableName);
|
||||||
|
response.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addColumn(request: Request, response: Response) {
|
||||||
|
await service.addColumn(request.session.user!.id, request.params.tableName, request.body);
|
||||||
|
response.status(201).json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async alterColumn(request: Request, response: Response) {
|
||||||
|
await service.alterColumnType(
|
||||||
|
request.session.user!.id,
|
||||||
|
request.params.tableName,
|
||||||
|
request.params.columnName,
|
||||||
|
request.body.dataType
|
||||||
|
);
|
||||||
|
response.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async dropColumn(request: Request, response: Response) {
|
||||||
|
await service.dropColumn(request.session.user!.id, request.params.tableName, request.params.columnName);
|
||||||
|
response.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIndex(request: Request, response: Response) {
|
||||||
|
await service.createIndex(request.session.user!.id, request.params.tableName, request.body);
|
||||||
|
response.status(201).json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRow(request: Request, response: Response) {
|
||||||
|
await service.createRow(request.session.user!.id, request.params.tableName, request.body);
|
||||||
|
response.status(201).json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRow(request: Request, response: Response) {
|
||||||
|
await service.updateRow(request.session.user!.id, request.params.tableName, request.params.id, request.body);
|
||||||
|
response.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRow(request: Request, response: Response) {
|
||||||
|
await service.deleteRow(request.session.user!.id, request.params.tableName, request.params.id);
|
||||||
|
response.status(204).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/controllers/sql.controller.ts
Normal file
10
backend/src/controllers/sql.controller.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { SqlConsoleService } from "../services/sql-console.service.js";
|
||||||
|
|
||||||
|
const service = new SqlConsoleService();
|
||||||
|
|
||||||
|
export class SqlController {
|
||||||
|
async execute(request: Request, response: Response) {
|
||||||
|
response.json(await service.execute(request.session.user!.id, request.body.sql));
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/db/pool.ts
Normal file
8
backend/src/db/pool.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: env.DATABASE_URL
|
||||||
|
});
|
||||||
11
backend/src/db/session-store.ts
Normal file
11
backend/src/db/session-store.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import session from "express-session";
|
||||||
|
import connectPgSimple from "connect-pg-simple";
|
||||||
|
import { pool } from "./pool.js";
|
||||||
|
|
||||||
|
const PgStore = connectPgSimple(session);
|
||||||
|
|
||||||
|
export const sessionStore = new PgStore({
|
||||||
|
pool,
|
||||||
|
tableName: "user_sessions",
|
||||||
|
createTableIfMissing: true
|
||||||
|
});
|
||||||
17
backend/src/middleware/admin.ts
Normal file
17
backend/src/middleware/admin.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { RbacService } from "../services/rbac.service.js";
|
||||||
|
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
|
||||||
|
|
||||||
|
const rbacService = new RbacService();
|
||||||
|
|
||||||
|
export async function requireAdmin(request: Request, _response: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
await rbacService.assertAnyRole(request.session.user!.id, [
|
||||||
|
SYSTEM_ROLE_CODES.ROOT,
|
||||||
|
SYSTEM_ROLE_CODES.GROUP_ADMIN
|
||||||
|
]);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/middleware/auth.ts
Normal file
10
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { UnauthorizedError } from "../utils/errors.js";
|
||||||
|
|
||||||
|
export function requireAuth(request: Request, _response: Response, next: NextFunction) {
|
||||||
|
if (!request.session.user) {
|
||||||
|
return next(new UnauthorizedError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
10
backend/src/middleware/error-handler.ts
Normal file
10
backend/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { AppError } from "../utils/errors.js";
|
||||||
|
|
||||||
|
export function errorHandler(error: Error, _request: Request, response: Response, _next: NextFunction) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return response.status(error.statusCode).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
6
backend/src/middleware/request-context.ts
Normal file
6
backend/src/middleware/request-context.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
export function attachRequestContext(request: Request, _response: Response, next: NextFunction) {
|
||||||
|
request.app.locals.requestStartedAt = new Date();
|
||||||
|
next();
|
||||||
|
}
|
||||||
18
backend/src/middleware/validate.ts
Normal file
18
backend/src/middleware/validate.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import type { ZodSchema } from "zod";
|
||||||
|
import { AppError } from "../utils/errors.js";
|
||||||
|
|
||||||
|
export function validateBody(schema: ZodSchema) {
|
||||||
|
return (request: Request, _response: Response, next: NextFunction) => {
|
||||||
|
const result = schema.safeParse(request.body);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return next(
|
||||||
|
new AppError(result.error.issues.map((issue: { message: string }) => issue.message).join(", "), 400)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.body = result.data;
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
57
backend/src/repositories/audit.repository.ts
Normal file
57
backend/src/repositories/audit.repository.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { PoolClient } from "pg";
|
||||||
|
import { pool } from "../db/pool.js";
|
||||||
|
|
||||||
|
export interface AuditLogInput {
|
||||||
|
userId?: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceName?: string | null;
|
||||||
|
sqlText?: string | null;
|
||||||
|
details?: unknown;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditRepository {
|
||||||
|
async create(entry: AuditLogInput, client?: PoolClient) {
|
||||||
|
const executor = client ?? pool;
|
||||||
|
await executor.query(
|
||||||
|
`
|
||||||
|
insert into audit_logs (
|
||||||
|
user_id,
|
||||||
|
action,
|
||||||
|
resource_type,
|
||||||
|
resource_name,
|
||||||
|
sql_text,
|
||||||
|
details,
|
||||||
|
success
|
||||||
|
) values ($1, $2, $3, $4, $5, $6::jsonb, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
entry.userId ?? null,
|
||||||
|
entry.action,
|
||||||
|
entry.resourceType,
|
||||||
|
entry.resourceName ?? null,
|
||||||
|
entry.sqlText ?? null,
|
||||||
|
JSON.stringify(entry.details ?? {}),
|
||||||
|
entry.success
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(search?: string) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select a.id, a.action, a.resource_type, a.resource_name, a.sql_text, a.details, a.success, a.created_at,
|
||||||
|
u.username
|
||||||
|
from audit_logs a
|
||||||
|
left join app_users u on u.id = a.user_id
|
||||||
|
where ($1::text is null or a.action ilike $1 or a.resource_name ilike $1 or coalesce(u.username, '') ilike $1)
|
||||||
|
order by a.created_at desc
|
||||||
|
limit 200
|
||||||
|
`,
|
||||||
|
[search ? `%${search}%` : null]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/repositories/auth.repository.ts
Normal file
30
backend/src/repositories/auth.repository.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pool } from "../db/pool.js";
|
||||||
|
|
||||||
|
export class AuthRepository {
|
||||||
|
async findUserByUsername(username: string) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select id, username, password_hash, is_active
|
||||||
|
from app_users
|
||||||
|
where username = $1
|
||||||
|
`,
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUserRoles(userId: string): Promise<string[]> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select r.code
|
||||||
|
from user_roles ur
|
||||||
|
join roles r on r.id = ur.role_id
|
||||||
|
where ur.user_id = $1
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row: { code: string }) => row.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/repositories/metadata.repository.ts
Normal file
63
backend/src/repositories/metadata.repository.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { pool } from "../db/pool.js";
|
||||||
|
|
||||||
|
export class MetadataRepository {
|
||||||
|
async listTables() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
t.table_name,
|
||||||
|
coalesce(tgt.table_group_id::text, '') as table_group_id,
|
||||||
|
coalesce(tg.name, 'ungrouped') as table_group,
|
||||||
|
obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass) as description
|
||||||
|
from information_schema.tables t
|
||||||
|
left join table_group_tables tgt on tgt.table_name = t.table_name
|
||||||
|
left join table_groups tg on tg.id = tgt.table_group_id
|
||||||
|
where t.table_schema = 'public'
|
||||||
|
and t.table_type = 'BASE TABLE'
|
||||||
|
and t.table_name not in (
|
||||||
|
'audit_logs', 'user_sessions', 'app_users', 'roles', 'permissions',
|
||||||
|
'role_permissions', 'user_roles', 'table_groups', 'table_group_tables'
|
||||||
|
)
|
||||||
|
order by tg.name nulls last, t.table_name
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTableColumns(tableName: string) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select column_name, data_type, is_nullable, column_default
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public' and table_name = $1
|
||||||
|
order by ordinal_position
|
||||||
|
`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForeignKeys(tableName: string) {
|
||||||
|
const { rows } = 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_name = $1
|
||||||
|
`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/src/repositories/rbac.repository.ts
Normal file
94
backend/src/repositories/rbac.repository.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { pool } from "../db/pool.js";
|
||||||
|
import type { PermissionAction } from "../types/api.js";
|
||||||
|
|
||||||
|
export class RbacRepository {
|
||||||
|
async hasSystemRole(userId: string, roleCode: string) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select 1
|
||||||
|
from user_roles ur
|
||||||
|
join roles r on r.id = ur.role_id
|
||||||
|
where ur.user_id = $1 and r.code = $2
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[userId, roleCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasAnyRole(userId: string, roleCodes: string[]) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select 1
|
||||||
|
from user_roles ur
|
||||||
|
join roles r on r.id = ur.role_id
|
||||||
|
where ur.user_id = $1 and r.code = any($2::text[])
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[userId, roleCodes]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasTablePermission(userId: string, tableName: string, action: PermissionAction) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select 1
|
||||||
|
from user_roles ur
|
||||||
|
join role_permissions rp on rp.role_id = ur.role_id
|
||||||
|
join permissions p on p.id = rp.permission_id
|
||||||
|
left join table_groups tg on tg.id = rp.table_group_id
|
||||||
|
left join table_group_tables tgt on tgt.table_group_id = tg.id
|
||||||
|
where ur.user_id = $1
|
||||||
|
and p.action = $2
|
||||||
|
and (tgt.table_name = $3 or p.resource = 'global')
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[userId, action, tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select u.id, u.username, u.is_active, coalesce(array_agg(r.code) filter (where r.code is not null), '{}') as roles
|
||||||
|
from app_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.username
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRoles() {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
select r.id, r.code, r.name, r.description,
|
||||||
|
coalesce(
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'resource', p.resource,
|
||||||
|
'action', p.action,
|
||||||
|
'tableGroupId', rp.table_group_id
|
||||||
|
)
|
||||||
|
) 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.code
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/routes/admin.routes.ts
Normal file
18
backend/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { AdminController } from "../controllers/admin.controller.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import { requireAdmin } from "../middleware/admin.js";
|
||||||
|
import { asyncHandler } from "../utils/async-handler.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const controller = new AdminController();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
router.get("/users", asyncHandler(controller.users.bind(controller)));
|
||||||
|
router.get("/roles", asyncHandler(controller.roles.bind(controller)));
|
||||||
|
router.get("/audit", asyncHandler(controller.audit.bind(controller)));
|
||||||
|
router.get("/postgres-logs", asyncHandler(controller.postgresLogs.bind(controller)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
14
backend/src/routes/auth.routes.ts
Normal file
14
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { AuthController } from "../controllers/auth.controller.js";
|
||||||
|
import { asyncHandler } from "../utils/async-handler.js";
|
||||||
|
import { validateBody } from "../middleware/validate.js";
|
||||||
|
import { loginSchema } from "../validators/auth.validators.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const controller = new AuthController();
|
||||||
|
|
||||||
|
router.post("/login", validateBody(loginSchema), asyncHandler(controller.login.bind(controller)));
|
||||||
|
router.post("/logout", asyncHandler(controller.logout.bind(controller)));
|
||||||
|
router.get("/me", asyncHandler(controller.me.bind(controller)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
14
backend/src/routes/index.ts
Normal file
14
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import authRoutes from "./auth.routes.js";
|
||||||
|
import metadataRoutes from "./metadata.routes.js";
|
||||||
|
import sqlRoutes from "./sql.routes.js";
|
||||||
|
import adminRoutes from "./admin.routes.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use("/auth", authRoutes);
|
||||||
|
router.use("/db", metadataRoutes);
|
||||||
|
router.use("/sql", sqlRoutes);
|
||||||
|
router.use("/admin", adminRoutes);
|
||||||
|
|
||||||
|
export default router;
|
||||||
36
backend/src/routes/metadata.routes.ts
Normal file
36
backend/src/routes/metadata.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { MetadataController } from "../controllers/metadata.controller.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import { validateBody } from "../middleware/validate.js";
|
||||||
|
import {
|
||||||
|
addColumnSchema,
|
||||||
|
alterColumnSchema,
|
||||||
|
createIndexSchema,
|
||||||
|
createTableSchema,
|
||||||
|
rowSchema
|
||||||
|
} from "../validators/metadata.validators.js";
|
||||||
|
import { asyncHandler } from "../utils/async-handler.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const controller = new MetadataController();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get("/tables", asyncHandler(controller.listTables.bind(controller)));
|
||||||
|
router.get("/tables/:tableName/details", asyncHandler(controller.getTableDetails.bind(controller)));
|
||||||
|
router.get("/tables/:tableName/rows", asyncHandler(controller.listRows.bind(controller)));
|
||||||
|
router.post("/tables", validateBody(createTableSchema), asyncHandler(controller.createTable.bind(controller)));
|
||||||
|
router.delete("/tables/:tableName", asyncHandler(controller.deleteTable.bind(controller)));
|
||||||
|
router.post("/tables/:tableName/columns", validateBody(addColumnSchema), asyncHandler(controller.addColumn.bind(controller)));
|
||||||
|
router.patch(
|
||||||
|
"/tables/:tableName/columns/:columnName",
|
||||||
|
validateBody(alterColumnSchema),
|
||||||
|
asyncHandler(controller.alterColumn.bind(controller))
|
||||||
|
);
|
||||||
|
router.delete("/tables/:tableName/columns/:columnName", asyncHandler(controller.dropColumn.bind(controller)));
|
||||||
|
router.post("/tables/:tableName/indexes", validateBody(createIndexSchema), asyncHandler(controller.createIndex.bind(controller)));
|
||||||
|
router.post("/tables/:tableName/rows", validateBody(rowSchema), asyncHandler(controller.createRow.bind(controller)));
|
||||||
|
router.put("/tables/:tableName/rows/:id", validateBody(rowSchema), asyncHandler(controller.updateRow.bind(controller)));
|
||||||
|
router.delete("/tables/:tableName/rows/:id", asyncHandler(controller.deleteRow.bind(controller)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
24
backend/src/routes/sql.routes.ts
Normal file
24
backend/src/routes/sql.routes.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { SqlController } from "../controllers/sql.controller.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
import { validateBody } from "../middleware/validate.js";
|
||||||
|
import { asyncHandler } from "../utils/async-handler.js";
|
||||||
|
import { sqlExecuteSchema } from "../validators/metadata.validators.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const controller = new SqlController();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
router.use(
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post("/execute", validateBody(sqlExecuteSchema), asyncHandler(controller.execute.bind(controller)));
|
||||||
|
|
||||||
|
export default router;
|
||||||
8
backend/src/server.ts
Normal file
8
backend/src/server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from "./app.js";
|
||||||
|
import { env } from "./config/env.js";
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
app.listen(env.PORT, () => {
|
||||||
|
console.log(`Backend listening on port ${env.PORT}`);
|
||||||
|
});
|
||||||
14
backend/src/services/audit.service.ts
Normal file
14
backend/src/services/audit.service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { AuditLogInput } from "../repositories/audit.repository.js";
|
||||||
|
import { AuditRepository } from "../repositories/audit.repository.js";
|
||||||
|
|
||||||
|
const repository = new AuditRepository();
|
||||||
|
|
||||||
|
export class AuditService {
|
||||||
|
async log(params: AuditLogInput) {
|
||||||
|
await repository.create(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(search?: string) {
|
||||||
|
return repository.list(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/src/services/auth.service.ts
Normal file
52
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||||
|
import { AuthRepository } from "../repositories/auth.repository.js";
|
||||||
|
import { AuditService } from "./audit.service.js";
|
||||||
|
import { UnauthorizedError } from "../utils/errors.js";
|
||||||
|
|
||||||
|
const authRepository = new AuthRepository();
|
||||||
|
const auditService = new AuditService();
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const user = await authRepository.findUserByUsername(username);
|
||||||
|
|
||||||
|
if (!user || !user.is_active) {
|
||||||
|
await auditService.log({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_FAILURE,
|
||||||
|
resourceType: "auth",
|
||||||
|
resourceName: username,
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
throw new UnauthorizedError("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
await auditService.log({
|
||||||
|
userId: user.id,
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_FAILURE,
|
||||||
|
resourceType: "auth",
|
||||||
|
resourceName: username,
|
||||||
|
success: false
|
||||||
|
});
|
||||||
|
throw new UnauthorizedError("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCodes = await authRepository.loadUserRoles(user.id);
|
||||||
|
|
||||||
|
await auditService.log({
|
||||||
|
userId: user.id,
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_SUCCESS,
|
||||||
|
resourceType: "auth",
|
||||||
|
resourceName: username,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
roleCodes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/services/logs.service.ts
Normal file
26
backend/src/services/logs.service.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Docker from "dockerode";
|
||||||
|
import { env } from "../config/env.js";
|
||||||
|
import { AppError } from "../utils/errors.js";
|
||||||
|
|
||||||
|
const docker = new Docker({ socketPath: "//./pipe/docker_engine" });
|
||||||
|
|
||||||
|
export class LogsService {
|
||||||
|
async readLogs(search?: string) {
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(env.POSTGRES_CONTAINER_NAME);
|
||||||
|
const logs = await container.logs({
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
tail: 300
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = logs.toString("utf-8");
|
||||||
|
return content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((line: string) => (search ? line.toLowerCase().includes(search.toLowerCase()) : true));
|
||||||
|
} catch {
|
||||||
|
throw new AppError("Unable to read PostgreSQL container logs. Ensure Docker is available.", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
backend/src/services/metadata.service.ts
Normal file
208
backend/src/services/metadata.service.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { pool } from "../db/pool.js";
|
||||||
|
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||||
|
import { MetadataRepository } from "../repositories/metadata.repository.js";
|
||||||
|
import { assertIdentifier, quoteIdentifier } from "../utils/identifiers.js";
|
||||||
|
import { AuditService } from "./audit.service.js";
|
||||||
|
import { RbacService } from "./rbac.service.js";
|
||||||
|
|
||||||
|
const repository = new MetadataRepository();
|
||||||
|
const auditService = new AuditService();
|
||||||
|
const rbacService = new RbacService();
|
||||||
|
|
||||||
|
export class MetadataService {
|
||||||
|
async listTables() {
|
||||||
|
return repository.listTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTableDetails(tableName: string) {
|
||||||
|
assertIdentifier(tableName, "table name");
|
||||||
|
const [columns, foreignKeys] = await Promise.all([
|
||||||
|
repository.listTableColumns(tableName),
|
||||||
|
repository.listForeignKeys(tableName)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { columns, foreignKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRows(params: {
|
||||||
|
userId: string;
|
||||||
|
tableName: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDirection?: "asc" | "desc";
|
||||||
|
}) {
|
||||||
|
await rbacService.assertPermission(params.userId, params.tableName, "read");
|
||||||
|
assertIdentifier(params.tableName, "table name");
|
||||||
|
|
||||||
|
const columns = await repository.listTableColumns(params.tableName);
|
||||||
|
const searchableColumns = columns.map((column: { column_name: string }) => column.column_name);
|
||||||
|
const offset = (params.page - 1) * params.pageSize;
|
||||||
|
const orderColumn = params.sortBy ? quoteIdentifier(params.sortBy) : "\"id\"";
|
||||||
|
const direction = params.sortDirection === "desc" ? "DESC" : "ASC";
|
||||||
|
|
||||||
|
const filterValues: unknown[] = [];
|
||||||
|
let whereSql = "";
|
||||||
|
|
||||||
|
if (params.search && searchableColumns.length > 0) {
|
||||||
|
filterValues.push(`%${params.search}%`);
|
||||||
|
whereSql = `where ${searchableColumns
|
||||||
|
.map((column: string) => `cast(${quoteIdentifier(column)} as text) ilike $1`)
|
||||||
|
.join(" or ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowValues = [...filterValues, params.pageSize, offset];
|
||||||
|
|
||||||
|
const rowsQuery = `
|
||||||
|
select *
|
||||||
|
from ${quoteIdentifier(params.tableName)}
|
||||||
|
${whereSql}
|
||||||
|
order by ${orderColumn} ${direction}
|
||||||
|
limit $${rowValues.length - 1} offset $${rowValues.length}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countQuery = `
|
||||||
|
select count(*)::int as total
|
||||||
|
from ${quoteIdentifier(params.tableName)}
|
||||||
|
${whereSql}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rowsResult, countResult] = await Promise.all([
|
||||||
|
pool.query(rowsQuery, rowValues),
|
||||||
|
pool.query(countQuery, filterValues)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rowsResult.rows,
|
||||||
|
total: countResult.rows[0]?.total ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) {
|
||||||
|
await this.assertSchemaPermission(userId, payload.tableName);
|
||||||
|
|
||||||
|
const columnSql = payload.columns
|
||||||
|
.map(
|
||||||
|
(column: { name: string; type: string; nullable?: boolean }) =>
|
||||||
|
`${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
await pool.query(`create table ${quoteIdentifier(payload.tableName)} (${columnSql})`);
|
||||||
|
await this.logSchema(userId, payload.tableName, "create_table", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTable(userId: string, tableName: string) {
|
||||||
|
await this.assertSchemaPermission(userId, tableName);
|
||||||
|
await pool.query(`drop table if exists ${quoteIdentifier(tableName)} cascade`);
|
||||||
|
await this.logSchema(userId, tableName, "delete_table", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addColumn(userId: string, tableName: string, column: { name: string; type: string; nullable?: boolean }) {
|
||||||
|
await this.assertSchemaPermission(userId, tableName);
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(tableName)} add column ${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}`
|
||||||
|
);
|
||||||
|
await this.logSchema(userId, tableName, "add_column", column);
|
||||||
|
}
|
||||||
|
|
||||||
|
async alterColumnType(userId: string, tableName: string, columnName: string, dataType: string) {
|
||||||
|
await this.assertSchemaPermission(userId, tableName);
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(tableName)} alter column ${quoteIdentifier(columnName)} type ${dataType}`
|
||||||
|
);
|
||||||
|
await this.logSchema(userId, tableName, "alter_column_type", { columnName, dataType });
|
||||||
|
}
|
||||||
|
|
||||||
|
async dropColumn(userId: string, tableName: string, columnName: string) {
|
||||||
|
await this.assertSchemaPermission(userId, tableName);
|
||||||
|
await pool.query(
|
||||||
|
`alter table ${quoteIdentifier(tableName)} drop column if exists ${quoteIdentifier(columnName)}`
|
||||||
|
);
|
||||||
|
await this.logSchema(userId, tableName, "drop_column", { columnName });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIndex(userId: string, tableName: string, payload: { indexName: string; columns: string[]; unique?: boolean }) {
|
||||||
|
await this.assertSchemaPermission(userId, tableName);
|
||||||
|
const columnsSql = payload.columns.map((column: string) => quoteIdentifier(column)).join(", ");
|
||||||
|
await pool.query(
|
||||||
|
`create ${payload.unique ? "unique " : ""}index ${quoteIdentifier(payload.indexName)} on ${quoteIdentifier(tableName)} (${columnsSql})`
|
||||||
|
);
|
||||||
|
await this.logSchema(userId, tableName, "create_index", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRow(userId: string, tableName: string, data: Record<string, unknown>) {
|
||||||
|
await rbacService.assertPermission(userId, tableName, "write");
|
||||||
|
await this.mutateRow(userId, tableName, "insert", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRow(userId: string, tableName: string, id: string | number, data: Record<string, unknown>) {
|
||||||
|
await rbacService.assertPermission(userId, tableName, "write");
|
||||||
|
await this.mutateRow(userId, tableName, "update", data, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRow(userId: string, tableName: string, id: string | number) {
|
||||||
|
await rbacService.assertPermission(userId, tableName, "delete");
|
||||||
|
await pool.query(`delete from ${quoteIdentifier(tableName)} where id = $1`, [id]);
|
||||||
|
await auditService.log({
|
||||||
|
userId,
|
||||||
|
action: AUDIT_ACTIONS.DATA_MUTATION,
|
||||||
|
resourceType: "row",
|
||||||
|
resourceName: `${tableName}:${id}`,
|
||||||
|
details: { id, operation: "delete" },
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mutateRow(
|
||||||
|
userId: string,
|
||||||
|
tableName: string,
|
||||||
|
operation: "insert" | "update",
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
id?: string | number
|
||||||
|
) {
|
||||||
|
assertIdentifier(tableName, "table name");
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
const columns = entries.map(([column]: [string, unknown]) => quoteIdentifier(column));
|
||||||
|
const values = entries.map(([, value]: [string, unknown]) => value);
|
||||||
|
|
||||||
|
if (operation === "insert") {
|
||||||
|
const placeholders = values.map((_value: unknown, index: number) => `$${index + 1}`).join(", ");
|
||||||
|
await pool.query(
|
||||||
|
`insert into ${quoteIdentifier(tableName)} (${columns.join(", ")}) values (${placeholders})`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const setSql = columns.map((column: string, index: number) => `${column} = $${index + 1}`).join(", ");
|
||||||
|
await pool.query(
|
||||||
|
`update ${quoteIdentifier(tableName)} set ${setSql} where id = $${values.length + 1}`,
|
||||||
|
[...values, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditService.log({
|
||||||
|
userId,
|
||||||
|
action: AUDIT_ACTIONS.DATA_MUTATION,
|
||||||
|
resourceType: "row",
|
||||||
|
resourceName: `${tableName}:${id ?? "new"}`,
|
||||||
|
details: { operation, data },
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSchemaPermission(userId: string, tableName: string) {
|
||||||
|
await rbacService.assertPermission(userId, tableName, "schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logSchema(userId: string, tableName: string, operation: string, details: unknown) {
|
||||||
|
await auditService.log({
|
||||||
|
userId,
|
||||||
|
action: AUDIT_ACTIONS.SCHEMA_CHANGE,
|
||||||
|
resourceType: "table",
|
||||||
|
resourceName: tableName,
|
||||||
|
details: { operation, ...((details as object) ?? {}) },
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/src/services/rbac.service.ts
Normal file
35
backend/src/services/rbac.service.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { SYSTEM_ROLE_CODES } from "../constants/permissions.js";
|
||||||
|
import { RbacRepository } from "../repositories/rbac.repository.js";
|
||||||
|
import type { PermissionAction } from "../types/api.js";
|
||||||
|
import { ForbiddenError } from "../utils/errors.js";
|
||||||
|
|
||||||
|
const repository = new RbacRepository();
|
||||||
|
|
||||||
|
export class RbacService {
|
||||||
|
async assertPermission(userId: string, tableName: string, action: PermissionAction) {
|
||||||
|
const isRoot = await repository.hasSystemRole(userId, SYSTEM_ROLE_CODES.ROOT);
|
||||||
|
if (isRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = await repository.hasTablePermission(userId, tableName, action);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenError(`Missing ${action} access to table ${tableName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertAnyRole(userId: string, roleCodes: string[]) {
|
||||||
|
const allowed = await repository.hasAnyRole(userId, roleCodes);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenError("Administrative role required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers() {
|
||||||
|
return repository.listUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRoles() {
|
||||||
|
return repository.listRoles();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/services/sql-console.service.ts
Normal file
43
backend/src/services/sql-console.service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { pool } from "../db/pool.js";
|
||||||
|
import { AUDIT_ACTIONS } from "../constants/permissions.js";
|
||||||
|
import { AuditService } from "./audit.service.js";
|
||||||
|
import { RbacService } from "./rbac.service.js";
|
||||||
|
import { assertSafeSql, inferQueryAction } from "../utils/sql-safety.js";
|
||||||
|
|
||||||
|
const auditService = new AuditService();
|
||||||
|
const rbacService = new RbacService();
|
||||||
|
|
||||||
|
export class SqlConsoleService {
|
||||||
|
async execute(userId: string, sql: string) {
|
||||||
|
assertSafeSql(sql);
|
||||||
|
const action = inferQueryAction(sql);
|
||||||
|
|
||||||
|
if (action === "schema" || action === "execute") {
|
||||||
|
await rbacService.assertPermission(userId, "audit_logs", "schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const result = await pool.query(sql);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
|
||||||
|
await auditService.log({
|
||||||
|
userId,
|
||||||
|
action: AUDIT_ACTIONS.SQL_EXECUTE,
|
||||||
|
resourceType: "sql_console",
|
||||||
|
sqlText: sql,
|
||||||
|
details: { rowCount: result.rowCount, durationMs, action },
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: result.command,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
durationMs,
|
||||||
|
fields: result.fields.map((field: { name: string; dataTypeID: number }) => ({
|
||||||
|
name: field.name,
|
||||||
|
dataTypeId: field.dataTypeID
|
||||||
|
})),
|
||||||
|
rows: result.rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/src/types/api.ts
Normal file
7
backend/src/types/api.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type PermissionAction = "read" | "write" | "delete" | "schema" | "execute";
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
roleCodes: string[];
|
||||||
|
}
|
||||||
11
backend/src/types/express-session.d.ts
vendored
Normal file
11
backend/src/types/express-session.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import "express-session";
|
||||||
|
|
||||||
|
declare module "express-session" {
|
||||||
|
interface SessionData {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
roleCodes: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/utils/async-handler.ts
Normal file
9
backend/src/utils/async-handler.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
export function asyncHandler(
|
||||||
|
handler: (request: Request, response: Response, next: NextFunction) => Promise<unknown>
|
||||||
|
) {
|
||||||
|
return (request: Request, response: Response, next: NextFunction) => {
|
||||||
|
void handler(request, response, next).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
20
backend/src/utils/errors.ts
Normal file
20
backend/src/utils/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode = 400) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = "Forbidden") {
|
||||||
|
super(message, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
constructor(message = "Unauthorized") {
|
||||||
|
super(message, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/utils/identifiers.ts
Normal file
15
backend/src/utils/identifiers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { AppError } from "./errors.js";
|
||||||
|
|
||||||
|
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
|
export function assertIdentifier(value: string, label = "identifier"): string {
|
||||||
|
if (!SAFE_IDENTIFIER.test(value)) {
|
||||||
|
throw new AppError(`Invalid ${label}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quoteIdentifier(value: string): string {
|
||||||
|
return `"${assertIdentifier(value).replace(/"/g, "\"\"")}"`;
|
||||||
|
}
|
||||||
40
backend/src/utils/sql-safety.ts
Normal file
40
backend/src/utils/sql-safety.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { AppError } from "./errors.js";
|
||||||
|
|
||||||
|
const BLOCKED_PATTERNS = [
|
||||||
|
/\bdrop\s+database\b/i,
|
||||||
|
/\bdrop\s+role\b/i,
|
||||||
|
/\balter\s+system\b/i,
|
||||||
|
/\bcopy\s+.+\s+to\s+program\b/i,
|
||||||
|
/\bgrant\s+superuser\b/i,
|
||||||
|
/\btruncate\b/i
|
||||||
|
];
|
||||||
|
|
||||||
|
export function assertSafeSql(sql: string) {
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(sql)) {
|
||||||
|
throw new AppError("Query contains a blocked operation", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferQueryAction(sql: string): "read" | "write" | "delete" | "schema" | "execute" {
|
||||||
|
const normalized = sql.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (normalized.startsWith("select") || normalized.startsWith("with")) {
|
||||||
|
return "read";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("insert") || normalized.startsWith("update")) {
|
||||||
|
return "write";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("delete")) {
|
||||||
|
return "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("create") || normalized.startsWith("alter") || normalized.startsWith("drop")) {
|
||||||
|
return "schema";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "execute";
|
||||||
|
}
|
||||||
6
backend/src/validators/auth.validators.ts
Normal file
6
backend/src/validators/auth.validators.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
username: z.string().min(3),
|
||||||
|
password: z.string().min(8)
|
||||||
|
});
|
||||||
34
backend/src/validators/metadata.validators.ts
Normal file
34
backend/src/validators/metadata.validators.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createTableSchema = z.object({
|
||||||
|
tableName: z.string().min(1),
|
||||||
|
columns: z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
nullable: z.boolean().optional()
|
||||||
|
})
|
||||||
|
).min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addColumnSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.string().min(1),
|
||||||
|
nullable: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const alterColumnSchema = z.object({
|
||||||
|
dataType: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createIndexSchema = z.object({
|
||||||
|
indexName: z.string().min(1),
|
||||||
|
columns: z.array(z.string().min(1)).min(1),
|
||||||
|
unique: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rowSchema = z.record(z.any());
|
||||||
|
|
||||||
|
export const sqlExecuteSchema = z.object({
|
||||||
|
sql: z.string().min(1)
|
||||||
|
});
|
||||||
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,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: pg-control-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: app_admin
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
|
||||||
|
- ./infra/postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||||
|
- ./infra/postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
|
||||||
|
- postgres_logs:/var/log/postgresql
|
||||||
|
command: [
|
||||||
|
"postgres",
|
||||||
|
"-c",
|
||||||
|
"config_file=/etc/postgresql/postgresql.conf",
|
||||||
|
"-c",
|
||||||
|
"hba_file=/etc/postgresql/pg_hba.conf"
|
||||||
|
]
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pg-control-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env.example
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 4000
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/app_admin
|
||||||
|
SESSION_SECRET: super-secret-change-me
|
||||||
|
POSTGRES_CONTAINER_NAME: pg-control-postgres
|
||||||
|
ALLOWED_ORIGIN: http://localhost:5173
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pg-control-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
VITE_API_BASE_URL: http://localhost:4000/api
|
||||||
|
ports:
|
||||||
|
- "5173:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
postgres_logs:
|
||||||
102
docs/architecture.md
Normal file
102
docs/architecture.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Production-oriented structure instead of a demo-only MVP
|
||||||
|
- Strict backend authorization for every data/schema action
|
||||||
|
- Safe SQL execution with explicit policy checks
|
||||||
|
- Clean separation between UI, API, and persistence concerns
|
||||||
|
- Deployable through Docker Compose and extensible toward Kubernetes
|
||||||
|
|
||||||
|
## High-level design
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Layered Node.js/Express application:
|
||||||
|
|
||||||
|
- `routes` expose HTTP endpoints
|
||||||
|
- `controllers` translate HTTP into service calls
|
||||||
|
- `services` contain business logic and authorization-aware workflows
|
||||||
|
- `repositories` talk to PostgreSQL metadata/business tables
|
||||||
|
- `middleware` handles auth, sessions, RBAC context, validation, errors
|
||||||
|
- `db` contains application pool and SQL bootstrap migrations
|
||||||
|
|
||||||
|
Two PostgreSQL access modes are used:
|
||||||
|
|
||||||
|
1. Application database access for auth, RBAC, audit, and metadata.
|
||||||
|
2. Controlled administrative SQL access for schema/data operations against managed tables.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
SPA with modular vanilla JavaScript:
|
||||||
|
|
||||||
|
- `pages` assemble route-level screens
|
||||||
|
- `components` provide reusable UI blocks
|
||||||
|
- `api` isolates HTTP communication
|
||||||
|
- `styles` keeps tokens/layout/components separated
|
||||||
|
|
||||||
|
This keeps the UI light while preserving clean boundaries. It can later be migrated to React/Vue without backend changes.
|
||||||
|
|
||||||
|
## Request flow
|
||||||
|
|
||||||
|
1. Session-authenticated user calls API.
|
||||||
|
2. Auth middleware loads session user.
|
||||||
|
3. Permission middleware resolves table group and required action.
|
||||||
|
4. Service validates identifiers and allowed SQL patterns.
|
||||||
|
5. Repository or admin-query utility executes parameterized SQL.
|
||||||
|
6. Audit service stores action metadata and outcome.
|
||||||
|
7. Structured response is returned to frontend.
|
||||||
|
|
||||||
|
## RBAC model
|
||||||
|
|
||||||
|
Core entities:
|
||||||
|
|
||||||
|
- `users`
|
||||||
|
- `roles`
|
||||||
|
- `permissions`
|
||||||
|
- `role_permissions`
|
||||||
|
- `user_roles`
|
||||||
|
- `table_groups`
|
||||||
|
- `table_group_tables`
|
||||||
|
|
||||||
|
Permission key shape:
|
||||||
|
|
||||||
|
- resource: `group`, `table`, `sql_console`, `logs`, `users`, `roles`, `audit`
|
||||||
|
- action: `read`, `write`, `delete`, `schema`, `execute`
|
||||||
|
|
||||||
|
Built-in model:
|
||||||
|
|
||||||
|
- `root`: unrestricted
|
||||||
|
- `group_admin`: scoped by assigned table groups, can manage schema/data per granted actions
|
||||||
|
- `viewer` / `editor`: least-privilege table access
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- `express-session` with secure cookie settings
|
||||||
|
- password hashing with `bcrypt`
|
||||||
|
- `helmet`, CORS allowlist, request size limits
|
||||||
|
- `zod` validation for request payloads
|
||||||
|
- identifier allowlisting and quoting for schema/table/column names
|
||||||
|
- parameterized queries for data paths
|
||||||
|
- SQL console denylist for dangerous statements and optional read-only mode by role
|
||||||
|
- audit log for auth, SQL, DML, DDL
|
||||||
|
- rate limiting for auth and console routes
|
||||||
|
|
||||||
|
## Scalability
|
||||||
|
|
||||||
|
- Stateless API except for shared session store abstraction
|
||||||
|
- Service/repository boundaries allow splitting modules later
|
||||||
|
- Docker-ready and twelve-factor env configuration
|
||||||
|
- Easy switch from in-memory session store to Redis/Postgres-backed store
|
||||||
|
- API can be horizontally scaled behind a reverse proxy
|
||||||
|
|
||||||
|
## Production improvements to add next
|
||||||
|
|
||||||
|
- Redis session store
|
||||||
|
- background jobs for heavy exports/imports
|
||||||
|
- row-level policies / policy engine
|
||||||
|
- WebSocket query progress / tailing logs
|
||||||
|
- metrics (`/metrics`) with Prometheus
|
||||||
|
- OpenTelemetry tracing
|
||||||
|
- optimistic UI with saved query tabs
|
||||||
|
- soft approvals for risky DDL actions
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package.json
|
||||||
|
COPY vite.config.js vite.config.js
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY public public
|
||||||
|
COPY src src
|
||||||
|
COPY index.html index.html
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PostgreSQL Control Center</title>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@pg-control/frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/src/api/client.js
Normal file
42
frontend/src/api/client.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api";
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {})
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || "Request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => request("/auth/me"),
|
||||||
|
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
logout: () => request("/auth/logout", { method: "POST" }),
|
||||||
|
tables: () => request("/db/tables"),
|
||||||
|
tableDetails: (tableName) => request(`/db/tables/${tableName}/details`),
|
||||||
|
rows: (tableName, query = {}) => {
|
||||||
|
const params = new URLSearchParams(query).toString();
|
||||||
|
return request(`/db/tables/${tableName}/rows${params ? `?${params}` : ""}`);
|
||||||
|
},
|
||||||
|
executeSql: (sql) => request("/sql/execute", { method: "POST", body: JSON.stringify({ sql }) }),
|
||||||
|
users: () => request("/admin/users"),
|
||||||
|
roles: () => request("/admin/roles"),
|
||||||
|
audit: (search = "") => request(`/admin/audit${search ? `?search=${encodeURIComponent(search)}` : ""}`),
|
||||||
|
postgresLogs: (search = "") =>
|
||||||
|
request(`/admin/postgres-logs${search ? `?search=${encodeURIComponent(search)}` : ""}`)
|
||||||
|
};
|
||||||
311
frontend/src/components/shell.js
Normal file
311
frontend/src/components/shell.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
export function renderAppShell(state) {
|
||||||
|
const groups = groupTables(state.tables);
|
||||||
|
const activeTable = state.activeTable ? state.tables.find((table) => table.table_name === state.activeTable) : null;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="layout-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand-block">
|
||||||
|
<span class="brand-kicker">PostgreSQL Control Center</span>
|
||||||
|
<h1>Database Admin</h1>
|
||||||
|
<p>Управление схемой, данными, SQL и аудитом.</p>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-caption">Группы таблиц</div>
|
||||||
|
${groups
|
||||||
|
.map(
|
||||||
|
(group) => `
|
||||||
|
<section class="sidebar-group">
|
||||||
|
<div class="group-title">${group.name}</div>
|
||||||
|
${group.tables
|
||||||
|
.map(
|
||||||
|
(table) => `
|
||||||
|
<button class="nav-table ${state.activeTable === table.table_name ? "is-active" : ""}" data-table="${table.table_name}">
|
||||||
|
<span>${table.table_name}</span>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-caption">Навигация</div>
|
||||||
|
${[
|
||||||
|
["overview", "Обзор"],
|
||||||
|
["users", "Пользователи"],
|
||||||
|
["roles", "Роли"],
|
||||||
|
["audit", "Аудит"],
|
||||||
|
["logs", "Логи PostgreSQL"]
|
||||||
|
]
|
||||||
|
.map(
|
||||||
|
([id, label]) => `
|
||||||
|
<button class="nav-link ${state.page === id ? "is-active" : ""}" data-page="${id}">${label}</button>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main-panel">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">Сессия</div>
|
||||||
|
<div class="user-badge">${state.user.username} · ${state.user.roleCodes.join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
${activeTable ? `<div class="table-chip">${activeTable.table_group} / ${activeTable.table_name}</div>` : ""}
|
||||||
|
<button id="logoutButton" class="ghost-button">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section class="content-panel">
|
||||||
|
${renderPageContent(state)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupTables(tables) {
|
||||||
|
const map = new Map();
|
||||||
|
tables.forEach((table) => {
|
||||||
|
if (!map.has(table.table_group)) {
|
||||||
|
map.set(table.table_group, {
|
||||||
|
name: table.table_group,
|
||||||
|
tables: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map.get(table.table_group).tables.push(table);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPageContent(state) {
|
||||||
|
if (state.page === "users") {
|
||||||
|
return renderUsers(state.users);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.page === "roles") {
|
||||||
|
return renderRoles(state.roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.page === "audit") {
|
||||||
|
return renderAudit(state.auditLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.page === "logs") {
|
||||||
|
return renderLogViewer(state.postgresLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderOverview(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview(state) {
|
||||||
|
return `
|
||||||
|
<div class="hero-card">
|
||||||
|
<div>
|
||||||
|
<span class="hero-kicker">Production-oriented panel</span>
|
||||||
|
<h2>Управление PostgreSQL с RBAC, аудитом и SQL console.</h2>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats">
|
||||||
|
<div><strong>${state.tables.length}</strong><span>Таблиц</span></div>
|
||||||
|
<div><strong>${state.auditLogs.length}</strong><span>Записей аудита</span></div>
|
||||||
|
<div><strong>${state.postgresLogs.length}</strong><span>Лог-строк</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-strip">
|
||||||
|
${[
|
||||||
|
["data", "Данные"],
|
||||||
|
["structure", "Структура"],
|
||||||
|
["sql", "SQL Console"],
|
||||||
|
["indexes", "Индексы"]
|
||||||
|
]
|
||||||
|
.map(
|
||||||
|
([tab, label]) => `
|
||||||
|
<button class="tab-button ${state.activeTab === tab ? "is-active" : ""}" data-tab="${tab}">${label}</button>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
<div class="workspace-grid">
|
||||||
|
<section class="workspace-card">
|
||||||
|
${renderActiveTab(state)}
|
||||||
|
</section>
|
||||||
|
<section class="workspace-card side-actions">
|
||||||
|
<h3>Быстрые действия</h3>
|
||||||
|
<button class="primary-button" data-action="refresh">Обновить данные</button>
|
||||||
|
<button class="secondary-button" data-page="audit">Открыть аудит</button>
|
||||||
|
<button class="secondary-button" data-page="logs">Открыть логи PostgreSQL</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveTab(state) {
|
||||||
|
if (!state.activeTable) {
|
||||||
|
return `<div class="empty-state">Выберите таблицу в левом меню, чтобы открыть данные и структуру.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeTab === "structure") {
|
||||||
|
const columns = state.tableDetails.columns || [];
|
||||||
|
const fks = state.tableDetails.foreignKeys || [];
|
||||||
|
return `
|
||||||
|
<h3>Структура: ${state.activeTable}</h3>
|
||||||
|
<div class="mini-grid">
|
||||||
|
<div>
|
||||||
|
<h4>Колонки</h4>
|
||||||
|
<div class="data-list">
|
||||||
|
${columns
|
||||||
|
.map(
|
||||||
|
(column) => `
|
||||||
|
<div class="data-list-item">
|
||||||
|
<strong>${column.column_name}</strong>
|
||||||
|
<span>${column.data_type}</span>
|
||||||
|
<span>${column.is_nullable === "YES" ? "nullable" : "required"}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Foreign Keys</h4>
|
||||||
|
<div class="data-list">
|
||||||
|
${fks.length
|
||||||
|
? fks
|
||||||
|
.map(
|
||||||
|
(fk) => `
|
||||||
|
<div class="data-list-item">
|
||||||
|
<strong>${fk.column_name}</strong>
|
||||||
|
<span>${fk.foreign_table_name}.${fk.foreign_column_name}</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="empty-inline">Связи не найдены</div>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeTab === "sql") {
|
||||||
|
return `
|
||||||
|
<h3>SQL Console</h3>
|
||||||
|
<textarea id="sqlEditor" class="sql-editor" placeholder="select * from ${state.activeTable} limit 20;">${state.sqlDraft}</textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="primary-button" data-action="run-sql">Выполнить</button>
|
||||||
|
</div>
|
||||||
|
<div class="sql-result">
|
||||||
|
${state.sqlResult ? renderRowsTable(state.sqlResult.rows) : `<div class="empty-inline">Результат появится после выполнения запроса.</div>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeTab === "indexes") {
|
||||||
|
return `
|
||||||
|
<h3>Индексы и оптимизация</h3>
|
||||||
|
<p class="muted">
|
||||||
|
В backend уже подготовлены endpoints для создания индексов. Следующий шаг для production:
|
||||||
|
рекомендации по индексам, explain plans и heatmaps по slow queries.
|
||||||
|
</p>
|
||||||
|
<div class="info-panel">
|
||||||
|
<span>Текущая реализация хранит индексные операции через backend и логирует их в аудит.</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Данные: ${state.activeTable}</h3>
|
||||||
|
<div class="search-box">
|
||||||
|
<input id="tableSearchInput" value="${state.search || ""}" placeholder="Поиск по всем колонкам" />
|
||||||
|
<button class="secondary-button" data-action="search">Искать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${renderRowsTable(state.rows.rows || [])}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRowsTable(rows) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return `<div class="empty-state">Нет данных для отображения.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(rows[0]);
|
||||||
|
return `
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>${columns.map((column) => `<th>${column}</th>`).join("")}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows
|
||||||
|
.map(
|
||||||
|
(row) => `
|
||||||
|
<tr>${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}</tr>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return `<span class="cell-null">null</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers(users) {
|
||||||
|
return `
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Управление пользователями</h2>
|
||||||
|
<p class="muted">Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.</p>
|
||||||
|
</div>
|
||||||
|
${renderRowsTable(users)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoles(roles) {
|
||||||
|
return `
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Роли и доступы</h2>
|
||||||
|
<p class="muted">RBAC хранится в PostgreSQL и поддерживает групповые права на таблицы.</p>
|
||||||
|
</div>
|
||||||
|
${renderRowsTable(roles)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAudit(logs) {
|
||||||
|
return `
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Аудит</h2>
|
||||||
|
<p class="muted">Логируются входы, SQL, изменения данных и схемы.</p>
|
||||||
|
</div>
|
||||||
|
${renderRowsTable(logs)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogViewer(logs) {
|
||||||
|
return `
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Логи PostgreSQL</h2>
|
||||||
|
<p class="muted">Чтение контейнерных логов для диагностики и мониторинга.</p>
|
||||||
|
</div>
|
||||||
|
<div class="log-viewer">
|
||||||
|
${logs.map((line) => `<div class="log-line">${line}</div>`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
176
frontend/src/main.js
Normal file
176
frontend/src/main.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { api } from "./api/client.js";
|
||||||
|
import { renderAppShell } from "./components/shell.js";
|
||||||
|
import { renderLoginPage } from "./pages/login.js";
|
||||||
|
import "./styles/main.css";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
user: null,
|
||||||
|
error: "",
|
||||||
|
tables: [],
|
||||||
|
activeTable: "",
|
||||||
|
activeTab: "data",
|
||||||
|
page: "overview",
|
||||||
|
rows: { rows: [], total: 0 },
|
||||||
|
tableDetails: { columns: [], foreignKeys: [] },
|
||||||
|
users: [],
|
||||||
|
roles: [],
|
||||||
|
auditLogs: [],
|
||||||
|
postgresLogs: [],
|
||||||
|
sqlDraft: "",
|
||||||
|
sqlResult: null,
|
||||||
|
search: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = document.querySelector("#app");
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const { user } = await api.me();
|
||||||
|
state.user = user;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await hydrateDashboard();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
state.user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateDashboard() {
|
||||||
|
const [tablesPayload, usersPayload, rolesPayload, auditPayload, logsPayload] = await Promise.all([
|
||||||
|
api.tables(),
|
||||||
|
api.users(),
|
||||||
|
api.roles(),
|
||||||
|
api.audit(),
|
||||||
|
api.postgresLogs()
|
||||||
|
]);
|
||||||
|
|
||||||
|
state.tables = tablesPayload.tables;
|
||||||
|
state.users = usersPayload.users;
|
||||||
|
state.roles = rolesPayload.roles;
|
||||||
|
state.auditLogs = auditPayload.logs;
|
||||||
|
state.postgresLogs = logsPayload.logs;
|
||||||
|
|
||||||
|
if (!state.activeTable && state.tables[0]) {
|
||||||
|
state.activeTable = state.tables[0].table_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeTable) {
|
||||||
|
await Promise.all([loadRows(), loadTableDetails()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRows() {
|
||||||
|
if (!state.activeTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.rows = await api.rows(state.activeTable, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
search: state.search
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTableDetails() {
|
||||||
|
if (!state.activeTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.tableDetails = await api.tableDetails(state.activeTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
app.innerHTML = state.user ? renderAppShell(state) : renderLoginPage(state);
|
||||||
|
bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
const loginForm = document.querySelector("#loginForm");
|
||||||
|
if (loginForm) {
|
||||||
|
loginForm.addEventListener("submit", handleLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoutButton = document.querySelector("#logoutButton");
|
||||||
|
if (logoutButton) {
|
||||||
|
logoutButton.addEventListener("click", handleLogout);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-page]").forEach((element) => {
|
||||||
|
element.addEventListener("click", async (event) => {
|
||||||
|
state.page = event.currentTarget.dataset.page;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-tab]").forEach((element) => {
|
||||||
|
element.addEventListener("click", async (event) => {
|
||||||
|
state.activeTab = event.currentTarget.dataset.tab;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-table]").forEach((element) => {
|
||||||
|
element.addEventListener("click", async (event) => {
|
||||||
|
state.activeTable = event.currentTarget.dataset.table;
|
||||||
|
state.page = "overview";
|
||||||
|
state.sqlDraft = `select * from ${state.activeTable} limit 20;`;
|
||||||
|
await Promise.all([loadRows(), loadTableDetails()]);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-action]").forEach((element) => {
|
||||||
|
element.addEventListener("click", handleAction);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
state.error = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await api.login({
|
||||||
|
username: formData.get("username"),
|
||||||
|
password: formData.get("password")
|
||||||
|
});
|
||||||
|
state.user = payload.user;
|
||||||
|
await hydrateDashboard();
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await api.logout();
|
||||||
|
state.user = null;
|
||||||
|
state.error = "";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAction(event) {
|
||||||
|
const action = event.currentTarget.dataset.action;
|
||||||
|
|
||||||
|
if (action === "refresh") {
|
||||||
|
await hydrateDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "search") {
|
||||||
|
state.search = document.querySelector("#tableSearchInput")?.value || "";
|
||||||
|
await loadRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "run-sql") {
|
||||||
|
state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
|
||||||
|
state.sqlResult = await api.executeSql(state.sqlDraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
27
frontend/src/pages/login.js
Normal file
27
frontend/src/pages/login.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export function renderLoginPage(state) {
|
||||||
|
return `
|
||||||
|
<div class="login-page">
|
||||||
|
<section class="login-panel">
|
||||||
|
<div class="login-copy">
|
||||||
|
<span class="hero-kicker">Secure Admin Access</span>
|
||||||
|
<h1>Production-grade PostgreSQL admin panel</h1>
|
||||||
|
<p>
|
||||||
|
Session-based auth, RBAC по группам таблиц, аудит действий, безопасный SQL console и контейнерные логи.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form id="loginForm" class="login-form">
|
||||||
|
<label>
|
||||||
|
<span>Логин</span>
|
||||||
|
<input name="username" value="root" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Пароль</span>
|
||||||
|
<input name="password" type="password" value="ChangeMe123!" required />
|
||||||
|
</label>
|
||||||
|
<button class="primary-button" type="submit">Войти</button>
|
||||||
|
${state.error ? `<div class="error-banner">${state.error}</div>` : ""}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
351
frontend/src/styles/main.css
Normal file
351
frontend/src/styles/main.css
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f3efe6;
|
||||||
|
--bg-strong: #e1d6c4;
|
||||||
|
--panel: rgba(255, 250, 242, 0.86);
|
||||||
|
--panel-strong: #fff8ec;
|
||||||
|
--ink: #1f2a24;
|
||||||
|
--ink-muted: #5b665e;
|
||||||
|
--accent: #0c6a5b;
|
||||||
|
--accent-strong: #12453d;
|
||||||
|
--accent-soft: #c7e3da;
|
||||||
|
--border: rgba(31, 42, 36, 0.12);
|
||||||
|
--danger: #8e3b35;
|
||||||
|
--shadow: 0 20px 50px rgba(35, 34, 28, 0.12);
|
||||||
|
--radius: 24px;
|
||||||
|
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(12, 106, 91, 0.18), transparent 32%),
|
||||||
|
linear-gradient(135deg, #f6f2e8, #e8efe5 58%, #e4ddd0);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 24px;
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 32px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(25, 42, 35, 0.95);
|
||||||
|
color: #f8efe0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1,
|
||||||
|
.hero-card h2,
|
||||||
|
.login-copy h1 {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-kicker,
|
||||||
|
.hero-kicker,
|
||||||
|
.eyebrow,
|
||||||
|
.sidebar-caption {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-caption,
|
||||||
|
.brand-kicker {
|
||||||
|
color: rgba(248, 239, 224, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(248, 239, 224, 0.72);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-table,
|
||||||
|
.nav-link,
|
||||||
|
.tab-button,
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.ghost-button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-table,
|
||||||
|
.nav-link {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-table:hover,
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-table.is-active,
|
||||||
|
.nav-link.is-active {
|
||||||
|
background: rgba(255, 248, 236, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.hero-card,
|
||||||
|
.workspace-card,
|
||||||
|
.login-form,
|
||||||
|
.login-copy {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 20px 22px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions,
|
||||||
|
.form-actions,
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
padding-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats div {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats strong {
|
||||||
|
font-size: 32px;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 248, 236, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.is-active,
|
||||||
|
.primary-button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button,
|
||||||
|
.ghost-button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.9fr 0.8fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-card,
|
||||||
|
.login-copy,
|
||||||
|
.login-form {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-list,
|
||||||
|
.log-viewer {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-list-item,
|
||||||
|
.log-line,
|
||||||
|
.info-panel,
|
||||||
|
.table-chip,
|
||||||
|
.user-badge {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: rgba(12, 106, 91, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-editor,
|
||||||
|
.login-form input,
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fffdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-editor {
|
||||||
|
min-height: 220px;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted,
|
||||||
|
.empty-inline,
|
||||||
|
.cell-null {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 248, 236, 0.5);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(142, 59, 53, 0.12);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.layout-shell,
|
||||||
|
.login-panel,
|
||||||
|
.workspace-grid,
|
||||||
|
.mini-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card,
|
||||||
|
.section-header,
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/vite.config.js
Normal file
8
frontend/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
156
infra/postgres/init.sql
Normal file
156
infra/postgres/init.sql
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
create extension if not exists "pgcrypto";
|
||||||
|
|
||||||
|
create table if not exists app_users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
username varchar(100) unique not null,
|
||||||
|
password_hash text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists roles (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
code varchar(100) unique not null,
|
||||||
|
name varchar(120) not null,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists permissions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
resource varchar(100) not null,
|
||||||
|
action varchar(50) not null,
|
||||||
|
unique(resource, action)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_roles (
|
||||||
|
user_id uuid not null references app_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 table_groups (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
code varchar(100) unique not null,
|
||||||
|
name varchar(120) not null,
|
||||||
|
description text
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists table_group_tables (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
table_group_id uuid not null references table_groups(id) on delete cascade,
|
||||||
|
table_name varchar(120) unique not null
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
table_group_id uuid references table_groups(id) on delete cascade,
|
||||||
|
primary key(role_id, permission_id, table_group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists audit_logs (
|
||||||
|
id bigserial primary key,
|
||||||
|
user_id uuid references app_users(id) on delete set null,
|
||||||
|
action varchar(100) not null,
|
||||||
|
resource_type varchar(100) not null,
|
||||||
|
resource_name varchar(150),
|
||||||
|
sql_text text,
|
||||||
|
details jsonb not null default '{}'::jsonb,
|
||||||
|
success boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists finance_entries (
|
||||||
|
id bigserial primary key,
|
||||||
|
title varchar(120) not null,
|
||||||
|
amount numeric(12, 2) not null,
|
||||||
|
currency varchar(10) not null default 'USD',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists user_profiles (
|
||||||
|
id bigserial primary key,
|
||||||
|
email varchar(190) not null unique,
|
||||||
|
full_name varchar(190) not null,
|
||||||
|
status varchar(50) not null default 'active',
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists app_logs (
|
||||||
|
id bigserial primary key,
|
||||||
|
level varchar(20) not null,
|
||||||
|
message text not null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into table_groups (code, name, description)
|
||||||
|
values
|
||||||
|
('finance', 'Finance', 'Financial tables'),
|
||||||
|
('users', 'Users', 'User domain tables'),
|
||||||
|
('logs', 'Logs', 'Log and audit related tables')
|
||||||
|
on conflict (code) do nothing;
|
||||||
|
|
||||||
|
insert into table_group_tables (table_group_id, table_name)
|
||||||
|
select tg.id, mapping.table_name
|
||||||
|
from table_groups tg
|
||||||
|
join (
|
||||||
|
values
|
||||||
|
('finance', 'finance_entries'),
|
||||||
|
('users', 'user_profiles'),
|
||||||
|
('logs', 'app_logs')
|
||||||
|
) as mapping(group_code, table_name)
|
||||||
|
on mapping.group_code = tg.code
|
||||||
|
on conflict (table_name) do nothing;
|
||||||
|
|
||||||
|
insert into permissions (resource, action)
|
||||||
|
values
|
||||||
|
('global', 'read'),
|
||||||
|
('global', 'write'),
|
||||||
|
('global', 'delete'),
|
||||||
|
('global', 'schema'),
|
||||||
|
('global', 'execute')
|
||||||
|
on conflict (resource, action) do nothing;
|
||||||
|
|
||||||
|
insert into roles (code, name, description)
|
||||||
|
values
|
||||||
|
('root', 'Root', 'Full access'),
|
||||||
|
('group_admin', 'Group Admin', 'Admin of assigned table groups'),
|
||||||
|
('editor', 'Editor', 'Read and write access'),
|
||||||
|
('viewer', 'Viewer', 'Read-only access')
|
||||||
|
on conflict (code) do nothing;
|
||||||
|
|
||||||
|
insert into app_users (username, password_hash)
|
||||||
|
values
|
||||||
|
('root', crypt('ChangeMe123!', gen_salt('bf')))
|
||||||
|
on conflict (username) do nothing;
|
||||||
|
|
||||||
|
insert into user_roles (user_id, role_id)
|
||||||
|
select u.id, r.id
|
||||||
|
from app_users u
|
||||||
|
join roles r on r.code = 'root'
|
||||||
|
where u.username = 'root'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into role_permissions (role_id, permission_id, table_group_id)
|
||||||
|
select r.id, p.id, null
|
||||||
|
from roles r
|
||||||
|
join permissions p on p.resource = 'global'
|
||||||
|
where r.code = 'root'
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
insert into finance_entries (title, amount, currency)
|
||||||
|
values
|
||||||
|
('Subscription revenue', 1500.00, 'USD'),
|
||||||
|
('Cloud hosting', -420.00, 'USD');
|
||||||
|
|
||||||
|
insert into user_profiles (email, full_name, status)
|
||||||
|
values
|
||||||
|
('root@example.com', 'Root Administrator', 'active'),
|
||||||
|
('analyst@example.com', 'Finance Analyst', 'active')
|
||||||
|
on conflict (email) do nothing;
|
||||||
|
|
||||||
|
insert into app_logs (level, message)
|
||||||
|
values
|
||||||
|
('info', 'System initialized'),
|
||||||
|
('warn', 'Background job delayed');
|
||||||
3
infra/postgres/pg_hba.conf
Normal file
3
infra/postgres/pg_hba.conf
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
local all all trust
|
||||||
|
host all all 0.0.0.0/0 md5
|
||||||
|
host all all ::/0 md5
|
||||||
7
infra/postgres/postgresql.conf
Normal file
7
infra/postgres/postgresql.conf
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
listen_addresses = '*'
|
||||||
|
logging_collector = on
|
||||||
|
log_directory = '/var/log/postgresql'
|
||||||
|
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
|
||||||
|
log_statement = 'ddl'
|
||||||
|
log_min_duration_statement = 500
|
||||||
|
log_line_prefix = '%m [%p] %u@%d '
|
||||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "postgres-control-center",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev -w backend & npm run dev -w frontend",
|
||||||
|
"build": "npm run build -w backend && npm run build -w frontend",
|
||||||
|
"start": "npm run start -w backend"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user