mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
Merge pull request #1 from johndoe6345789/copilot/add-postgres-web-ui
Add modern Postgres web admin UI with Material UI, authentication, and all-in-one Docker deployment
This commit is contained in:
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.next
|
||||
out
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.vscode
|
||||
.storybook
|
||||
coverage
|
||||
tests
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.e2e.ts
|
||||
.github
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
4
.env
4
.env
@@ -17,6 +17,10 @@ NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
# `DATABASE_URL` is a placeholder, you can find your connection string in `.env.local` file.
|
||||
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres
|
||||
|
||||
# Admin Panel JWT Secret (REQUIRED for admin panel authentication)
|
||||
# Generate a secure secret with: openssl rand -base64 32
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
|
||||
# Next.js
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
|
||||
59
.github/workflows/docker.yml
vendored
Normal file
59
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
447
ADMIN_README.md
Normal file
447
ADMIN_README.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# Postgres Web Admin Panel
|
||||
|
||||
A **modern, beautiful replacement for legacy database admin tools** like phpMyAdmin, Adminer, and pgAdmin.
|
||||
|
||||
Built with Next.js 16, Material UI, and TypeScript for a superior user experience.
|
||||
|
||||
## Why This Instead of Legacy Tools?
|
||||
|
||||
### 🎨 Modern vs. Crusty
|
||||
- **Beautiful Material UI** instead of outdated 2000s-era interfaces
|
||||
- **Dark mode friendly** and responsive design
|
||||
- **Fast, smooth interactions** with React and Next.js
|
||||
- **Clean, intuitive navigation** vs cluttered legacy UIs
|
||||
|
||||
### 🚀 All-in-One Solution
|
||||
- **Includes PostgreSQL** - no separate database setup needed
|
||||
- **Docker-ready** - deploy anywhere in seconds
|
||||
- **Zero configuration** - works out of the box
|
||||
- **Built-in authentication** - no complicated auth setup
|
||||
|
||||
### 🔒 Security First
|
||||
- **Modern authentication** with bcrypt + JWT
|
||||
- **SQL injection protection** with multiple layers
|
||||
- **Session management** with HTTP-only cookies
|
||||
- **Auto-generated passwords** - no default "admin/admin"
|
||||
|
||||
### 💼 Production Ready
|
||||
- **Caprover compatible** - deploy with one click
|
||||
- **GitHub Container Registry** - automated CI/CD
|
||||
- **Cloudflare Tunnel support** - easy HTTPS
|
||||
- **Persistent storage** - data survives restarts
|
||||
|
||||
## Replaces These Legacy Tools
|
||||
|
||||
| Old Tool | Issues | This Solution |
|
||||
|----------|--------|---------------|
|
||||
| **phpMyAdmin** | PHP-based, outdated UI, MySQL-focused | Modern Next.js, beautiful UI, PostgreSQL-focused |
|
||||
| **Adminer** | Single PHP file, basic features | Full-featured app with authentication |
|
||||
| **pgAdmin** | Heavy desktop app, complex setup | Lightweight web app, simple deployment |
|
||||
| **SQL Workbench** | Desktop only, OS-specific | Web-based, works everywhere |
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 User/password authentication
|
||||
- 💎 Material UI design
|
||||
- 📊 View database tables
|
||||
- 🔍 SQL query interface (SELECT only for security)
|
||||
- 📋 Table data viewer with pagination
|
||||
- 🐳 Docker support
|
||||
- 📦 GitHub Container Registry (GHCR) integration
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Setup Database
|
||||
|
||||
First, ensure your database is running and migrations are applied:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### 2. Create Admin User
|
||||
|
||||
Create an admin user for logging into the admin panel:
|
||||
|
||||
```bash
|
||||
npm run db:seed-admin
|
||||
```
|
||||
|
||||
**Auto-generated password**: If you don't provide a password, a secure 32-character password will be automatically generated:
|
||||
|
||||
```bash
|
||||
✅ Admin user created successfully!
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📧 Username: admin
|
||||
🔑 Password: aB3$xK9@mP2&vL8#qR5!wN7^zT4%yU6*
|
||||
⚠️ This password was auto-generated. Save it securely!
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🌐 Login at: http://localhost:3000/admin/login
|
||||
```
|
||||
|
||||
**Custom credentials**: You can also provide custom credentials:
|
||||
|
||||
```bash
|
||||
ADMIN_USERNAME=myuser ADMIN_PASSWORD=mypassword npm run db:seed-admin
|
||||
```
|
||||
|
||||
### Generate Secure Passwords
|
||||
|
||||
Use the built-in password generator to create secure passwords:
|
||||
|
||||
```bash
|
||||
# Generate a 32-character password (default)
|
||||
npm run generate:password
|
||||
|
||||
# Generate a 64-character password
|
||||
npm run generate:password 64
|
||||
|
||||
# Generate without special characters
|
||||
npm run generate:password 32 false
|
||||
```
|
||||
|
||||
Example output:
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🔐 Secure Password Generated
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Password: xK9@mP2&vL8#qR5!wN7^zT4%yU6*aB3$
|
||||
Length: 32 characters
|
||||
Special characters: Yes
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
⚠️ Save this password securely!
|
||||
|
||||
💡 Usage examples:
|
||||
ADMIN_PASSWORD="xK9@mP2&vL8#qR5!wN7^zT4%yU6*aB3$" npm run db:seed-admin
|
||||
export JWT_SECRET="xK9@mP2&vL8#qR5!wN7^zT4%yU6*aB3$"
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
```
|
||||
|
||||
### 3. Access the Admin Panel
|
||||
|
||||
Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigate to: **http://localhost:3000/admin/login**
|
||||
|
||||
## Caprover Deployment
|
||||
|
||||
The application is ready to deploy on Caprover with minimal configuration.
|
||||
|
||||
### Deploy to Caprover
|
||||
|
||||
1. **Create a new app** in your Caprover dashboard
|
||||
- App Name: `postgres-admin` (or your choice)
|
||||
- Enable HTTPS (Caprover handles SSL automatically)
|
||||
|
||||
2. **Deploy via Dockerfile**:
|
||||
- Caprover will automatically use the Dockerfile in the repository
|
||||
- No additional configuration needed!
|
||||
|
||||
3. **Set Environment Variables** in Caprover:
|
||||
```
|
||||
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||
CREATE_ADMIN_USER=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your-secure-password
|
||||
```
|
||||
|
||||
4. **Access your admin panel**:
|
||||
- https://postgres-admin.your-caprover-domain.com/admin/login
|
||||
|
||||
### Captain Definition (Optional)
|
||||
|
||||
If you want to customize the build, create `captain-definition` in the root:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"dockerfilePath": "./Dockerfile"
|
||||
}
|
||||
```
|
||||
|
||||
### Caprover One-Click App (Optional)
|
||||
|
||||
For easier deployment, you can also deploy as a one-click app. The all-in-one Docker image includes PostgreSQL and Next.js, so no external database needed!
|
||||
|
||||
### Notes
|
||||
|
||||
- **HTTPS**: Caprover automatically provides HTTPS via Let's Encrypt
|
||||
- **Built-in Database**: The Docker image includes PostgreSQL, no need for separate database setup
|
||||
- **Persistent Storage**: Caprover automatically handles volume persistence
|
||||
- **Auto-restart**: Caprover restarts the container automatically on failure
|
||||
|
||||
## Cloudflare Tunnel Deployment (Alternative)
|
||||
|
||||
<details>
|
||||
<summary>Click to expand Cloudflare Tunnel instructions</summary>
|
||||
|
||||
The application works seamlessly with Cloudflare Tunnel for secure HTTPS access without exposing ports.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Install `cloudflared`: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/
|
||||
2. Cloudflare account with a domain
|
||||
|
||||
### Quick Setup with Cloudflare Tunnel
|
||||
|
||||
1. **Start the application**:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Create a Cloudflare Tunnel**:
|
||||
```bash
|
||||
cloudflared tunnel login
|
||||
cloudflared tunnel create postgres-admin
|
||||
```
|
||||
|
||||
3. **Create tunnel configuration** (`~/.cloudflared/config.yml`):
|
||||
```yaml
|
||||
tunnel: <your-tunnel-id>
|
||||
credentials-file: /home/user/.cloudflared/<tunnel-id>.json
|
||||
|
||||
ingress:
|
||||
- hostname: postgres-admin.yourdomain.com
|
||||
service: http://localhost:3000
|
||||
- service: http_status:404
|
||||
```
|
||||
|
||||
4. **Route DNS**:
|
||||
```bash
|
||||
cloudflared tunnel route dns postgres-admin postgres-admin.yourdomain.com
|
||||
```
|
||||
|
||||
5. **Run the tunnel**:
|
||||
```bash
|
||||
cloudflared tunnel run postgres-admin
|
||||
```
|
||||
|
||||
6. **Access your admin panel** at:
|
||||
- https://postgres-admin.yourdomain.com/admin/login
|
||||
|
||||
### Docker Compose with Cloudflare Tunnel
|
||||
|
||||
Create a complete setup with tunnel included:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres-admin:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://docker:docker@localhost:5432/postgres
|
||||
- JWT_SECRET=your-secret-key-change-in-production
|
||||
- CREATE_ADMIN_USER=true
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=admin123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/15/main
|
||||
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
command: tunnel --no-autoupdate run
|
||||
environment:
|
||||
- TUNNEL_TOKEN=<your-tunnel-token>
|
||||
depends_on:
|
||||
- postgres-admin
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
Get your tunnel token from: https://one.dash.cloudflare.com/
|
||||
|
||||
### Security Considerations with Cloudflare
|
||||
|
||||
✅ **Automatic HTTPS** - Cloudflare provides SSL/TLS automatically
|
||||
✅ **DDoS Protection** - Built-in Cloudflare protection
|
||||
✅ **Access Control** - Use Cloudflare Access for additional authentication
|
||||
✅ **Rate Limiting** - Configure Cloudflare rate limits
|
||||
✅ **WAF** - Web Application Firewall protection
|
||||
|
||||
### Recommended Cloudflare Settings
|
||||
|
||||
1. **SSL/TLS Mode**: Full (strict) recommended
|
||||
2. **Always Use HTTPS**: Enabled
|
||||
3. **Automatic HTTPS Rewrites**: Enabled
|
||||
4. **HTTP Strict Transport Security (HSTS)**: Enabled
|
||||
5. **Rate Limiting**: Configure for /api/* endpoints
|
||||
|
||||
### Cloudflare Access (Optional Extra Security)
|
||||
|
||||
Add an extra authentication layer:
|
||||
|
||||
```bash
|
||||
# In Cloudflare Dashboard > Access > Applications
|
||||
# Create a new application for postgres-admin.yourdomain.com
|
||||
# Add authentication methods (Email OTP, Google, etc.)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The project includes an **all-in-one Docker image** that contains both PostgreSQL and the Next.js application, making deployment simple and straightforward.
|
||||
|
||||
### Build Docker Image
|
||||
|
||||
```bash
|
||||
docker build -t postgres-admin .
|
||||
```
|
||||
|
||||
### Run with Docker (All-in-One)
|
||||
|
||||
The simplest way to run the application is using the all-in-one image:
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -p 5432:5432 \
|
||||
-e JWT_SECRET="your-secret-key" \
|
||||
-e ADMIN_USERNAME="admin" \
|
||||
-e ADMIN_PASSWORD="admin123" \
|
||||
postgres-admin
|
||||
```
|
||||
|
||||
The container will:
|
||||
1. Start PostgreSQL automatically
|
||||
2. Run database migrations
|
||||
3. Create the admin user
|
||||
4. Start the Next.js application
|
||||
|
||||
Access the admin panel at: **http://localhost:3000/admin/login**
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
The easiest way to run the application:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
This will:
|
||||
- Build the all-in-one image
|
||||
- Start PostgreSQL and Next.js in the same container
|
||||
- Expose ports 3000 (web) and 5432 (database)
|
||||
- Create a persistent volume for PostgreSQL data
|
||||
- Automatically create an admin user
|
||||
|
||||
Access the admin panel at: **http://localhost:3000/admin/login**
|
||||
|
||||
Default credentials:
|
||||
- **Username**: admin
|
||||
- **Password**: admin123
|
||||
|
||||
### Multi-Container Setup (Optional)
|
||||
|
||||
If you prefer to run PostgreSQL in a separate container, create this `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: mydb
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
admin:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/mydb
|
||||
JWT_SECRET: your-secret-key-here
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## GitHub Container Registry (GHCR)
|
||||
|
||||
The project automatically builds and publishes Docker images to GitHub Container Registry when you push to the main branch or create tags.
|
||||
|
||||
### Pull from GHCR
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/johndoe6345789/postgres:latest
|
||||
```
|
||||
|
||||
### Run from GHCR
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-e DATABASE_URL="your-database-url" \
|
||||
-e JWT_SECRET="your-secret-key" \
|
||||
ghcr.io/johndoe6345789/postgres:latest
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | Required |
|
||||
| `JWT_SECRET` | Secret key for JWT tokens | `your-secret-key-change-in-production` |
|
||||
| `ADMIN_USERNAME` | Initial admin username | `admin` |
|
||||
| `ADMIN_PASSWORD` | Initial admin password | `admin123` |
|
||||
|
||||
## Security
|
||||
|
||||
- Only SELECT queries are allowed in the SQL query interface
|
||||
- Password authentication with bcrypt hashing
|
||||
- JWT-based session management
|
||||
- HTTP-only cookies for session tokens
|
||||
- Protected API routes requiring authentication
|
||||
|
||||
## API Routes
|
||||
|
||||
- `POST /api/admin/login` - User login
|
||||
- `POST /api/admin/logout` - User logout
|
||||
- `GET /api/admin/tables` - List all database tables
|
||||
- `POST /api/admin/query` - Execute SQL query (SELECT only)
|
||||
|
||||
## Production Deployment
|
||||
|
||||
1. Set strong passwords for admin users
|
||||
2. Use a secure JWT_SECRET
|
||||
3. Enable HTTPS
|
||||
4. Configure proper CORS settings
|
||||
5. Set up database backups
|
||||
6. Monitor logs and errors
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run migrations
|
||||
npm run db:migrate
|
||||
|
||||
# Create admin user
|
||||
npm run db:seed-admin
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
90
Dockerfile
Normal file
90
Dockerfile
Normal file
@@ -0,0 +1,90 @@
|
||||
# All-in-one image with PostgreSQL and Next.js
|
||||
FROM node:20-bookworm
|
||||
|
||||
# Install PostgreSQL
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-15 \
|
||||
postgresql-client-15 \
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up PostgreSQL
|
||||
USER postgres
|
||||
RUN /etc/init.d/postgresql start && \
|
||||
psql --command "CREATE USER docker WITH SUPERUSER PASSWORD 'docker';" && \
|
||||
createdb -O docker postgres
|
||||
|
||||
# Switch back to root
|
||||
USER root
|
||||
|
||||
# Configure PostgreSQL to allow connections
|
||||
RUN echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/15/main/pg_hba.conf && \
|
||||
echo "listen_addresses='*'" >> /etc/postgresql/15/main/postgresql.conf
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Build the Next.js application
|
||||
RUN npm run build
|
||||
|
||||
# Copy startup script
|
||||
COPY <<EOF /start.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Start PostgreSQL
|
||||
service postgresql start
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
until pg_isready -h localhost -p 5432; do
|
||||
echo "Waiting for PostgreSQL..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Set DATABASE_URL if not provided
|
||||
export DATABASE_URL=\${DATABASE_URL:-postgresql://docker:docker@localhost:5432/postgres}
|
||||
|
||||
# Generate JWT_SECRET if not provided
|
||||
if [ -z "\$JWT_SECRET" ]; then
|
||||
echo "WARNING: JWT_SECRET not provided. Generating a random secret..."
|
||||
export JWT_SECRET=\$(openssl rand -base64 32)
|
||||
echo "Generated JWT_SECRET: \$JWT_SECRET"
|
||||
echo "IMPORTANT: Save this secret if you need to restart the container!"
|
||||
fi
|
||||
|
||||
# Run migrations
|
||||
npm run db:migrate
|
||||
|
||||
# Create admin user if needed
|
||||
if [ "\$CREATE_ADMIN_USER" = "true" ]; then
|
||||
npm run db:seed-admin || true
|
||||
fi
|
||||
|
||||
# Start Next.js
|
||||
exec npm start
|
||||
EOF
|
||||
|
||||
RUN chmod +x /start.sh
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3000 5432
|
||||
|
||||
# Environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV DATABASE_URL=postgresql://docker:docker@localhost:5432/postgres
|
||||
ENV CREATE_ADMIN_USER=true
|
||||
ENV ADMIN_USERNAME=admin
|
||||
ENV ADMIN_PASSWORD=admin123
|
||||
# Note: JWT_SECRET will be auto-generated if not provided
|
||||
|
||||
# Set the default command
|
||||
CMD ["/start.sh"]
|
||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres-admin:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
- '5432:5432'
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://docker:docker@localhost:5432/postgres
|
||||
- JWT_SECRET=your-secret-key-change-in-production
|
||||
- CREATE_ADMIN_USER=true
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=admin123
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/15/main
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
8
migrations/0001_swift_namorita.sql
Normal file
8
migrations/0001_swift_namorita.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "admin_users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"password_hash" varchar(255) NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "admin_users_username_unique" UNIQUE("username")
|
||||
);
|
||||
112
migrations/meta/0001_snapshot.json
Normal file
112
migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"id": "956a8899-4123-4162-8c04-e6fa18912e16",
|
||||
"prevId": "0896e842-e142-406c-99b2-a602f7fa8731",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.admin_users": {
|
||||
"name": "admin_users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"admin_users_username_unique": {
|
||||
"name": "admin_users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.counter": {
|
||||
"name": "counter",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"count": {
|
||||
"name": "count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1745518076143,
|
||||
"tag": "0000_init-db",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1767834503489,
|
||||
"tag": "0001_swift_namorita",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
1249
package-lock.json
generated
1249
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -29,6 +29,8 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "dotenv -c -- drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed-admin": "tsx scripts/seed-admin.ts",
|
||||
"generate:password": "tsx scripts/generate-password.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:test": "vitest run --config .storybook/vitest.config.mts",
|
||||
"build-storybook": "storybook build"
|
||||
@@ -37,11 +39,18 @@
|
||||
"@arcjet/next": "^1.0.0-beta.15",
|
||||
"@clerk/localizations": "^3.32.1",
|
||||
"@clerk/nextjs": "^6.36.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@logtape/logtape": "^1.3.5",
|
||||
"@mui/icons-material": "^7.3.6",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@sentry/nextjs": "^10.32.1",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"jose": "^6.1.3",
|
||||
"next": "^16.1.1",
|
||||
"next-intl": "^4.6.1",
|
||||
"pg": "^8.16.3",
|
||||
@@ -101,6 +110,7 @@
|
||||
"semantic-release": "^25.0.2",
|
||||
"storybook": "^10.1.4",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.15",
|
||||
|
||||
63
scripts/generate-password.ts
Normal file
63
scripts/generate-password.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Generate a secure random password
|
||||
* @param length - Length of the password (default: 32)
|
||||
* @param includeSpecial - Include special characters (default: true)
|
||||
*/
|
||||
function generateSecurePassword(length = 32, includeSpecial = true): string {
|
||||
let charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
if (includeSpecial) {
|
||||
charset += '!@#$%^&*()-_=+[]{}|;:,.<>?';
|
||||
}
|
||||
|
||||
const randomBytes = crypto.randomBytes(length);
|
||||
let password = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const byte = randomBytes[i];
|
||||
if (byte !== undefined) {
|
||||
password += charset[byte % charset.length];
|
||||
}
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const length = args[0] ? Number.parseInt(args[0], 10) : 32;
|
||||
const includeSpecial = args[1] !== 'false';
|
||||
|
||||
if (Number.isNaN(length) || length < 8) {
|
||||
console.error('Error: Password length must be at least 8 characters');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (length > 128) {
|
||||
console.error('Error: Password length cannot exceed 128 characters');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const password = generateSecurePassword(length, includeSpecial);
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('🔐 Secure Password Generated');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`Password: ${password}`);
|
||||
console.log(`Length: ${password.length} characters`);
|
||||
console.log(`Special characters: ${includeSpecial ? 'Yes' : 'No'}`);
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('⚠️ Save this password securely!');
|
||||
console.log('');
|
||||
console.log('💡 Usage examples:');
|
||||
console.log(` ADMIN_PASSWORD="${password}" npm run db:seed-admin`);
|
||||
console.log(` export JWT_SECRET="${password}"`);
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
}
|
||||
|
||||
export { generateSecurePassword };
|
||||
53
scripts/seed-admin.ts
Normal file
53
scripts/seed-admin.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { adminUserSchema } from '../src/models/Schema';
|
||||
import { generateSecurePassword } from './generate-password';
|
||||
|
||||
async function seedAdminUser() {
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
const db = drizzle(pool);
|
||||
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
|
||||
// Generate secure password if not provided
|
||||
let password = process.env.ADMIN_PASSWORD;
|
||||
let passwordGenerated = false;
|
||||
|
||||
if (!password) {
|
||||
password = generateSecurePassword(32);
|
||||
passwordGenerated = true;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
try {
|
||||
await db.insert(adminUserSchema).values({
|
||||
username,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
console.log('✅ Admin user created successfully!');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`📧 Username: ${username}`);
|
||||
console.log(`🔑 Password: ${password}`);
|
||||
if (passwordGenerated) {
|
||||
console.log('⚠️ This password was auto-generated. Save it securely!');
|
||||
}
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('🌐 Login at: http://localhost:3000/admin/login');
|
||||
} catch (error: any) {
|
||||
if (error.code === '23505') {
|
||||
console.log('ℹ️ Admin user already exists');
|
||||
} else {
|
||||
console.error('❌ Error creating admin user:', error);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
seedAdminUser();
|
||||
335
src/app/admin/dashboard/page.tsx
Normal file
335
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import {
|
||||
Alert,
|
||||
AppBar,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { theme } from '@/utils/theme';
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
type TabPanelProps = {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`tabpanel-${index}`}
|
||||
aria-labelledby={`tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<string>('');
|
||||
const [queryText, setQueryText] = useState('');
|
||||
const [queryResult, setQueryResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchTables = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/tables');
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to fetch tables');
|
||||
}
|
||||
const data = await response.json();
|
||||
setTables(data.tables);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTables();
|
||||
}, [fetchTables]);
|
||||
|
||||
const handleTableClick = async (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setQueryResult(null);
|
||||
|
||||
try {
|
||||
// Use dedicated API with table name validation
|
||||
const response = await fetch('/api/admin/table-data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ tableName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Query failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setQueryResult(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuerySubmit = async () => {
|
||||
if (!queryText.trim()) {
|
||||
setError('Please enter a query');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setQueryResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query: queryText }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Query failed');
|
||||
}
|
||||
|
||||
setQueryResult(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/admin/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
router.push('/admin/login');
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{ zIndex: theme => theme.zIndex.drawer + 1 }}
|
||||
>
|
||||
<Toolbar>
|
||||
<StorageIcon sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
Postgres Admin Panel
|
||||
</Typography>
|
||||
<Button color="inherit" onClick={handleLogout} startIcon={<LogoutIcon />}>
|
||||
Logout
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
'width': DRAWER_WIDTH,
|
||||
'flexShrink': 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => setTabValue(0)}>
|
||||
<ListItemIcon>
|
||||
<StorageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Tables" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => setTabValue(1)}>
|
||||
<ListItemIcon>
|
||||
<CodeIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="SQL Query" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
bgcolor: 'background.default',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Database Tables
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ mt: 2, mb: 2 }}>
|
||||
<List>
|
||||
{tables.map(table => (
|
||||
<ListItem key={table.table_name} disablePadding>
|
||||
<ListItemButton onClick={() => handleTableClick(table.table_name)}>
|
||||
<ListItemIcon>
|
||||
<StorageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={table.table_name} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
{selectedTable && (
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Table:
|
||||
{' '}
|
||||
{selectedTable}
|
||||
</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
SQL Query Interface
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={6}
|
||||
label="SQL Query (SELECT only)"
|
||||
variant="outlined"
|
||||
value={queryText}
|
||||
onChange={e => setQueryText(e.target.value)}
|
||||
placeholder="SELECT * FROM your_table LIMIT 10;"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleQuerySubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Execute Query'}
|
||||
</Button>
|
||||
</Paper>
|
||||
</TabPanel>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{queryResult && !loading && (
|
||||
<Paper sx={{ mt: 2, overflow: 'auto' }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Rows returned:
|
||||
{' '}
|
||||
{queryResult.rowCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{queryResult.fields?.map((field: any) => (
|
||||
<TableCell key={field.name}>
|
||||
<strong>{field.name}</strong>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{queryResult.rows?.map((row: any, idx: number) => (
|
||||
<TableRow key={idx}>
|
||||
{queryResult.fields?.map((field: any) => (
|
||||
<TableCell key={field.name}>
|
||||
{row[field.name] !== null
|
||||
? String(row[field.name])
|
||||
: 'NULL'}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
18
src/app/admin/layout.tsx
Normal file
18
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Postgres Admin Panel',
|
||||
description: 'Web-based PostgreSQL admin interface',
|
||||
};
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
154
src/app/admin/login/page.tsx
Normal file
154
src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { theme } from '@/utils/theme';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Login failed');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to admin dashboard
|
||||
router.push('/admin/dashboard');
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError('An error occurred. Please try again.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<LockOutlinedIcon sx={{ color: 'white', fontSize: 32 }} />
|
||||
</Box>
|
||||
|
||||
<Typography component="h1" variant="h4" gutterBottom>
|
||||
Postgres Admin
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Sign in to access the database admin panel
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Sign In'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
60
src/app/api/admin/login/route.ts
Normal file
60
src/app/api/admin/login/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { verifyPassword } from '@/utils/auth';
|
||||
import { adminUserSchema, db } from '@/utils/db';
|
||||
import { createSession, setSessionCookie } from '@/utils/session';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Username and password are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find user
|
||||
const users = await db
|
||||
.select()
|
||||
.from(adminUserSchema)
|
||||
.where(eq(adminUserSchema.username, username))
|
||||
.limit(1);
|
||||
|
||||
const user = users[0];
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create session
|
||||
const token = await createSession({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
await setSessionCookie(token);
|
||||
|
||||
return NextResponse.json({ success: true, username: user.username });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/app/api/admin/logout/route.ts
Normal file
15
src/app/api/admin/logout/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { clearSession } from '@/utils/session';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await clearSession();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
72
src/app/api/admin/query/route.ts
Normal file
72
src/app/api/admin/query/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/utils/db';
|
||||
import { getSession } from '@/utils/session';
|
||||
|
||||
// Validate that query is a safe SELECT statement
|
||||
function validateSelectQuery(query: string): boolean {
|
||||
const trimmed = query.trim();
|
||||
|
||||
// Remove leading comments and whitespace
|
||||
const noComments = trimmed.replace(/^(?:--[^\n]*\n|\s)+/g, '');
|
||||
|
||||
// Check if it starts with SELECT (case insensitive)
|
||||
if (!/^select\s/i.test(noComments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for dangerous keywords (case insensitive)
|
||||
// Includes common SQL modification commands and advanced features
|
||||
const dangerous = /;\s*(?:drop|delete|update|insert|alter|create|truncate|exec|execute|merge|call|with)\s/i;
|
||||
if (dangerous.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { query } = await request.json();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Query is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate query
|
||||
if (!validateSelectQuery(query)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only SELECT queries are allowed. No modification queries (INSERT, UPDATE, DELETE, DROP, etc.) permitted.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.execute(query);
|
||||
|
||||
return NextResponse.json({
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
fields: result.fields.map(field => ({
|
||||
name: field.name,
|
||||
dataTypeID: field.dataTypeID,
|
||||
})),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Query error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Query failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
61
src/app/api/admin/table-data/route.ts
Normal file
61
src/app/api/admin/table-data/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/utils/db';
|
||||
import { getSession } from '@/utils/session';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { tableName } = await request.json();
|
||||
|
||||
if (!tableName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate table name against schema to prevent SQL injection
|
||||
const tablesResult = await db.execute(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ${tableName}
|
||||
`);
|
||||
|
||||
if (tablesResult.rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table not found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Table name is validated against schema - safe to use the validated name
|
||||
// The validation query above ensures the table exists in the public schema
|
||||
const validatedTableName = (tablesResult.rows[0] as any).table_name;
|
||||
const result = await db.execute(sql.raw(`SELECT * FROM "${validatedTableName}" LIMIT 100`));
|
||||
|
||||
return NextResponse.json({
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
fields: result.fields?.map(field => ({
|
||||
name: field.name,
|
||||
dataTypeID: field.dataTypeID,
|
||||
})) || [],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Table query error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Query failed' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/admin/tables/route.ts
Normal file
31
src/app/api/admin/tables/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/utils/db';
|
||||
import { getSession } from '@/utils/session';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.execute(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
return NextResponse.json({ tables: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tables:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch tables' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { integer, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
// This file defines the structure of your database tables using the Drizzle ORM.
|
||||
|
||||
@@ -22,3 +22,14 @@ export const counterSchema = pgTable('counter', {
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const adminUserSchema = pgTable('admin_users', {
|
||||
id: serial('id').primaryKey(),
|
||||
username: varchar('username', { length: 255 }).unique().notNull(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'date' })
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
10
src/proxy.ts
10
src/proxy.ts
@@ -20,6 +20,10 @@ const isAuthPage = createRouteMatcher([
|
||||
'/:locale/sign-up(.*)',
|
||||
]);
|
||||
|
||||
// Admin routes that should bypass i18n routing
|
||||
const ADMIN_ROUTE_PREFIX = '/admin';
|
||||
const ADMIN_API_PREFIX = '/api/admin';
|
||||
|
||||
// Improve security with Arcjet
|
||||
const aj = arcjet.withRule(
|
||||
detectBot({
|
||||
@@ -38,6 +42,12 @@ export default async function proxy(
|
||||
request: NextRequest,
|
||||
event: NextFetchEvent,
|
||||
) {
|
||||
// Skip i18n routing for admin and API routes
|
||||
if (request.nextUrl.pathname.startsWith(ADMIN_ROUTE_PREFIX)
|
||||
|| request.nextUrl.pathname.startsWith(ADMIN_API_PREFIX)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Verify the request with Arcjet
|
||||
// Use `process.env` instead of Env to reduce bundle size in middleware
|
||||
if (process.env.ARCJET_KEY) {
|
||||
|
||||
14
src/utils/auth.ts
Normal file
14
src/utils/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
11
src/utils/db.ts
Normal file
11
src/utils/db.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { adminUserSchema } from '@/models/Schema';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
export const db = drizzle(pool);
|
||||
|
||||
export { adminUserSchema };
|
||||
64
src/utils/session.ts
Normal file
64
src/utils/session.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { jwtVerify, SignJWT } from 'jose';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const SESSION_COOKIE_NAME = 'admin-session';
|
||||
|
||||
// Get JWT secret and throw error if not provided
|
||||
function getJwtSecret(): Uint8Array {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
const JWT_SECRET = getJwtSecret();
|
||||
|
||||
export type SessionData = {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export async function createSession(data: SessionData): Promise<string> {
|
||||
const token = await new SignJWT(data)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('24h')
|
||||
.sign(JWT_SECRET);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function setSessionCookie(token: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionData | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as SessionData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearSession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||
}
|
||||
26
src/utils/theme.ts
Normal file
26
src/utils/theme.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user