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:
2026-01-08 01:43:44 +00:00
committed by GitHub
27 changed files with 2911 additions and 115 deletions

21
.dockerignore Normal file
View 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
View File

@@ -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
View 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
View 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
View 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
View 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:

View 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")
);

View 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": {}
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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
View 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();

View 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
View 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>
);
}

View 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>
);
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View File

@@ -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(),
});

View File

@@ -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
View 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
View 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
View 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
View 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(','),
},
});