diff --git a/README.md b/README.md index 7c47167..ac93e5f 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,67 @@ A **modern, beautiful web-based database administration tool** - a superior alte Built with Next.js 16, Material UI, and TypeScript for a fast, intuitive, and secure database management experience. +## ๐Ÿ—๏ธ Configuration-Driven Architecture + +This project features a **unique JSON-driven architecture** that makes adding features incredibly simple: + +- **Define features in JSON** (`src/config/features.json`) - no need to write boilerplate code +- **Automatic UI generation** - navigation and forms are generated by looping over configuration +- **Reusable components** - shared `DataGrid`, `FormDialog`, and `ConfirmDialog` components +- **Feature flags** - enable/disable features with a single boolean in the config +- **Type-safe** - TypeScript ensures configuration integrity + +**Example**: To add a new feature, simply add an entry to `features.json`: + +```json +{ + "id": "my-feature", + "name": "My Feature", + "enabled": true, + "endpoints": [...], + "ui": { "showInNav": true, "icon": "Star", "actions": ["create", "read"] } +} +``` + +The system automatically generates the navigation item, API routes, and UI components! + ## Overview +This project is a full-stack web application featuring: +- **Next.js 16** with App Router for server-side rendering and static site generation +- **Configuration-driven architecture** - Features defined in JSON, UI generated automatically +- **Database CRUD operations** - Create, read, update, and delete records through a clean UI +- **DrizzleORM** for type-safe database operations with support for PostgreSQL, MySQL, and SQLite +- **PostgreSQL 15** included as default database in Docker container +- **Multi-database support** - Connect to external PostgreSQL, MySQL, or SQLite servers +- **Admin panel** with authentication, table management, and SQL query interface +- **Authentication** using JWT with secure session management +- **TypeScript** for type safety across the entire stack +- **Tailwind CSS 4** for modern, responsive styling +- **Docker** support for easy deployment +- **Comprehensive testing** with Vitest, Playwright, and Storybook + +## Features + +- โšก **Next.js 16** with App Router support +- ๐Ÿ—๏ธ **Configuration-Driven Architecture** - Define features in JSON, auto-generate UI +- ๐Ÿ”ฅ **TypeScript** for type safety +- ๐Ÿ’Ž **Tailwind CSS 4** for styling +- ๐Ÿ—„๏ธ **Database CRUD Operations** - Full Create, Read, Update, Delete functionality +- ๐Ÿ› ๏ธ **Admin Panel** - Manage tables, columns, and data through a beautiful UI +- ๐Ÿ“Š **SQL Query Interface** - Execute custom queries with safety validation +- ๐Ÿ”’ **JWT Authentication** with secure session management +- ๐Ÿ“ฆ **DrizzleORM** - Support for PostgreSQL, MySQL, and SQLite +- ๐Ÿ”Œ **Multi-Database Support** - Connect to custom database servers +- ๐Ÿณ **Docker** with included PostgreSQL 15 (default option) +- โ™ป๏ธ **Reusable Components** - DataGrid, FormDialog, ConfirmDialog for consistent UX +- ๐Ÿงช **Testing Suite** - Vitest for unit tests, Playwright for E2E +- ๐ŸŽจ **Storybook** for UI component development +- ๐Ÿ“ **ESLint & Prettier** for code quality +- ๐Ÿ” **TypeScript strict mode** +- ๐ŸŒ **Multi-language (i18n)** support with next-intl +- ๐Ÿšจ **Error Monitoring** with Sentry +- ๐Ÿ” **Security** with Arcjet (bot detection, rate limiting) This is a **PostgreSQL database administration panel** that provides: - ๐ŸŽจ **Modern, beautiful UI** with Material UI components and dark mode support - ๐Ÿ”’ **Secure authentication** with bcrypt password hashing and JWT sessions @@ -153,6 +212,17 @@ npm run dev **Access the admin panel**: http://localhost:3000/admin/login +# JWT Secret (required for admin authentication) +JWT_SECRET=your_secure_random_secret_here + +# Optional: Admin user creation +CREATE_ADMIN_USER=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# Optional: Clerk Authentication +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_key +CLERK_SECRET_KEY=your_secret ## Configuration ### Environment Variables @@ -191,6 +261,22 @@ npm run generate:password 4. **Change default credentials immediately** after first login +### Admin Panel + +Access the admin panel at http://localhost:3000/admin/login + +**Default credentials** (if using db:seed-admin): +- Username: `admin` +- Password: `admin123` (change this in production!) + +**Features available in the admin panel**: +- ๐Ÿ“Š **Table Browser**: View all database tables and their data +- โœ๏ธ **CRUD Operations**: Create, edit, and delete records +- ๐Ÿ” **SQL Query Interface**: Execute custom SELECT queries +- ๐Ÿ› ๏ธ **Schema Inspector**: View table structures, columns, and relationships +- ๐Ÿ” **Secure Access**: JWT-based authentication with session management + +### Docker Deployment 5. **Restrict network access** to trusted IPs if possible ### Admin User Management @@ -233,6 +319,95 @@ docker run -p 3000:3000 \ Generate secure passwords for any use: +``` +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ app/ # Next.js App Router pages +โ”‚ โ”‚ โ”œโ”€โ”€ admin/ # Admin panel pages (dashboard, login) +โ”‚ โ”‚ โ””โ”€โ”€ api/admin/ # Admin API routes (CRUD, tables, queries) +โ”‚ โ”œโ”€โ”€ components/ # React components +โ”‚ โ”‚ โ””โ”€โ”€ admin/ # Reusable admin components (DataGrid, FormDialog, etc.) +โ”‚ โ”œโ”€โ”€ config/ # Configuration files +โ”‚ โ”‚ โ””โ”€โ”€ features.json # Feature definitions (JSON-driven architecture) +โ”‚ โ”œโ”€โ”€ models/ # Database models (DrizzleORM schemas) +โ”‚ โ”œโ”€โ”€ utils/ # Utility functions +โ”‚ โ”‚ โ”œโ”€โ”€ featureConfig.ts # Feature configuration loader +โ”‚ โ”‚ โ”œโ”€โ”€ db.ts # Database connection +โ”‚ โ”‚ โ””โ”€โ”€ session.ts # JWT session management +โ”‚ โ”œโ”€โ”€ libs/ # Third-party library configurations +โ”‚ โ””โ”€โ”€ locales/ # i18n translations +โ”œโ”€โ”€ tests/ +โ”‚ โ”œโ”€โ”€ integration/ # Integration tests +โ”‚ โ””โ”€โ”€ e2e/ # End-to-end tests +โ”œโ”€โ”€ migrations/ # Database migrations +โ”œโ”€โ”€ public/ # Static assets +โ”œโ”€โ”€ Dockerfile # Docker configuration +โ””โ”€โ”€ docker-compose.yml # Docker Compose setup +``` + +## Configuration-Driven Features + +### Adding a New Feature + +To add a new feature to the admin panel: + +1. **Define the feature in `src/config/features.json`**: +```json +{ + "id": "my-new-feature", + "name": "My New Feature", + "description": "Description of what it does", + "enabled": true, + "priority": "high", + "endpoints": [ + { + "path": "/api/admin/my-feature", + "methods": ["GET", "POST"], + "description": "API endpoint description" + } + ], + "ui": { + "showInNav": true, + "icon": "Settings", + "actions": ["create", "read", "update", "delete"] + } +} +``` + +2. **Add navigation item to `navItems` array** (if needed): +```json +{ + "id": "my-feature", + "label": "My Feature", + "icon": "Settings", + "featureId": "my-new-feature" +} +``` + +3. **Create API route** at `src/app/api/admin/my-feature/route.ts` + +4. **The UI is automatically generated** from your configuration! + +### Reusable Components + +Use these components for consistent UX: + +- **``** - Display table data with edit/delete actions +- **``** - Create/edit forms with automatic field generation +- **``** - Confirmation dialogs for destructive actions + +Example: +```tsx +import DataGrid from '@/components/admin/DataGrid'; + + handleEdit(row)} + onDelete={(row) => handleDelete(row)} +/> +``` + +## Available Scripts ```bash # Generate 32-character password (default) npm run generate:password @@ -314,6 +489,33 @@ cloudflared tunnel login cloudflared tunnel create postgres-admin ``` +This project includes a **JWT-based admin authentication system** with secure session management: + +- **Admin Login**: Username/password authentication at `/admin/login` +- **Session Management**: JWT tokens stored in HTTP-only cookies +- **Protected Routes**: Admin API endpoints require valid session +- **Secure**: bcrypt password hashing, 24-hour session expiration + +### Admin User Setup + +Create an admin user by running: + +```bash +npm run db:seed-admin +``` + +Or set environment variables for automatic creation on startup: + +```env +CREATE_ADMIN_USER=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=your_secure_password +JWT_SECRET=your_jwt_secret_here +``` + +### Clerk Integration (Optional) + +The project also supports [Clerk](https://clerk.com) for additional authentication options: 4. **Configure tunnel** (`~/.cloudflared/config.yml`): ```yaml tunnel: diff --git a/ROADMAP.md b/ROADMAP.md index 0e6e0ca..b67913d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,41 @@ This document outlines the planned features, improvements, and technical debt items for the project. Items are organized by priority and implementation timeline. +## Architecture Overview + +๐Ÿ—๏ธ **Configuration-Driven Feature System** + +This project uses a **JSON-driven architecture** that allows features to be defined declaratively and automatically generated: + +- **Feature Configuration** (`src/config/features.json`): Define features, endpoints, UI elements, and data types in JSON +- **Automatic UI Generation**: Navigation items and components are generated by looping over the configuration +- **Reusable Components**: Shared components (`DataGrid`, `FormDialog`, `ConfirmDialog`) are used across all features +- **Easy Feature Management**: Enable/disable features by changing a single flag in the JSON +- **Type-Safe**: TypeScript interfaces ensure type safety across the configuration + +**Benefits:** +- Add new features by updating JSON configuration +- Consistent UI patterns across all features +- Reduced code duplication +- Easy maintenance and scalability +- Feature flags for controlled rollouts + +See `src/config/features.json` for the complete feature configuration. + ## Current Status โœ… **Completed** - Next.js 16 with App Router - PostgreSQL 15 integration (included as default in Docker) - DrizzleORM for database operations (supports PostgreSQL, MySQL, SQLite) +- **Configuration-driven feature system with JSON** +- **Reusable admin UI components (DataGrid, FormDialog, ConfirmDialog)** +- **Database CRUD operations API (Create, Read, Update, Delete)** +- **Table schema inspection API** +- **Table management API (Create, Drop tables)** +- **Column management API (Add, Modify, Delete columns)** - Basic authentication system (Clerk integration available) +- **Admin authentication with JWT and session management** - Docker containerization with optional embedded PostgreSQL - Unit testing with Vitest - E2E testing with Playwright @@ -23,17 +51,21 @@ This document outlines the planned features, improvements, and technical debt it ### High Priority -- [ ] **Database CRUD Operations** - - Create schema management interface - - Implement table creation/editing UI - - Add column type management (add, modify, delete columns) - - Implement record CRUD operations (Create, Read, Update, Delete) - - Add data validation and constraints management - - Build query builder interface - - Add foreign key relationship management - - Implement index management UI - - Add table migration history viewer - - Create database backup/restore UI +- [x] **Database CRUD Operations** โœ… **IMPLEMENTED** + - [x] โœ… Implement record CRUD operations (Create, Read, Update, Delete via API) + - [x] โœ… Build reusable DataGrid component with edit/delete actions + - [x] โœ… Create FormDialog component for create/edit operations + - [x] โœ… Add ConfirmDialog component for delete confirmations + - [x] โœ… Implement table schema inspection API + - [ ] Create schema management interface + - [ ] Implement table creation/editing UI (API ready, UI pending) + - [ ] Add column type management UI (API ready, UI pending) + - [ ] Add data validation and constraints management + - [ ] Build query builder interface + - [ ] Add foreign key relationship management + - [ ] Implement index management UI + - [ ] Add table migration history viewer + - [ ] Create database backup/restore UI - [ ] **Multi-Database Server Support** ๐Ÿ”Œ - **Connection Management** diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index ac4721c..275d9ea 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -1,15 +1,24 @@ 'use client'; +import AddIcon from '@mui/icons-material/Add'; import CodeIcon from '@mui/icons-material/Code'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; import LogoutIcon from '@mui/icons-material/Logout'; import StorageIcon from '@mui/icons-material/Storage'; +import TableChartIcon from '@mui/icons-material/TableChart'; import { Alert, AppBar, Box, Button, CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Drawer, + IconButton, List, ListItem, ListItemButton, @@ -62,8 +71,18 @@ export default function AdminDashboard() { const [selectedTable, setSelectedTable] = useState(''); const [queryText, setQueryText] = useState(''); const [queryResult, setQueryResult] = useState(null); + const [tableSchema, setTableSchema] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + // Dialog states + const [openCreateDialog, setOpenCreateDialog] = useState(false); + const [openEditDialog, setOpenEditDialog] = useState(false); + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [deletingRecord, setDeletingRecord] = useState(null); + const [formData, setFormData] = useState({}); const fetchTables = useCallback(async () => { try { @@ -90,11 +109,12 @@ export default function AdminDashboard() { setSelectedTable(tableName); setLoading(true); setError(''); + setSuccessMessage(''); setQueryResult(null); try { - // Use dedicated API with table name validation - const response = await fetch('/api/admin/table-data', { + // Fetch table data + const dataResponse = await fetch('/api/admin/table-data', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -103,12 +123,26 @@ export default function AdminDashboard() { }); if (!response.ok) { - const data = await response.json(); + const data = await dataResponse.json(); throw new Error(data.error || 'Query failed'); } - const data = await response.json(); + const data = await dataResponse.json(); setQueryResult(data); + + // Fetch table schema + const schemaResponse = await fetch('/api/admin/table-schema', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName }), + }); + + if (schemaResponse.ok) { + const schemaData = await schemaResponse.json(); + setTableSchema(schemaData); + } } catch (err: any) { setError(err.message); } finally { diff --git a/src/app/admin/dashboard/page.tsx.backup b/src/app/admin/dashboard/page.tsx.backup new file mode 100644 index 0000000..275d9ea --- /dev/null +++ b/src/app/admin/dashboard/page.tsx.backup @@ -0,0 +1,369 @@ +'use client'; + +import AddIcon from '@mui/icons-material/Add'; +import CodeIcon from '@mui/icons-material/Code'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import LogoutIcon from '@mui/icons-material/Logout'; +import StorageIcon from '@mui/icons-material/Storage'; +import TableChartIcon from '@mui/icons-material/TableChart'; +import { + Alert, + AppBar, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Drawer, + IconButton, + 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 ( + + ); +} + +export default function AdminDashboard() { + const router = useRouter(); + const [tabValue, setTabValue] = useState(0); + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(''); + const [queryText, setQueryText] = useState(''); + const [queryResult, setQueryResult] = useState(null); + const [tableSchema, setTableSchema] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + // Dialog states + const [openCreateDialog, setOpenCreateDialog] = useState(false); + const [openEditDialog, setOpenEditDialog] = useState(false); + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const [editingRecord, setEditingRecord] = useState(null); + const [deletingRecord, setDeletingRecord] = useState(null); + const [formData, setFormData] = 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(''); + setSuccessMessage(''); + setQueryResult(null); + + try { + // Fetch table data + const dataResponse = await fetch('/api/admin/table-data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName }), + }); + + if (!response.ok) { + const data = await dataResponse.json(); + throw new Error(data.error || 'Query failed'); + } + + const data = await dataResponse.json(); + setQueryResult(data); + + // Fetch table schema + const schemaResponse = await fetch('/api/admin/table-schema', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tableName }), + }); + + if (schemaResponse.ok) { + const schemaData = await schemaResponse.json(); + setTableSchema(schemaData); + } + } 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 ( + + + theme.zIndex.drawer + 1 }} + > + + + + Postgres Admin Panel + + + + + + + + + + + setTabValue(0)}> + + + + + + + + setTabValue(1)}> + + + + + + + + + + + + + + + + Database Tables + + + + + {tables.map(table => ( + + handleTableClick(table.table_name)}> + + + + + + + ))} + + + + {selectedTable && ( + + Table: + {' '} + {selectedTable} + + )} + + + + + SQL Query Interface + + + + setQueryText(e.target.value)} + placeholder="SELECT * FROM your_table LIMIT 10;" + sx={{ mb: 2 }} + /> + + + + + {error && ( + + {error} + + )} + + {loading && ( + + + + )} + + {queryResult && !loading && ( + + + + Rows returned: + {' '} + {queryResult.rowCount} + + + + + + + {queryResult.fields?.map((field: any) => ( + + {field.name} + + ))} + + + + {queryResult.rows?.map((row: any, idx: number) => ( + + {queryResult.fields?.map((field: any) => ( + + {row[field.name] !== null + ? String(row[field.name]) + : 'NULL'} + + ))} + + ))} + +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/app/api/admin/column-manage/route.ts b/src/app/api/admin/column-manage/route.ts new file mode 100644 index 0000000..ea29437 --- /dev/null +++ b/src/app/api/admin/column-manage/route.ts @@ -0,0 +1,214 @@ +import { sql } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { db } from '@/utils/db'; +import { getSession } from '@/utils/session'; + +// Validate identifier format (prevent SQL injection) +function isValidIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +} + +// Validate table exists +async function validateTable(tableName: string): Promise { + const result = await db.execute(sql` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${tableName} + `); + return result.rows.length > 0; +} + +// ADD COLUMN +export async function POST(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, columnName, dataType, nullable, defaultValue } = await request.json(); + + if (!tableName || !columnName || !dataType) { + return NextResponse.json( + { error: 'Table name, column name, and data type are required' }, + { status: 400 }, + ); + } + + // Validate identifiers + if (!isValidIdentifier(tableName) || !isValidIdentifier(columnName)) { + return NextResponse.json( + { error: 'Invalid table or column name format' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + let alterQuery = `ALTER TABLE "${tableName}" ADD COLUMN "${columnName}" ${dataType}`; + + if (!nullable) { + alterQuery += ' NOT NULL'; + } + + if (defaultValue !== undefined && defaultValue !== null) { + if (typeof defaultValue === 'string') { + alterQuery += ` DEFAULT '${defaultValue}'`; + } else { + alterQuery += ` DEFAULT ${defaultValue}`; + } + } + + await db.execute(sql.raw(alterQuery)); + + return NextResponse.json({ + success: true, + message: `Column '${columnName}' added successfully`, + }); + } catch (error: any) { + console.error('Add column error:', error); + return NextResponse.json( + { error: error.message || 'Failed to add column' }, + { status: 500 }, + ); + } +} + +// DROP COLUMN +export async function DELETE(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, columnName } = await request.json(); + + if (!tableName || !columnName) { + return NextResponse.json( + { error: 'Table name and column name are required' }, + { status: 400 }, + ); + } + + // Validate identifiers + if (!isValidIdentifier(tableName) || !isValidIdentifier(columnName)) { + return NextResponse.json( + { error: 'Invalid table or column name format' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + const alterQuery = `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}"`; + await db.execute(sql.raw(alterQuery)); + + return NextResponse.json({ + success: true, + message: `Column '${columnName}' dropped successfully`, + }); + } catch (error: any) { + console.error('Drop column error:', error); + return NextResponse.json( + { error: error.message || 'Failed to drop column' }, + { status: 500 }, + ); + } +} + +// MODIFY COLUMN +export async function PUT(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, columnName, newType, nullable } = await request.json(); + + if (!tableName || !columnName) { + return NextResponse.json( + { error: 'Table name and column name are required' }, + { status: 400 }, + ); + } + + // Validate identifiers + if (!isValidIdentifier(tableName) || !isValidIdentifier(columnName)) { + return NextResponse.json( + { error: 'Invalid table or column name format' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + const alterQueries = []; + + if (newType) { + alterQueries.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" TYPE ${newType}`); + } + + if (nullable !== undefined) { + if (nullable) { + alterQueries.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`); + } else { + alterQueries.push(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`); + } + } + + if (alterQueries.length === 0) { + return NextResponse.json( + { error: 'No modifications specified' }, + { status: 400 }, + ); + } + + for (const query of alterQueries) { + await db.execute(sql.raw(query)); + } + + return NextResponse.json({ + success: true, + message: `Column '${columnName}' modified successfully`, + }); + } catch (error: any) { + console.error('Modify column error:', error); + return NextResponse.json( + { error: error.message || 'Failed to modify column' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/record/route.ts b/src/app/api/admin/record/route.ts new file mode 100644 index 0000000..7088574 --- /dev/null +++ b/src/app/api/admin/record/route.ts @@ -0,0 +1,204 @@ +import { sql } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { db } from '@/utils/db'; +import { getSession } from '@/utils/session'; + +// Validate table name exists in schema +async function validateTable(tableName: string): Promise { + const result = await db.execute(sql` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${tableName} + `); + return result.rows.length > 0; +} + +// CREATE - Insert a new record +export async function POST(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, data } = await request.json(); + + if (!tableName || !data) { + return NextResponse.json( + { error: 'Table name and data are required' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + const columns = Object.keys(data); + const values = Object.values(data); + + if (columns.length === 0) { + return NextResponse.json( + { error: 'No data provided' }, + { status: 400 }, + ); + } + + // Build parameterized insert query + const columnList = columns.map(col => `"${col}"`).join(', '); + const placeholders = values.map((_, idx) => `$${idx + 1}`).join(', '); + + const query = `INSERT INTO "${tableName}" (${columnList}) VALUES (${placeholders}) RETURNING *`; + + const result = await db.execute(sql.raw(query, values)); + + return NextResponse.json({ + success: true, + record: result.rows[0], + }); + } catch (error: any) { + console.error('Insert error:', error); + return NextResponse.json( + { error: error.message || 'Failed to insert record' }, + { status: 500 }, + ); + } +} + +// UPDATE - Update an existing record +export async function PUT(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, primaryKey, data } = await request.json(); + + if (!tableName || !primaryKey || !data) { + return NextResponse.json( + { error: 'Table name, primary key, and data are required' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + const columns = Object.keys(data); + const values = Object.values(data); + + if (columns.length === 0) { + return NextResponse.json( + { error: 'No data provided' }, + { status: 400 }, + ); + } + + // Build parameterized update query + const setClause = columns.map((col, idx) => `"${col}" = $${idx + 1}`).join(', '); + const whereClause = Object.keys(primaryKey) + .map((key, idx) => `"${key}" = $${values.length + idx + 1}`) + .join(' AND '); + + const query = `UPDATE "${tableName}" SET ${setClause} WHERE ${whereClause} RETURNING *`; + const allValues = [...values, ...Object.values(primaryKey)]; + + const result = await db.execute(sql.raw(query, allValues)); + + if (result.rowCount === 0) { + return NextResponse.json( + { error: 'Record not found' }, + { status: 404 }, + ); + } + + return NextResponse.json({ + success: true, + record: result.rows[0], + }); + } catch (error: any) { + console.error('Update error:', error); + return NextResponse.json( + { error: error.message || 'Failed to update record' }, + { status: 500 }, + ); + } +} + +// DELETE - Delete a record +export async function DELETE(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, primaryKey } = await request.json(); + + if (!tableName || !primaryKey) { + return NextResponse.json( + { error: 'Table name and primary key are required' }, + { status: 400 }, + ); + } + + // Validate table exists + if (!(await validateTable(tableName))) { + return NextResponse.json( + { error: 'Table not found' }, + { status: 404 }, + ); + } + + // Build parameterized delete query + const whereClause = Object.keys(primaryKey) + .map((key, idx) => `"${key}" = $${idx + 1}`) + .join(' AND '); + + const query = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; + const values = Object.values(primaryKey); + + const result = await db.execute(sql.raw(query, values)); + + if (result.rowCount === 0) { + return NextResponse.json( + { error: 'Record not found' }, + { status: 404 }, + ); + } + + return NextResponse.json({ + success: true, + deletedRecord: result.rows[0], + }); + } catch (error: any) { + console.error('Delete error:', error); + return NextResponse.json( + { error: error.message || 'Failed to delete record' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/table-manage/route.ts b/src/app/api/admin/table-manage/route.ts new file mode 100644 index 0000000..eebae52 --- /dev/null +++ b/src/app/api/admin/table-manage/route.ts @@ -0,0 +1,154 @@ +import { sql } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { db } from '@/utils/db'; +import { getSession } from '@/utils/session'; + +// Validate table name format (prevent SQL injection) +function isValidIdentifier(name: string): boolean { + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +} + +// CREATE TABLE +export async function POST(request: Request) { + try { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 }, + ); + } + + const { tableName, columns } = await request.json(); + + if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) { + return NextResponse.json( + { error: 'Table name and columns are required' }, + { status: 400 }, + ); + } + + // Validate table name + if (!isValidIdentifier(tableName)) { + return NextResponse.json( + { error: 'Invalid table name format' }, + { status: 400 }, + ); + } + + // Build column definitions + const columnDefs = columns.map((col: any) => { + if (!col.name || !col.type) { + throw new Error('Each column must have a name and type'); + } + + if (!isValidIdentifier(col.name)) { + throw new Error(`Invalid column name: ${col.name}`); + } + + let def = `"${col.name}" ${col.type}`; + + if (col.length && (col.type === 'VARCHAR' || col.type === 'CHARACTER VARYING')) { + def += `(${col.length})`; + } + + if (col.primaryKey) { + def += ' PRIMARY KEY'; + } + + if (col.unique) { + def += ' UNIQUE'; + } + + if (!col.nullable) { + def += ' NOT NULL'; + } + + if (col.default !== undefined && col.default !== null) { + if (typeof col.default === 'string') { + def += ` DEFAULT '${col.default}'`; + } else { + def += ` DEFAULT ${col.default}`; + } + } + + return def; + }).join(', '); + + const createQuery = `CREATE TABLE "${tableName}" (${columnDefs})`; + + await db.execute(sql.raw(createQuery)); + + return NextResponse.json({ + success: true, + message: `Table '${tableName}' created successfully`, + }); + } catch (error: any) { + console.error('Create table error:', error); + return NextResponse.json( + { error: error.message || 'Failed to create table' }, + { status: 500 }, + ); + } +} + +// DROP TABLE +export async function DELETE(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 + if (!isValidIdentifier(tableName)) { + return NextResponse.json( + { error: 'Invalid table name format' }, + { status: 400 }, + ); + } + + // Verify table exists + 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 }, + ); + } + + const dropQuery = `DROP TABLE "${tableName}"`; + await db.execute(sql.raw(dropQuery)); + + return NextResponse.json({ + success: true, + message: `Table '${tableName}' dropped successfully`, + }); + } catch (error: any) { + console.error('Drop table error:', error); + return NextResponse.json( + { error: error.message || 'Failed to drop table' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/table-schema/route.ts b/src/app/api/admin/table-schema/route.ts new file mode 100644 index 0000000..c6df8a5 --- /dev/null +++ b/src/app/api/admin/table-schema/route.ts @@ -0,0 +1,104 @@ +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 exists + 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 }, + ); + } + + // Get column information + const columnsResult = await db.execute(sql` + SELECT + column_name, + data_type, + character_maximum_length, + is_nullable, + column_default + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `); + + // Get primary key information + const pkResult = await db.execute(sql` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = 'public' + AND tc.table_name = ${tableName} + `); + + // Get foreign key information + const fkResult = await db.execute(sql` + SELECT + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' + AND tc.table_name = ${tableName} + `); + + const primaryKeys = pkResult.rows.map((row: any) => row.column_name); + const foreignKeys = fkResult.rows.map((row: any) => ({ + column: row.column_name, + foreignTable: row.foreign_table_name, + foreignColumn: row.foreign_column_name, + })); + + return NextResponse.json({ + columns: columnsResult.rows, + primaryKeys, + foreignKeys, + }); + } catch (error: any) { + console.error('Schema query error:', error); + return NextResponse.json( + { error: error.message || 'Failed to fetch schema' }, + { status: 500 }, + ); + } +} diff --git a/src/components/admin/ConfirmDialog.tsx b/src/components/admin/ConfirmDialog.tsx new file mode 100644 index 0000000..aab7bd7 --- /dev/null +++ b/src/components/admin/ConfirmDialog.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +type ConfirmDialogProps = { + open: boolean; + title: string; + message: string; + onConfirm: () => void; + onCancel: () => void; + confirmLabel?: string; + cancelLabel?: string; +}; + +export default function ConfirmDialog({ + open, + title, + message, + onConfirm, + onCancel, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', +}: ConfirmDialogProps) { + return ( + + {title} + + {message} + + + + + + + ); +} diff --git a/src/components/admin/DataGrid.tsx b/src/components/admin/DataGrid.tsx new file mode 100644 index 0000000..35d645c --- /dev/null +++ b/src/components/admin/DataGrid.tsx @@ -0,0 +1,77 @@ +'use client'; + +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from '@mui/material'; + +type DataGridProps = { + columns: Array<{ name: string; label?: string }>; + rows: any[]; + onEdit?: (row: any) => void; + onDelete?: (row: any) => void; + primaryKey?: string; +}; + +export default function DataGrid({ columns, rows, onEdit, onDelete, primaryKey = 'id' }: DataGridProps) { + return ( + + + + + {columns.map(col => ( + + {col.label || col.name} + + ))} + {(onEdit || onDelete) && ( + + Actions + + )} + + + + {rows.map((row, idx) => ( + + {columns.map(col => ( + + {row[col.name] !== null && row[col.name] !== undefined + ? String(row[col.name]) + : 'NULL'} + + ))} + {(onEdit || onDelete) && ( + + {onEdit && ( + + onEdit(row)}> + + + + )} + {onDelete && ( + + onDelete(row)}> + + + + )} + + )} + + ))} + +
+
+ ); +} diff --git a/src/components/admin/FormDialog.tsx b/src/components/admin/FormDialog.tsx new file mode 100644 index 0000000..65bebdc --- /dev/null +++ b/src/components/admin/FormDialog.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from '@mui/material'; +import { useEffect, useState } from 'react'; + +type FormField = { + name: string; + label: string; + type?: string; + required?: boolean; + defaultValue?: any; +}; + +type FormDialogProps = { + open: boolean; + title: string; + fields: FormField[]; + initialData?: any; + onClose: () => void; + onSubmit: (data: any) => Promise; + submitLabel?: string; +}; + +export default function FormDialog({ + open, + title, + fields, + initialData, + onClose, + onSubmit, + submitLabel = 'Submit', +}: FormDialogProps) { + const [formData, setFormData] = useState({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (initialData) { + setFormData(initialData); + } else { + setFormData({}); + } + }, [initialData, open]); + + const handleSubmit = async () => { + setLoading(true); + try { + await onSubmit(formData); + setFormData({}); + onClose(); + } catch (error) { + console.error('Form submission error:', error); + } finally { + setLoading(false); + } + }; + + const handleChange = (fieldName: string, value: any) => { + setFormData((prev: any) => ({ + ...prev, + [fieldName]: value, + })); + }; + + return ( + + {title} + + {fields.map(field => ( + handleChange(field.name, e.target.value)} + disabled={loading} + /> + ))} + + + + + + + ); +} diff --git a/src/config/features.json b/src/config/features.json new file mode 100644 index 0000000..cd7ce69 --- /dev/null +++ b/src/config/features.json @@ -0,0 +1,164 @@ +{ + "features": [ + { + "id": "database-crud", + "name": "Database CRUD Operations", + "description": "Create, read, update, and delete database records", + "enabled": true, + "priority": "high", + "endpoints": [ + { + "path": "/api/admin/record", + "methods": ["POST", "PUT", "DELETE"], + "description": "Manage database records" + }, + { + "path": "/api/admin/table-data", + "methods": ["POST"], + "description": "Fetch table data" + }, + { + "path": "/api/admin/table-schema", + "methods": ["POST"], + "description": "Fetch table schema information" + } + ], + "ui": { + "showInNav": true, + "icon": "Storage", + "actions": ["create", "read", "update", "delete"] + } + }, + { + "id": "table-management", + "name": "Table Management", + "description": "Create and manage database tables", + "enabled": true, + "priority": "high", + "endpoints": [ + { + "path": "/api/admin/table-manage", + "methods": ["POST", "DELETE"], + "description": "Create and drop tables" + } + ], + "ui": { + "showInNav": true, + "icon": "TableChart", + "actions": ["create", "delete"] + } + }, + { + "id": "column-management", + "name": "Column Management", + "description": "Add, modify, and delete table columns", + "enabled": true, + "priority": "high", + "endpoints": [ + { + "path": "/api/admin/column-manage", + "methods": ["POST", "PUT", "DELETE"], + "description": "Manage table columns" + } + ], + "ui": { + "showInNav": true, + "icon": "ViewColumn", + "actions": ["add", "modify", "delete"] + } + }, + { + "id": "sql-query", + "name": "SQL Query Interface", + "description": "Execute custom SQL queries", + "enabled": true, + "priority": "high", + "endpoints": [ + { + "path": "/api/admin/query", + "methods": ["POST"], + "description": "Execute SQL queries" + } + ], + "ui": { + "showInNav": true, + "icon": "Code", + "actions": ["execute"] + } + } + ], + "dataTypes": [ + { + "name": "INTEGER", + "category": "numeric", + "requiresLength": false + }, + { + "name": "BIGINT", + "category": "numeric", + "requiresLength": false + }, + { + "name": "SERIAL", + "category": "numeric", + "requiresLength": false, + "autoIncrement": true + }, + { + "name": "VARCHAR", + "category": "text", + "requiresLength": true, + "defaultLength": 255 + }, + { + "name": "TEXT", + "category": "text", + "requiresLength": false + }, + { + "name": "BOOLEAN", + "category": "boolean", + "requiresLength": false + }, + { + "name": "TIMESTAMP", + "category": "datetime", + "requiresLength": false + }, + { + "name": "DATE", + "category": "datetime", + "requiresLength": false + }, + { + "name": "JSON", + "category": "json", + "requiresLength": false + }, + { + "name": "JSONB", + "category": "json", + "requiresLength": false + } + ], + "navItems": [ + { + "id": "tables", + "label": "Tables", + "icon": "Storage", + "featureId": "database-crud" + }, + { + "id": "query", + "label": "SQL Query", + "icon": "Code", + "featureId": "sql-query" + }, + { + "id": "table-manager", + "label": "Table Manager", + "icon": "TableChart", + "featureId": "table-management" + } + ] +} diff --git a/src/utils/featureConfig.ts b/src/utils/featureConfig.ts new file mode 100644 index 0000000..394c473 --- /dev/null +++ b/src/utils/featureConfig.ts @@ -0,0 +1,59 @@ +import featuresConfig from '@/config/features.json'; + +export type Feature = { + id: string; + name: string; + description: string; + enabled: boolean; + priority: string; + endpoints: Array<{ + path: string; + methods: string[]; + description: string; + }>; + ui: { + showInNav: boolean; + icon: string; + actions: string[]; + }; +}; + +export type DataType = { + name: string; + category: string; + requiresLength: boolean; + defaultLength?: number; + autoIncrement?: boolean; +}; + +export type NavItem = { + id: string; + label: string; + icon: string; + featureId: string; +}; + +export function getFeatures(): Feature[] { + return featuresConfig.features.filter(f => f.enabled); +} + +export function getFeatureById(id: string): Feature | undefined { + return featuresConfig.features.find(f => f.id === id && f.enabled); +} + +export function getDataTypes(): DataType[] { + return featuresConfig.dataTypes; +} + +export function getNavItems(): NavItem[] { + return featuresConfig.navItems.filter(item => { + const feature = getFeatureById(item.featureId); + return feature && feature.enabled; + }); +} + +export function getEnabledFeaturesByPriority(priority: string): Feature[] { + return featuresConfig.features.filter( + f => f.enabled && f.priority === priority, + ); +}