mirror of
https://github.com/rmoren97/mc-manager.git
synced 2026-02-10 17:40:30 -08:00
initial commit
This commit is contained in:
parent
df98be5555
commit
79b41409e5
213
.github/copilot-instructions.md
vendored
Normal file
213
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,213 @@
|
||||
# MC-Manager — Copilot Instructions
|
||||
|
||||
## Project Overview
|
||||
Minecraft Server Manager — a full-stack Next.js 15+ (App Router) web application for managing Minecraft server instances. Built following Rezzect's organization-wide Next.js standards.
|
||||
|
||||
## Tech Stack
|
||||
- **Framework:** Next.js 15+ (App Router, Turbopack), React 19+, TypeScript 5.9+ (strict mode)
|
||||
- **Styling:** Tailwind CSS 3.4+ with dark glassmorphism design system (cyan-500 primary, `bg-gray-900/80 backdrop-blur-lg`)
|
||||
- **Database:** MongoDB 6+ via Mongoose 8+ with serverless-safe cached connections (`src/lib/mongodb.ts`)
|
||||
- **Auth:** Dual-token JWT (1h access + 7d refresh) in HTTP-only cookies, mandatory 2FA via email, bcryptjs (12 rounds)
|
||||
- **Containers:** Docker via dockerode — each MC server runs as its own container
|
||||
- **Icons:** Lucide React only — no other icon libraries
|
||||
- **Email:** Microsoft Graph API primary, SMTP (nodemailer) fallback — always implement dual-provider
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/app/ — Pages ('use client' on every page) and API routes
|
||||
src/components/ — Shared components; templates/ for reusable CRUD patterns (DataManagementTemplate)
|
||||
src/contexts/ — AuthContext, ToastContext, ConfirmationContext
|
||||
src/lib/ — Server utilities: auth.ts, mongodb.ts, models.ts, docker.ts, date-utils.ts, input-validation.ts, audit.ts
|
||||
src/hooks/ — Custom React hooks
|
||||
src/types/ — TypeScript interfaces and types
|
||||
```
|
||||
|
||||
## Critical Conventions
|
||||
- **Every page** must start with `'use client'`; add `export const dynamic = 'force-dynamic'` if using `useSearchParams`
|
||||
- **Date formatting:** NEVER create custom formatters — always use `formatDate()`, `formatDateTime()`, `formatDateForInput()` from `@/lib/date-utils`
|
||||
- **Images:** Always `next/image` — never raw `<img>` tags
|
||||
- **CRUD pages:** Use `DataManagementTemplate` from `@/components/templates/` for consistent data management UIs
|
||||
- **Drawers** over modals for detail views (`max-w-3xl`, `animate-slide-in-right`)
|
||||
|
||||
## API Route Pattern (every route must follow this order)
|
||||
1. `validateSession(request)` → 401 if missing
|
||||
2. `getClientIP(request)` for audit trail
|
||||
3. `sanitizeObject(await request.json())` — sanitize ALL input
|
||||
4. `isValidObjectId()` / `isValidEmail()` — validate params
|
||||
5. Permission check → 403 + audit log if denied
|
||||
6. `connectToDatabase()` then business logic
|
||||
7. `createAuditLog()` for every CREATE/UPDATE/DELETE (success AND failure)
|
||||
8. Return proper HTTP status: 200/201/400/401/403/404/409/500
|
||||
|
||||
## Security (non-negotiable)
|
||||
- Server-side permission checks are **authoritative**; client-side checks are UI hints only
|
||||
- Permission format: `resource:action` (e.g., `servers:edit`, `servers:view:department`)
|
||||
- Sanitize all inputs via `sanitizeObject()` from `@/lib/input-validation.ts`
|
||||
- Parameterized DB queries only — never string concatenation
|
||||
- Audit log ALL mutations with previous/new values and client IP
|
||||
|
||||
## UI Patterns (Dark Theme)
|
||||
- **Base background:** `bg-gray-950` (page), `bg-gray-900` (surfaces), `bg-gray-800` (elevated elements)
|
||||
- **Color semantics:** cyan=primary, emerald=success/active, amber=warning/pending, red=error/danger, gray=neutral
|
||||
- **Text colors:** `text-gray-100` (primary), `text-gray-400` (secondary), `text-gray-500` (muted)
|
||||
- **Animations:** Only animate `transform` and `opacity`, keep under 300ms
|
||||
- **Buttons:** `bg-cyan-500 hover:bg-cyan-600 text-white rounded-lg` (primary), `bg-red-500 hover:bg-red-600` (danger), `bg-gray-700 hover:bg-gray-600 text-gray-200` (secondary)
|
||||
- **Inputs:** `bg-gray-800 border-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-cyan-500 focus:border-transparent placeholder-gray-500`
|
||||
- **Cards:** `bg-gray-900/80 backdrop-blur-lg rounded-lg shadow-lg border border-gray-700/50 p-6`
|
||||
- **Glassmorphism card:** `bg-gray-800/60 backdrop-blur-lg rounded-lg border border-gray-700/50 shadow-xl`
|
||||
- **Tables:** `bg-gray-900` body, `bg-gray-800/50` header, `divide-gray-700`, `hover:bg-gray-800` rows
|
||||
- **Badges:** `bg-cyan-500/20 text-cyan-400` (info), `bg-emerald-500/20 text-emerald-400` (success), `bg-amber-500/20 text-amber-400` (warning), `bg-red-500/20 text-red-400` (error)
|
||||
- **Borders:** `border-gray-700` (standard), `border-gray-700/50` (subtle)
|
||||
- **Import order:** React hooks → Next.js hooks → contexts → lib utilities → components
|
||||
|
||||
## Minecraft Domain & Server Types
|
||||
|
||||
Each server instance has a **type** that determines its capabilities:
|
||||
|
||||
| Type | Examples | Supports Plugins | Supports Mods |
|
||||
|------|----------|:-:|:-:|
|
||||
| `vanilla` | Official Mojang JAR | ✗ | ✗ |
|
||||
| `bukkit` | Spigot, PaperMC, Purpur, etc. | ✓ | ✗ |
|
||||
| `forge` | Forge | ✗ | ✓ |
|
||||
| `fabric` | Fabric | ✗ | ✓ |
|
||||
|
||||
**Feature matrix by type — gate UI and API logic on `server.type`:**
|
||||
- **All types:** start/stop/restart, console streaming, command execution, server.properties editing, JVM args, backup/restore, player management (whitelist, ops, bans)
|
||||
- **Bukkit-based only:** plugin install/remove/enable/disable (JAR-based in `plugins/` directory)
|
||||
- **Forge/Fabric only:** mod install/remove/enable/disable (JAR-based in `mods/` directory)
|
||||
- **Vanilla:** no extension management — only core server features
|
||||
|
||||
### Core Feature Areas
|
||||
1. **Server Lifecycle** — create, start, stop, restart, delete instances; real-time status (online/offline/starting/stopping)
|
||||
2. **Console** — live log streaming (tail server stdout), send commands to server stdin
|
||||
3. **Configuration** — edit server.properties, JVM memory/flags, world settings per instance
|
||||
4. **Backups** — manual + scheduled backups of world data; restore to point-in-time
|
||||
5. **Plugins** (bukkit only) — upload/install/remove JARs; enable/disable without removing
|
||||
6. **Mods** (forge/fabric only) — upload/install/remove JARs; enable/disable without removing
|
||||
7. **Player Management** — whitelist add/remove, op/deop, ban/unban, view online players
|
||||
|
||||
## Data Models (Mongoose schemas in `src/lib/models.ts`)
|
||||
|
||||
### User
|
||||
- `username`, `email`, `passwordHash` (bcrypt 12 rounds)
|
||||
- `roles: [ObjectId]` → references Role
|
||||
- `twoFactorCode`, `twoFactorExpiry` — for mandatory 2FA
|
||||
- `loginAttempts`, `lockUntil` — account lockout (5 attempts → 30 min)
|
||||
- `status`: active | inactive | locked
|
||||
- `lastLogin`, `createdAt`, `updatedAt`
|
||||
|
||||
### Role
|
||||
- `name` (e.g., Admin, Operator, Viewer)
|
||||
- `permissions: [{ resource: string, actions: string[] }]`
|
||||
- `description`, `isDefault`, `createdAt`
|
||||
|
||||
### Server
|
||||
- `name`, `type`: vanilla | bukkit | forge | fabric
|
||||
- `version` (MC version, e.g., "1.21.4")
|
||||
- `dockerImage` (default `itzg/minecraft-server`, or a custom image)
|
||||
- `containerId` (Docker container ID — set after creation)
|
||||
- `containerName` (e.g., `mc-{serverId}`)
|
||||
- `port` (host port mapped to container 25565)
|
||||
- `rconPort` (host port mapped to container 25575, optional)
|
||||
- `status`: online | offline | starting | stopping | crashed
|
||||
- `maxPlayers`, `memory` (min/max heap in MB)
|
||||
- `jvmArgs: [string]`
|
||||
- `autoStart: boolean`, `autoRestart: boolean`
|
||||
- `backupSchedule`: cron expression (e.g., `0 */6 * * *` = every 6h) or `null` if manual-only
|
||||
- `backupRetention`: max number of backups to keep per server (oldest auto-deleted)
|
||||
- `createdBy: ObjectId` → User who created it
|
||||
- `createdAt`, `updatedAt`
|
||||
|
||||
### Backup
|
||||
- `serverId: ObjectId` → Server
|
||||
- `filename`, `filePath`, `fileSize`
|
||||
- `type`: manual | scheduled
|
||||
- `status`: completed | in_progress | failed
|
||||
- `createdBy: ObjectId`, `createdAt`
|
||||
|
||||
### AuditLog
|
||||
- Standard org pattern: `action`, `entityType`, `entityId`, `entityName`, `userId`, `userName`, `userEmail`, `previousValues`, `newValues`, `changes`, `clientIP`, `status`, `statusCode`
|
||||
|
||||
## Permissions
|
||||
|
||||
Format: `resource:action`. Relevant resources for this project:
|
||||
|
||||
```
|
||||
servers:view servers:create servers:edit servers:delete
|
||||
servers:start servers:stop servers:restart servers:console
|
||||
backups:view backups:create backups:restore backups:delete
|
||||
plugins:view plugins:install plugins:remove plugins:toggle
|
||||
mods:view mods:install mods:remove mods:toggle
|
||||
players:view players:whitelist players:op players:ban
|
||||
users:view users:create users:edit users:delete
|
||||
roles:view roles:create roles:edit roles:delete
|
||||
audit:view
|
||||
```
|
||||
|
||||
Admin role gets `*:*` (wildcard). Always check `server.type` before allowing plugin/mod actions — return 400 if mismatched (e.g., plugin install on a Forge server).
|
||||
|
||||
## Docker Architecture
|
||||
|
||||
Every Minecraft server runs as an isolated Docker container. Use **dockerode** (`src/lib/docker.ts`) to interact with the Docker Engine API via Unix socket.
|
||||
|
||||
### Docker Images
|
||||
- **Default:** `itzg/minecraft-server` — supports all server types via `TYPE` env var (e.g., `VANILLA`, `PAPER`, `SPIGOT`, `FORGE`, `FABRIC`)
|
||||
- **Custom:** Users can specify any Docker image in `server.dockerImage`; when custom, the app still bind-mounts the same volume layout but skips `itzg`-specific env vars — the user is responsible for image compatibility
|
||||
- Store `dockerImage` on the Server model; default to `itzg/minecraft-server` if not provided
|
||||
|
||||
### Container Lifecycle
|
||||
- **Create:** `docker.createContainer()` with `server.dockerImage`, bind-mount a host volume for persistent data
|
||||
- **Start/Stop/Restart:** `container.start()`, `container.stop()`, `container.restart()` — update `server.status` in MongoDB to match
|
||||
- **Delete:** `container.remove({ force: true })`, then optionally clean up host volume
|
||||
- **Status sync:** On app startup and periodically, reconcile `server.status` with `container.inspect()` state
|
||||
|
||||
### Volume Layout
|
||||
Each server gets a host-mounted volume at a configurable base path (env `MC_SERVERS_PATH`, default `/opt/mc-servers/`):
|
||||
```
|
||||
/opt/mc-servers/{serverId}/
|
||||
├── server.properties # MC config (editable via API)
|
||||
├── world/ # World data (backed up)
|
||||
├── plugins/ # Bukkit-type only
|
||||
├── mods/ # Forge/Fabric only
|
||||
├── logs/ # Server logs
|
||||
└── backups/ # Backup archives
|
||||
```
|
||||
|
||||
### Console & Logs
|
||||
- **Log streaming:** `container.logs({ follow: true, stdout: true, stderr: true })` → stream to client via SSE or WebSocket
|
||||
- **Command execution:** `container.exec()` to run `rcon-cli` or attach to stdin to send commands
|
||||
- Alternative: use RCON protocol directly on the `rconPort` if RCON is enabled in server.properties
|
||||
|
||||
### Key Patterns for `src/lib/docker.ts`
|
||||
- Export a singleton `getDockerClient()` that returns a cached dockerode instance
|
||||
- All container operations must catch Docker API errors and map to proper HTTP status codes
|
||||
- Container names follow convention: `mc-{server._id}` for easy lookup
|
||||
- Always set resource limits: `--memory` from `server.memory.max`, CPU shares as needed
|
||||
- Use `RestartPolicy: { Name: 'unless-stopped' }` when `server.autoRestart` is true
|
||||
|
||||
### Backups
|
||||
- Pause world saving (`save-off` + `save-all` via RCON/exec) → tar/gzip the `world/` directory → resume (`save-on`)
|
||||
- Store backup archives in `{serverDir}/backups/` and record metadata in the Backup model
|
||||
- Restore: stop container → replace `world/` with extracted backup → start container
|
||||
|
||||
### Scheduled Backups (node-cron)
|
||||
- Use **node-cron** running inside the Next.js process — no external scheduler needed
|
||||
- Each server stores a `backupSchedule` cron expression (e.g., `0 */6 * * *`) and `backupRetention` count
|
||||
- On app startup, query all servers with a `backupSchedule` and register cron jobs via `cron.schedule()`
|
||||
- When a server's schedule is created/updated/deleted via API, dynamically add/update/remove the cron job
|
||||
- Keep a `Map<serverId, CronJob>` in memory for lifecycle management
|
||||
- `backupRetention`: after each successful backup, delete oldest archives exceeding the limit
|
||||
- Scheduled backups set `backup.type = 'scheduled'`; manual backups set `backup.type = 'manual'`
|
||||
|
||||
### Environment Variables (Docker-specific)
|
||||
```env
|
||||
MC_SERVERS_PATH=/opt/mc-servers # Base path for server volumes
|
||||
DOCKER_SOCKET=/var/run/docker.sock # Docker socket path (default)
|
||||
```
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
npm run dev # Start dev server (port 3000, Turbopack)
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint check
|
||||
```
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.fontFamily": "'CaskaydiaMono Nerd Font Mono'",
|
||||
"editor.allowVariableFonts": true
|
||||
}
|
||||
@ -1,7 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// Allow server-side Node.js modules used by dockerode, child_process, etc.
|
||||
serverExternalPackages: ['dockerode', 'node-cron'],
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'mc-heads.net',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'crafatar.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1699
package-lock.json
generated
1699
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -6,21 +6,38 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"seed": "tsx scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.3",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dockerode": "^4.0.9",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mongoose": "^9.1.6",
|
||||
"next": "16.1.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.9",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.2.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
218
scripts/seed.ts
Normal file
218
scripts/seed.ts
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Seed script — creates the initial Admin role and admin user.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/seed.ts
|
||||
*
|
||||
* Requires a valid MONGODB_URI in .env (or .env.local).
|
||||
*/
|
||||
|
||||
import mongoose from 'mongoose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import * as readline from 'readline'
|
||||
import * as dotenv from 'dotenv'
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: '.env.local' })
|
||||
dotenv.config({ path: '.env' })
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI
|
||||
|
||||
if (!MONGODB_URI) {
|
||||
console.error('❌ MONGODB_URI is not defined. Create a .env or .env.local file first.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// ─── Inline Schemas (avoid importing from src/ which needs TS paths) ─────
|
||||
|
||||
const permissionSchema = new mongoose.Schema(
|
||||
{
|
||||
resource: { type: String, required: true },
|
||||
actions: [{ type: String, required: true }],
|
||||
},
|
||||
{ _id: false }
|
||||
)
|
||||
|
||||
const roleSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true, unique: true, trim: true },
|
||||
permissions: [permissionSchema],
|
||||
description: { type: String, default: '' },
|
||||
isDefault: { type: Boolean, default: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
username: { type: String, required: true, unique: true, trim: true },
|
||||
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
roles: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Role' }],
|
||||
twoFactorCode: { type: String, default: null },
|
||||
twoFactorExpiry: { type: Date, default: null },
|
||||
loginAttempts: { type: Number, default: 0 },
|
||||
lockUntil: { type: Date, default: null },
|
||||
status: { type: String, enum: ['active', 'inactive', 'locked'], default: 'active' },
|
||||
lastLogin: { type: Date, default: null },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
const Role = mongoose.models.Role || mongoose.model('Role', roleSchema)
|
||||
const User = mongoose.models.User || mongoose.model('User', userSchema)
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
return new Promise(resolve => {
|
||||
rl.question(question, answer => {
|
||||
rl.close()
|
||||
resolve(answer.trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Admin Permissions (wildcard on every resource) ──────────────
|
||||
|
||||
const ADMIN_PERMISSIONS = [
|
||||
{ resource: '*', actions: ['*'] },
|
||||
]
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 MC-Manager Seed Script\n')
|
||||
|
||||
await mongoose.connect(MONGODB_URI!)
|
||||
console.log('✅ Connected to MongoDB\n')
|
||||
|
||||
// ── 1. Create or update Admin Role ──────────────────────────────
|
||||
let adminRole = await Role.findOne({ name: 'Admin' })
|
||||
|
||||
if (adminRole) {
|
||||
console.log('ℹ️ Admin role already exists — updating permissions...')
|
||||
adminRole.permissions = ADMIN_PERMISSIONS
|
||||
adminRole.description = 'Full system administrator with all permissions'
|
||||
await adminRole.save()
|
||||
} else {
|
||||
adminRole = await Role.create({
|
||||
name: 'Admin',
|
||||
permissions: ADMIN_PERMISSIONS,
|
||||
description: 'Full system administrator with all permissions',
|
||||
isDefault: false,
|
||||
})
|
||||
console.log('✅ Admin role created')
|
||||
}
|
||||
|
||||
// ── 2. Create default Viewer role ───────────────────────────────
|
||||
let viewerRole = await Role.findOne({ name: 'Viewer' })
|
||||
|
||||
if (!viewerRole) {
|
||||
viewerRole = await Role.create({
|
||||
name: 'Viewer',
|
||||
permissions: [
|
||||
{ resource: 'servers', actions: ['view'] },
|
||||
{ resource: 'backups', actions: ['view'] },
|
||||
{ resource: 'plugins', actions: ['view'] },
|
||||
{ resource: 'mods', actions: ['view'] },
|
||||
{ resource: 'players', actions: ['view'] },
|
||||
],
|
||||
description: 'Read-only access to servers and related resources',
|
||||
isDefault: true,
|
||||
})
|
||||
console.log('✅ Viewer role created')
|
||||
} else {
|
||||
console.log('ℹ️ Viewer role already exists — skipped')
|
||||
}
|
||||
|
||||
// ── 3. Create Operator role ─────────────────────────────────────
|
||||
let operatorRole = await Role.findOne({ name: 'Operator' })
|
||||
|
||||
if (!operatorRole) {
|
||||
operatorRole = await Role.create({
|
||||
name: 'Operator',
|
||||
permissions: [
|
||||
{ resource: 'servers', actions: ['view', 'start', 'stop', 'restart', 'console'] },
|
||||
{ resource: 'backups', actions: ['view', 'create'] },
|
||||
{ resource: 'plugins', actions: ['view', 'install', 'remove', 'toggle'] },
|
||||
{ resource: 'mods', actions: ['view', 'install', 'remove', 'toggle'] },
|
||||
{ resource: 'players', actions: ['view', 'whitelist', 'op', 'ban'] },
|
||||
],
|
||||
description: 'Can operate servers but cannot create/delete them or manage users',
|
||||
isDefault: false,
|
||||
})
|
||||
console.log('✅ Operator role created')
|
||||
} else {
|
||||
console.log('ℹ️ Operator role already exists — skipped')
|
||||
}
|
||||
|
||||
// ── 4. Create Admin User ────────────────────────────────────────
|
||||
const existingAdmin = await User.findOne({ username: 'admin' })
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log('\nℹ️ Admin user already exists.')
|
||||
const reset = await prompt('Reset admin password? (y/N): ')
|
||||
|
||||
if (reset.toLowerCase() === 'y') {
|
||||
const newPassword = await prompt('New password (min 8 chars): ')
|
||||
if (newPassword.length < 8) {
|
||||
console.error('❌ Password must be at least 8 characters.')
|
||||
await mongoose.disconnect()
|
||||
process.exit(1)
|
||||
}
|
||||
existingAdmin.passwordHash = await bcrypt.hash(newPassword, 12)
|
||||
existingAdmin.status = 'active'
|
||||
existingAdmin.loginAttempts = 0
|
||||
existingAdmin.lockUntil = null
|
||||
|
||||
// Ensure admin has the Admin role
|
||||
if (!existingAdmin.roles.some((r: mongoose.Types.ObjectId) => r.equals(adminRole._id))) {
|
||||
existingAdmin.roles.push(adminRole._id)
|
||||
}
|
||||
|
||||
await existingAdmin.save()
|
||||
console.log('✅ Admin password reset successfully')
|
||||
}
|
||||
} else {
|
||||
console.log('\n📝 Create initial admin user:\n')
|
||||
|
||||
const email = await prompt('Admin email: ')
|
||||
if (!email || !email.includes('@')) {
|
||||
console.error('❌ A valid email is required.')
|
||||
await mongoose.disconnect()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const password = await prompt('Admin password (min 8 chars): ')
|
||||
if (password.length < 8) {
|
||||
console.error('❌ Password must be at least 8 characters.')
|
||||
await mongoose.disconnect()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
await User.create({
|
||||
username: 'admin',
|
||||
email,
|
||||
passwordHash,
|
||||
roles: [adminRole._id],
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
console.log('✅ Admin user created (username: admin)')
|
||||
}
|
||||
|
||||
// ── Done ────────────────────────────────────────────────────────
|
||||
console.log('\n🎉 Seed complete! You can now log in at http://localhost:3000')
|
||||
|
||||
await mongoose.disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ Seed failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
169
src/app/(app)/audit/page.tsx
Normal file
169
src/app/(app)/audit/page.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FileText, Search } from 'lucide-react'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Select from '@/components/ui/Select'
|
||||
import { formatDateTime } from '@/lib/date-utils'
|
||||
import type { AuditLog } from '@/types'
|
||||
|
||||
export default function AuditPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [entityFilter, setEntityFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '50' })
|
||||
if (search) params.set('search', search)
|
||||
if (entityFilter) params.set('entityType', entityFilter)
|
||||
|
||||
const res = await fetch(`/api/audit?${params}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLogs(data.data || [])
|
||||
setTotalPages(data.totalPages || 1)
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to fetch audit logs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, entityFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [fetchLogs])
|
||||
|
||||
const entityTypes = [
|
||||
{ label: 'Server', value: 'server' },
|
||||
{ label: 'Backup', value: 'backup' },
|
||||
{ label: 'User', value: 'user' },
|
||||
{ label: 'Role', value: 'role' },
|
||||
{ label: 'Plugin', value: 'plugin' },
|
||||
{ label: 'Mod', value: 'mod' },
|
||||
{ label: 'Player', value: 'player' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Audit Log"
|
||||
description="Track all system changes and user actions"
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-[200px] max-w-md">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search audit logs..."
|
||||
className="w-full bg-gray-800 border border-gray-700 text-gray-100 text-sm rounded-lg pl-10 pr-4 py-2 focus:ring-2 focus:ring-cyan-500 focus:border-transparent placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={entityFilter}
|
||||
onChange={e => { setEntityFilter(e.target.value); setPage(1) }}
|
||||
options={entityTypes}
|
||||
placeholder="All Entity Types"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<FileText size={40} className="mx-auto mb-3 text-gray-600" />
|
||||
<p className="font-medium">No audit logs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Timestamp</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">User</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Action</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Entity</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{logs.map(log => (
|
||||
<tr key={log._id} className="hover:bg-gray-800">
|
||||
<td className="px-6 py-3 text-xs text-gray-400 whitespace-nowrap">
|
||||
{formatDateTime(log.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-200">
|
||||
{log.userName}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-200 font-mono text-xs">
|
||||
{log.action}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<div>
|
||||
<Badge variant="neutral">{log.entityType}</Badge>
|
||||
<span className="ml-2 text-sm text-gray-300">{log.entityName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<Badge variant={log.status === 'SUCCESS' ? 'success' : 'error'}>
|
||||
{log.statusCode || log.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-xs text-gray-500 font-mono">
|
||||
{log.clientIP}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-700">
|
||||
<p className="text-sm text-gray-500">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1.5 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1.5 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
src/app/(app)/dashboard/page.tsx
Normal file
163
src/app/(app)/dashboard/page.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { LayoutDashboard, Server, HardDrive, Users, Activity } from 'lucide-react'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import ServerStatusBadge from '@/components/ServerStatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import type { Server as ServerType } from '@/types/server'
|
||||
|
||||
interface DashboardStats {
|
||||
totalServers: number
|
||||
onlineServers: number
|
||||
totalPlayers: number
|
||||
totalBackups: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth()
|
||||
const [servers, setServers] = useState<ServerType[]>([])
|
||||
const [stats, setStats] = useState<DashboardStats>({ totalServers: 0, onlineServers: 0, totalPlayers: 0, totalBackups: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/servers', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const serverList: ServerType[] = data.data || []
|
||||
setServers(serverList)
|
||||
setStats({
|
||||
totalServers: serverList.length,
|
||||
onlineServers: serverList.filter(s => s.status === 'online').length,
|
||||
totalPlayers: 0, // TODO: aggregate from online servers
|
||||
totalBackups: 0, // TODO: fetch from backups API
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`Welcome back, ${user?.username}`}
|
||||
description="Overview of your Minecraft servers"
|
||||
icon={LayoutDashboard}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Server}
|
||||
label="Total Servers"
|
||||
value={stats.totalServers}
|
||||
color="cyan"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Online"
|
||||
value={stats.onlineServers}
|
||||
color="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Users}
|
||||
label="Players Online"
|
||||
value={stats.totalPlayers}
|
||||
color="amber"
|
||||
/>
|
||||
<StatCard
|
||||
icon={HardDrive}
|
||||
label="Total Backups"
|
||||
value={stats.totalBackups}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||
<h2 className="text-lg font-semibold text-gray-100">Servers</h2>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No servers yet. Create your first Minecraft server!
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700">
|
||||
{servers.map(server => (
|
||||
<a
|
||||
key={server._id}
|
||||
href={`/servers/${server._id}`}
|
||||
className="flex items-center justify-between px-6 py-4 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Server size={20} className="text-gray-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-200">{server.name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{server.type} · {server.version} · Port {server.port}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ServerStatusBadge status={server.status} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: typeof Server
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
cyan: 'bg-cyan-500/20 text-cyan-400',
|
||||
emerald: 'bg-emerald-500/20 text-emerald-400',
|
||||
amber: 'bg-amber-500/20 text-amber-400',
|
||||
gray: 'bg-gray-500/20 text-gray-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2.5 rounded-lg ${colorMap[color]}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-100">{value}</p>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/app/(app)/layout.tsx
Normal file
28
src/app/(app)/layout.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-950">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null // Middleware will redirect
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-950">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
319
src/app/(app)/roles/page.tsx
Normal file
319
src/app/(app)/roles/page.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
import { Shield, Edit, Trash2 } from 'lucide-react'
|
||||
import DataManagementTemplate, { Column } from '@/components/templates/DataManagementTemplate'
|
||||
import Drawer from '@/components/Drawer'
|
||||
import Modal from '@/components/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import type { Role, Permission } from '@/types/user'
|
||||
|
||||
const allResources = [
|
||||
'servers', 'backups', 'plugins', 'mods', 'players', 'users', 'roles', 'audit',
|
||||
]
|
||||
|
||||
const allActions: Record<string, string[]> = {
|
||||
servers: ['view', 'create', 'edit', 'delete', 'start', 'stop', 'restart', 'console'],
|
||||
backups: ['view', 'create', 'restore', 'delete'],
|
||||
plugins: ['view', 'install', 'remove', 'toggle'],
|
||||
mods: ['view', 'install', 'remove', 'toggle'],
|
||||
players: ['view', 'whitelist', 'op', 'ban'],
|
||||
users: ['view', 'create', 'edit', 'delete'],
|
||||
roles: ['view', 'create', 'edit', 'delete'],
|
||||
audit: ['view'],
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editPermissions, setEditPermissions] = useState<Permission[]>([])
|
||||
|
||||
const { hasPermission } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const fetchRoles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/roles', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRoles(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to fetch roles', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles()
|
||||
}, [fetchRoles])
|
||||
|
||||
const togglePermission = (resource: string, action: string) => {
|
||||
setEditPermissions(prev => {
|
||||
const existing = prev.find(p => p.resource === resource)
|
||||
if (existing) {
|
||||
const hasAction = existing.actions.includes(action)
|
||||
const newActions = hasAction
|
||||
? existing.actions.filter(a => a !== action)
|
||||
: [...existing.actions, action]
|
||||
|
||||
if (newActions.length === 0) {
|
||||
return prev.filter(p => p.resource !== resource)
|
||||
}
|
||||
|
||||
return prev.map(p => p.resource === resource ? { ...p, actions: newActions } : p)
|
||||
}
|
||||
|
||||
return [...prev, { resource, actions: [action] }]
|
||||
})
|
||||
}
|
||||
|
||||
const hasPermissionToggle = (resource: string, action: string) => {
|
||||
return editPermissions.some(p => p.resource === resource && p.actions.includes(action))
|
||||
}
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
const form = new FormData(e.currentTarget)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: form.get('name'),
|
||||
description: form.get('description'),
|
||||
permissions: editPermissions,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Role created successfully', 'success')
|
||||
setShowCreate(false)
|
||||
setEditPermissions([])
|
||||
fetchRoles()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to create role', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to create role', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!selectedRole) return
|
||||
setSaving(true)
|
||||
const form = new FormData(e.currentTarget)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/roles/${selectedRole._id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name: form.get('name'),
|
||||
description: form.get('description'),
|
||||
permissions: editPermissions,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Role updated successfully', 'success')
|
||||
setShowEdit(false)
|
||||
setSelectedRole(null)
|
||||
setEditPermissions([])
|
||||
fetchRoles()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to update role', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to update role', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (role: Role) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Delete Role',
|
||||
message: 'Are you sure you want to permanently delete',
|
||||
itemName: role.name,
|
||||
type: 'danger',
|
||||
confirmText: 'Delete Role',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/roles/${role._id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Role deleted', 'success')
|
||||
fetchRoles()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to delete role', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to delete role', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Role>[] = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{
|
||||
key: 'permissions',
|
||||
label: 'Permissions',
|
||||
render: (r) => {
|
||||
const count = r.permissions.reduce((acc, p) => acc + p.actions.length, 0)
|
||||
return <Badge variant="info">{count} permissions</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isDefault',
|
||||
label: 'Default',
|
||||
render: (r) => r.isDefault ? <Badge variant="success">Default</Badge> : null,
|
||||
},
|
||||
{
|
||||
key: '_id',
|
||||
label: 'Actions',
|
||||
render: (r) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPermission('roles:edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={Edit}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedRole(r)
|
||||
setEditPermissions(r.permissions || [])
|
||||
setShowEdit(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('roles:delete') && !r.isDefault && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={Trash2}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(r)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const PermissionGrid = () => (
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{allResources.map(resource => (
|
||||
<div key={resource} className="bg-gray-800/50 rounded-lg p-3">
|
||||
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wider mb-2">{resource}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allActions[resource].map(action => (
|
||||
<button
|
||||
key={`${resource}:${action}`}
|
||||
type="button"
|
||||
onClick={() => togglePermission(resource, action)}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
||||
hasPermissionToggle(resource, action)
|
||||
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/50'
|
||||
: 'bg-gray-700 text-gray-400 border border-gray-600 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataManagementTemplate<Role>
|
||||
title="Roles"
|
||||
description="Manage roles and their permissions"
|
||||
icon={Shield}
|
||||
items={roles}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
getRowKey={(r) => r._id}
|
||||
searchPlaceholder="Search roles..."
|
||||
searchFields={['name', 'description']}
|
||||
onRowClick={(r) => {
|
||||
setSelectedRole(r)
|
||||
setEditPermissions(r.permissions || [])
|
||||
setShowEdit(true)
|
||||
}}
|
||||
onAdd={() => { setEditPermissions([]); setShowCreate(true) }}
|
||||
addLabel="Create Role"
|
||||
canAdd={hasPermission('roles:create')}
|
||||
emptyIcon={Shield}
|
||||
emptyTitle="No roles yet"
|
||||
emptyDescription="Create roles to manage user permissions."
|
||||
/>
|
||||
|
||||
{/* Create Role Modal */}
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Create Role" maxWidth="max-w-2xl">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input name="name" label="Role Name" placeholder="Operator" required />
|
||||
<Input name="description" label="Description" placeholder="Can manage servers and players" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Permissions</label>
|
||||
<PermissionGrid />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button type="submit" loading={saving}>Create Role</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Role Drawer */}
|
||||
<Drawer isOpen={showEdit} onClose={() => { setShowEdit(false); setSelectedRole(null) }} title={`Edit Role — ${selectedRole?.name}`}>
|
||||
{selectedRole && (
|
||||
<form onSubmit={handleUpdate} className="space-y-4">
|
||||
<Input name="name" label="Role Name" defaultValue={selectedRole.name} required />
|
||||
<Input name="description" label="Description" defaultValue={selectedRole.description || ''} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Permissions</label>
|
||||
<PermissionGrid />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" type="button" onClick={() => { setShowEdit(false); setSelectedRole(null) }}>Cancel</Button>
|
||||
<Button type="submit" loading={saving}>Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
212
src/app/(app)/servers/[id]/backups/page.tsx
Normal file
212
src/app/(app)/servers/[id]/backups/page.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, HardDrive, RotateCcw, Plus, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
import { formatDateTime, formatFileSize } from '@/lib/date-utils'
|
||||
import type { Backup } from '@/types/backup'
|
||||
|
||||
export default function BackupsPage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
const { showToast } = useToast()
|
||||
const { hasPermission } = useAuth()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const [backups, setBackups] = useState<Backup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/backups`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setBackups(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load backups', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackups()
|
||||
}, [fetchBackups])
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/backups`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Backup started', 'success')
|
||||
fetchBackups()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to create backup', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to create backup', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (backup: Backup) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Restore Backup',
|
||||
message: 'This will stop the server and replace the current world with the backup. Continue?',
|
||||
itemName: backup.filename,
|
||||
type: 'warning',
|
||||
confirmText: 'Restore',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/backups/${backup._id}/restore`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Backup restoration started', 'success')
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to restore backup', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to restore backup', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (backup: Backup) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Delete Backup',
|
||||
message: 'Are you sure you want to permanently delete',
|
||||
itemName: backup.filename,
|
||||
type: 'danger',
|
||||
confirmText: 'Delete',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/backups/${backup._id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Backup deleted', 'success')
|
||||
fetchBackups()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to delete backup', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to delete backup', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Backups"
|
||||
description="Create and manage world backups"
|
||||
icon={HardDrive}
|
||||
actions={
|
||||
hasPermission('backups:create') ? (
|
||||
<Button icon={Plus} onClick={handleCreate} loading={creating}>
|
||||
Create Backup
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{backups.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<HardDrive size={40} className="mx-auto mb-3 text-gray-600" />
|
||||
<p className="font-medium">No backups yet</p>
|
||||
<p className="text-sm mt-1">Create your first backup to protect your world data.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Filename</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Type</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Size</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Created</th>
|
||||
<th className="text-right text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{backups.map(backup => (
|
||||
<tr key={backup._id} className="hover:bg-gray-800">
|
||||
<td className="px-6 py-4 text-sm text-gray-200 font-mono">{backup.filename}</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={backup.type === 'manual' ? 'info' : 'neutral'}>
|
||||
{backup.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400">{formatFileSize(backup.fileSize)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={backup.status === 'completed' ? 'success' : backup.status === 'failed' ? 'error' : 'warning'}>
|
||||
{backup.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400">{formatDateTime(backup.createdAt)}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{hasPermission('backups:restore') && backup.status === 'completed' && (
|
||||
<Button size="sm" variant="secondary" icon={RotateCcw} onClick={() => handleRestore(backup)}>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('backups:delete') && (
|
||||
<Button size="sm" variant="danger" icon={Trash2} onClick={() => handleDelete(backup)}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
src/app/(app)/servers/[id]/configuration/page.tsx
Normal file
161
src/app/(app)/servers/[id]/configuration/page.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Settings, Save } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
interface ServerProperties {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [properties, setProperties] = useState<ServerProperties>({})
|
||||
const [jvmArgs, setJvmArgs] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/configuration`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setProperties(data.data?.properties || {})
|
||||
setJvmArgs((data.data?.jvmArgs || []).join(' '))
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load configuration', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [fetchConfig])
|
||||
|
||||
const handlePropertyChange = (key: string, value: string) => {
|
||||
setProperties(prev => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/configuration`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
properties,
|
||||
jvmArgs: jvmArgs.split(/\s+/).filter(Boolean),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Configuration saved. Restart the server to apply changes.', 'success')
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to save configuration', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to save configuration', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const commonProps = [
|
||||
'server-port', 'max-players', 'motd', 'difficulty', 'gamemode',
|
||||
'pvp', 'allow-nether', 'spawn-protection', 'view-distance',
|
||||
'online-mode', 'white-list', 'enable-command-block', 'level-seed',
|
||||
'level-name', 'level-type', 'spawn-npcs', 'spawn-animals',
|
||||
'spawn-monsters', 'generate-structures'
|
||||
]
|
||||
|
||||
const sortedKeys = Object.keys(properties).sort((a, b) => {
|
||||
const aCommon = commonProps.indexOf(a)
|
||||
const bCommon = commonProps.indexOf(b)
|
||||
if (aCommon !== -1 && bCommon !== -1) return aCommon - bCommon
|
||||
if (aCommon !== -1) return -1
|
||||
if (bCommon !== -1) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Configuration"
|
||||
description="Edit server.properties and JVM arguments"
|
||||
icon={Settings}
|
||||
actions={
|
||||
<Button icon={Save} onClick={handleSave} loading={saving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* JVM Args */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-200 mb-3">JVM Arguments</h3>
|
||||
<Input
|
||||
value={jvmArgs}
|
||||
onChange={e => setJvmArgs(e.target.value)}
|
||||
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Space-separated JVM flags. Memory flags are set automatically from server settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Server Properties */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-200">server.properties</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700">
|
||||
{sortedKeys.map(key => (
|
||||
<div key={key} className="flex items-center gap-4 px-6 py-3">
|
||||
<label className="w-1/3 text-sm text-gray-400 font-mono truncate" title={key}>
|
||||
{key}
|
||||
</label>
|
||||
<input
|
||||
value={properties[key]}
|
||||
onChange={e => handlePropertyChange(key, e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 text-gray-100 text-sm rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-cyan-500 focus:border-transparent placeholder-gray-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sortedKeys.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No server.properties found. Start the server once to generate the default configuration.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/app/(app)/servers/[id]/console/page.tsx
Normal file
33
src/app/(app)/servers/[id]/console/page.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import ConsoleViewer from '@/components/ConsoleViewer'
|
||||
|
||||
export default function ConsolePage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Console"
|
||||
description="View live server logs and execute commands"
|
||||
icon={Terminal}
|
||||
/>
|
||||
|
||||
<div className="h-[calc(100vh-220px)]">
|
||||
<ConsoleViewer serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1076
src/app/(app)/servers/[id]/files/page.tsx
Normal file
1076
src/app/(app)/servers/[id]/files/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
182
src/app/(app)/servers/[id]/logs/page.tsx
Normal file
182
src/app/(app)/servers/[id]/logs/page.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, FileText, Download, ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { formatDateTime } from '@/lib/date-utils'
|
||||
|
||||
interface LogFile {
|
||||
name: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [files, setFiles] = useState<LogFile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const [fileLoading, setFileLoading] = useState(false)
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/logs`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setFiles(data.data)
|
||||
} else {
|
||||
showToast('Failed to load log files', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load log files', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles()
|
||||
}, [fetchFiles])
|
||||
|
||||
const openFile = async (fileName: string) => {
|
||||
setSelectedFile(fileName)
|
||||
setFileContent(null)
|
||||
setFileLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/logs?file=${encodeURIComponent(fileName)}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setFileContent(data.data.content)
|
||||
} else {
|
||||
showToast('Failed to load log file', 'error')
|
||||
setSelectedFile(null)
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load log file', 'error')
|
||||
setSelectedFile(null)
|
||||
} finally {
|
||||
setFileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = () => {
|
||||
if (!fileContent || !selectedFile) return
|
||||
const blob = new Blob([fileContent], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = selectedFile.replace('.gz', '')
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Logs"
|
||||
description="View server log files"
|
||||
icon={FileText}
|
||||
actions={
|
||||
selectedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={Download} size="sm" variant="secondary" onClick={downloadFile}>
|
||||
Download
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setSelectedFile(null); setFileContent(null) }}>
|
||||
Back to Files
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button icon={RefreshCw} size="sm" variant="secondary" onClick={fetchFiles}>
|
||||
Refresh
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
/* Log File Viewer */
|
||||
<div className="bg-gray-950 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-700/50 bg-gray-900/50">
|
||||
<FileText size={16} className="text-cyan-400" />
|
||||
<span className="text-sm font-medium text-gray-200">{selectedFile}</span>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[calc(100vh-300px)] p-4">
|
||||
{fileLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap break-all">
|
||||
{fileContent || 'Empty log file'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* File List */
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText size={40} className="mx-auto text-gray-600 mb-3" />
|
||||
<p className="text-gray-400">No log files found</p>
|
||||
<p className="text-gray-600 text-sm mt-1">Logs will appear after the server has started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700/50">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => openFile(file.name)}
|
||||
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-gray-800/60 transition-colors text-left group"
|
||||
>
|
||||
<FileText size={18} className="text-gray-500 group-hover:text-cyan-400 transition-colors flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-200 truncate">{file.name}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{formatFileSize(file.size)} · {formatDateTime(file.modifiedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 group-hover:text-gray-400 transition-colors flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
src/app/(app)/servers/[id]/mods/page.tsx
Normal file
221
src/app/(app)/servers/[id]/mods/page.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Package, Upload, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
import { formatFileSize } from '@/lib/date-utils'
|
||||
|
||||
interface ModInfo {
|
||||
name: string
|
||||
filename: string
|
||||
size: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function ModsPage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
const { showToast } = useToast()
|
||||
const { hasPermission } = useAuth()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const [mods, setMods] = useState<ModInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchMods = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/mods`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setMods(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load mods', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMods()
|
||||
}, [fetchMods])
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.name.endsWith('.jar')) {
|
||||
showToast('Only .jar files are allowed', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/mods`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Mod uploaded successfully', 'success')
|
||||
fetchMods()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to upload mod', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to upload mod', 'error')
|
||||
}
|
||||
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleToggle = async (mod: ModInfo) => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/mods/${encodeURIComponent(mod.filename)}/toggle`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`Mod ${mod.enabled ? 'disabled' : 'enabled'}`, 'success')
|
||||
fetchMods()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to toggle mod', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to toggle mod', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (mod: ModInfo) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Remove Mod',
|
||||
message: 'Are you sure you want to permanently remove',
|
||||
itemName: mod.name,
|
||||
type: 'danger',
|
||||
confirmText: 'Remove',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/mods/${encodeURIComponent(mod.filename)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Mod removed', 'success')
|
||||
fetchMods()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to remove mod', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to remove mod', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Mods"
|
||||
description="Manage server mods (Forge/Fabric)"
|
||||
icon={Package}
|
||||
actions={
|
||||
hasPermission('mods:install') ? (
|
||||
<label className="cursor-pointer">
|
||||
<Button icon={Upload} as="span">
|
||||
Upload Mod
|
||||
</Button>
|
||||
<input type="file" accept=".jar" className="hidden" onChange={handleUpload} />
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{mods.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Package size={40} className="mx-auto mb-3 text-gray-600" />
|
||||
<p className="font-medium">No mods installed</p>
|
||||
<p className="text-sm mt-1">Upload a .jar file to install a mod.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Mod</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Size</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||
<th className="text-right text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{mods.map(mod => (
|
||||
<tr key={mod.filename} className="hover:bg-gray-800">
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-medium text-gray-200">{mod.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{mod.filename}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400">{formatFileSize(mod.size)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={mod.enabled ? 'success' : 'neutral'}>
|
||||
{mod.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{hasPermission('mods:toggle') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={mod.enabled ? ToggleLeft : ToggleRight}
|
||||
onClick={() => handleToggle(mod)}
|
||||
>
|
||||
{mod.enabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('mods:remove') && (
|
||||
<Button size="sm" variant="danger" icon={Trash2} onClick={() => handleRemove(mod)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
src/app/(app)/servers/[id]/page.tsx
Normal file
292
src/app/(app)/servers/[id]/page.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
import {
|
||||
Server, Play, Square, RotateCcw, Terminal, Settings, HardDrive,
|
||||
Puzzle, Package, Users, Trash2, ArrowLeft, FileText, FolderOpen
|
||||
} from 'lucide-react'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import ServerStatusBadge from '@/components/ServerStatusBadge'
|
||||
import StatsChart from '@/components/StatsChart'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { supportsPlugins, supportsMods } from '@/types/server'
|
||||
import type { Server as ServerData } from '@/types/server'
|
||||
import Link from 'next/link'
|
||||
|
||||
const MAX_STATS_POINTS = 60
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
export default function ServerDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const serverId = params.id as string
|
||||
const { hasPermission } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const [server, setServer] = useState<ServerData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [cpuHistory, setCpuHistory] = useState<number[]>([])
|
||||
const [memHistory, setMemHistory] = useState<number[]>([])
|
||||
const [currentStats, setCurrentStats] = useState<{ cpu: number; memUsed: number; memLimit: number; memPercent: number } | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
const fetchServer = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setServer(data.data)
|
||||
} else {
|
||||
showToast('Server not found', 'error')
|
||||
router.push('/servers')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to fetch server', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, router, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchServer()
|
||||
const interval = setInterval(fetchServer, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchServer])
|
||||
|
||||
// SSE connection for live stats
|
||||
useEffect(() => {
|
||||
if (!server || server.status !== 'online') {
|
||||
// Close any existing connection and clear data when offline
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
setCpuHistory([])
|
||||
setMemHistory([])
|
||||
setCurrentStats(null)
|
||||
return
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/servers/${serverId}/stats`)
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
setCurrentStats(data)
|
||||
setCpuHistory(prev => {
|
||||
const next = [...prev, data.cpu]
|
||||
return next.length > MAX_STATS_POINTS ? next.slice(-MAX_STATS_POINTS) : next
|
||||
})
|
||||
setMemHistory(prev => {
|
||||
const next = [...prev, data.memUsed]
|
||||
return next.length > MAX_STATS_POINTS ? next.slice(-MAX_STATS_POINTS) : next
|
||||
})
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
es.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
}, [server?.status, serverId])
|
||||
|
||||
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
|
||||
setActionLoading(action)
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`Server ${action} initiated`, 'success')
|
||||
fetchServer()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || `Failed to ${action} server`, 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast(`Failed to ${action} server`, 'error')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!server) return
|
||||
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Delete Server',
|
||||
message: 'Are you sure you want to permanently delete',
|
||||
itemName: server.name,
|
||||
type: 'danger',
|
||||
confirmText: 'Delete Server',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Server deleted', 'success')
|
||||
router.push('/servers')
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to delete server', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to delete server', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !server) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Console', href: `/servers/${serverId}/console`, icon: Terminal, permission: 'servers:console' },
|
||||
{ label: 'Logs', href: `/servers/${serverId}/logs`, icon: FileText, permission: 'servers:console' },
|
||||
{ label: 'Files', href: `/servers/${serverId}/files`, icon: FolderOpen, permission: 'servers:edit' },
|
||||
{ label: 'Configuration', href: `/servers/${serverId}/configuration`, icon: Settings, permission: 'servers:edit' },
|
||||
{ label: 'Backups', href: `/servers/${serverId}/backups`, icon: HardDrive, permission: 'backups:view' },
|
||||
...(supportsPlugins(server.type) ? [{ label: 'Plugins', href: `/servers/${serverId}/plugins`, icon: Puzzle, permission: 'plugins:view' }] : []),
|
||||
...(supportsMods(server.type) ? [{ label: 'Mods', href: `/servers/${serverId}/mods`, icon: Package, permission: 'mods:view' }] : []),
|
||||
{ label: 'Players', href: `/servers/${serverId}/players`, icon: Users, permission: 'players:view' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back Button */}
|
||||
<Link href="/servers" className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">
|
||||
<ArrowLeft size={16} /> Back to Servers
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title={server.name}
|
||||
description={`${server.type} · ${server.version} · Port ${server.port}`}
|
||||
icon={Server}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerStatusBadge status={server.status} size="md" />
|
||||
|
||||
{hasPermission('servers:start') && server.status === 'offline' && (
|
||||
<Button icon={Play} size="sm" onClick={() => handleAction('start')} loading={actionLoading === 'start'}>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('servers:stop') && server.status === 'online' && (
|
||||
<Button icon={Square} size="sm" variant="secondary" onClick={() => handleAction('stop')} loading={actionLoading === 'stop'}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('servers:restart') && server.status === 'online' && (
|
||||
<Button icon={RotateCcw} size="sm" variant="secondary" onClick={() => handleAction('restart')} loading={actionLoading === 'restart'}>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('servers:delete') && (
|
||||
<Button icon={Trash2} size="sm" variant="danger" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<InfoCard label="Type" value={server.type} />
|
||||
<InfoCard label="Version" value={server.version} />
|
||||
<InfoCard label="Memory" value={`${server.memory.min}–${server.memory.max} MB`} />
|
||||
<InfoCard label="Max Players" value={String(server.maxPlayers)} />
|
||||
</div>
|
||||
|
||||
{/* Resource Graphs */}
|
||||
{server.status === 'online' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<StatsChart
|
||||
data={cpuHistory}
|
||||
maxDataPoints={MAX_STATS_POINTS}
|
||||
label="CPU Usage"
|
||||
value={currentStats ? `${currentStats.cpu.toFixed(1)}%` : '—'}
|
||||
color="cyan"
|
||||
maxY={100}
|
||||
unit="%"
|
||||
/>
|
||||
<StatsChart
|
||||
data={memHistory}
|
||||
maxDataPoints={MAX_STATS_POINTS}
|
||||
label="Memory Usage"
|
||||
value={currentStats ? formatBytes(currentStats.memUsed) : '—'}
|
||||
subValue={currentStats ? `/ ${formatBytes(currentStats.memLimit)}` : undefined}
|
||||
color="emerald"
|
||||
maxY={currentStats?.memLimit || server.memory.max * 1024 * 1024}
|
||||
unit=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{navLinks
|
||||
.filter(link => hasPermission(link.permission))
|
||||
.map(link => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-5 hover:border-cyan-500/50 hover:bg-gray-800/60 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-lg bg-gray-800 group-hover:bg-cyan-500/20 transition-colors">
|
||||
<Icon size={20} className="text-gray-400 group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-200">{link.label}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</p>
|
||||
<p className="text-lg font-semibold text-gray-100 mt-1 capitalize">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
src/app/(app)/servers/[id]/players/page.tsx
Normal file
284
src/app/(app)/servers/[id]/players/page.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft, Users, UserPlus, UserMinus, Shield, ShieldOff,
|
||||
Ban, CheckCircle
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Modal from '@/components/Modal'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
|
||||
interface PlayerInfo {
|
||||
name: string
|
||||
uuid?: string
|
||||
isOp: boolean
|
||||
isWhitelisted: boolean
|
||||
isBanned: boolean
|
||||
isOnline: boolean
|
||||
}
|
||||
|
||||
export default function PlayersPage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
const { showToast } = useToast()
|
||||
const { hasPermission } = useAuth()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const [players, setPlayers] = useState<PlayerInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddPlayer, setShowAddPlayer] = useState(false)
|
||||
const [newPlayerName, setNewPlayerName] = useState('')
|
||||
const [addAction, setAddAction] = useState<'whitelist' | 'op'>('whitelist')
|
||||
|
||||
const fetchPlayers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/players`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPlayers(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load players', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlayers()
|
||||
const interval = setInterval(fetchPlayers, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchPlayers])
|
||||
|
||||
const handlePlayerAction = async (
|
||||
playerName: string,
|
||||
action: 'whitelist' | 'unwhitelist' | 'op' | 'deop' | 'ban' | 'unban'
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/players/${encodeURIComponent(playerName)}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`Player ${action}${action.endsWith('e') ? 'd' : 'ed'} successfully`, 'success')
|
||||
fetchPlayers()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || `Failed to ${action} player`, 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast(`Failed to ${action} player`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPlayer = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newPlayerName.trim()) return
|
||||
|
||||
await handlePlayerAction(newPlayerName.trim(), addAction)
|
||||
setNewPlayerName('')
|
||||
setShowAddPlayer(false)
|
||||
}
|
||||
|
||||
const handleBan = async (playerName: string) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Ban Player',
|
||||
message: 'Are you sure you want to ban',
|
||||
itemName: playerName,
|
||||
type: 'danger',
|
||||
confirmText: 'Ban Player',
|
||||
})
|
||||
|
||||
if (confirmed) {
|
||||
await handlePlayerAction(playerName, 'ban')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onlinePlayers = players.filter(p => p.isOnline)
|
||||
const offlinePlayers = players.filter(p => !p.isOnline)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Players"
|
||||
description="Manage whitelist, ops, and bans"
|
||||
icon={Users}
|
||||
actions={
|
||||
<Button icon={UserPlus} onClick={() => setShowAddPlayer(true)}>
|
||||
Add Player
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Online Players */}
|
||||
{onlinePlayers.length > 0 && (
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-emerald-400 rounded-full" />
|
||||
Online ({onlinePlayers.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700">
|
||||
{onlinePlayers.map(player => (
|
||||
<PlayerRow
|
||||
key={player.name}
|
||||
player={player}
|
||||
hasPermission={hasPermission}
|
||||
onAction={handlePlayerAction}
|
||||
onBan={handleBan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Known Players */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||
<h3 className="text-sm font-semibold text-gray-200">
|
||||
All Players ({players.length})
|
||||
</h3>
|
||||
</div>
|
||||
{players.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Users size={40} className="mx-auto mb-3 text-gray-600" />
|
||||
<p className="font-medium">No players found</p>
|
||||
<p className="text-sm mt-1">Add players to the whitelist or start the server to see players.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700">
|
||||
{[...onlinePlayers, ...offlinePlayers].map(player => (
|
||||
<PlayerRow
|
||||
key={player.name}
|
||||
player={player}
|
||||
hasPermission={hasPermission}
|
||||
onAction={handlePlayerAction}
|
||||
onBan={handleBan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Player Modal */}
|
||||
<Modal isOpen={showAddPlayer} onClose={() => setShowAddPlayer(false)} title="Add Player">
|
||||
<form onSubmit={handleAddPlayer} className="space-y-4">
|
||||
<Input
|
||||
label="Player Name"
|
||||
value={newPlayerName}
|
||||
onChange={e => setNewPlayerName(e.target.value)}
|
||||
placeholder="Steve"
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddAction('whitelist')}
|
||||
className={`flex-1 p-3 rounded-lg border text-sm font-medium transition-colors ${
|
||||
addAction === 'whitelist'
|
||||
? 'bg-cyan-500/20 border-cyan-500/50 text-cyan-400'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Add to Whitelist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddAction('op')}
|
||||
className={`flex-1 p-3 rounded-lg border text-sm font-medium transition-colors ${
|
||||
addAction === 'op'
|
||||
? 'bg-amber-500/20 border-amber-500/50 text-amber-400'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
Make Operator
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" type="button" onClick={() => setShowAddPlayer(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Player</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayerRow({
|
||||
player,
|
||||
hasPermission,
|
||||
onAction,
|
||||
onBan,
|
||||
}: {
|
||||
player: PlayerInfo
|
||||
hasPermission: (p: string) => boolean
|
||||
onAction: (name: string, action: 'whitelist' | 'unwhitelist' | 'op' | 'deop' | 'ban' | 'unban') => void
|
||||
onBan: (name: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${player.isOnline ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-200">{player.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{player.isOp && <Badge variant="warning">OP</Badge>}
|
||||
{player.isWhitelisted && <Badge variant="info">Whitelisted</Badge>}
|
||||
{player.isBanned && <Badge variant="error">Banned</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPermission('players:whitelist') && (
|
||||
player.isWhitelisted ? (
|
||||
<Button size="sm" variant="ghost" icon={UserMinus} onClick={() => onAction(player.name, 'unwhitelist')} />
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" icon={UserPlus} onClick={() => onAction(player.name, 'whitelist')} />
|
||||
)
|
||||
)}
|
||||
{hasPermission('players:op') && (
|
||||
player.isOp ? (
|
||||
<Button size="sm" variant="ghost" icon={ShieldOff} onClick={() => onAction(player.name, 'deop')} />
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" icon={Shield} onClick={() => onAction(player.name, 'op')} />
|
||||
)
|
||||
)}
|
||||
{hasPermission('players:ban') && (
|
||||
player.isBanned ? (
|
||||
<Button size="sm" variant="ghost" icon={CheckCircle} onClick={() => onAction(player.name, 'unban')} />
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" icon={Ban} onClick={() => onBan(player.name)} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
src/app/(app)/servers/[id]/plugins/page.tsx
Normal file
221
src/app/(app)/servers/[id]/plugins/page.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ArrowLeft, Puzzle, Upload, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
import { formatFileSize } from '@/lib/date-utils'
|
||||
|
||||
interface PluginInfo {
|
||||
name: string
|
||||
filename: string
|
||||
size: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const params = useParams()
|
||||
const serverId = params.id as string
|
||||
const { showToast } = useToast()
|
||||
const { hasPermission } = useAuth()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/plugins`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPlugins(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to load plugins', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins()
|
||||
}, [fetchPlugins])
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!file.name.endsWith('.jar')) {
|
||||
showToast('Only .jar files are allowed', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/plugins`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Plugin uploaded successfully', 'success')
|
||||
fetchPlugins()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to upload plugin', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to upload plugin', 'error')
|
||||
}
|
||||
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleToggle = async (plugin: PluginInfo) => {
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/plugins/${encodeURIComponent(plugin.filename)}/toggle`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`Plugin ${plugin.enabled ? 'disabled' : 'enabled'}`, 'success')
|
||||
fetchPlugins()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to toggle plugin', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to toggle plugin', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (plugin: PluginInfo) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Remove Plugin',
|
||||
message: 'Are you sure you want to permanently remove',
|
||||
itemName: plugin.name,
|
||||
type: 'danger',
|
||||
confirmText: 'Remove',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/plugins/${encodeURIComponent(plugin.filename)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Plugin removed', 'success')
|
||||
fetchPlugins()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to remove plugin', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to remove plugin', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/servers/${serverId}`}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Server
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title="Plugins"
|
||||
description="Manage server plugins (Bukkit/Spigot/Paper)"
|
||||
icon={Puzzle}
|
||||
actions={
|
||||
hasPermission('plugins:install') ? (
|
||||
<label className="cursor-pointer">
|
||||
<Button icon={Upload} as="span">
|
||||
Upload Plugin
|
||||
</Button>
|
||||
<input type="file" accept=".jar" className="hidden" onChange={handleUpload} />
|
||||
</label>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{plugins.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Puzzle size={40} className="mx-auto mb-3 text-gray-600" />
|
||||
<p className="font-medium">No plugins installed</p>
|
||||
<p className="text-sm mt-1">Upload a .jar file to install a plugin.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Plugin</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Size</th>
|
||||
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||
<th className="text-right text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{plugins.map(plugin => (
|
||||
<tr key={plugin.filename} className="hover:bg-gray-800">
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm font-medium text-gray-200">{plugin.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{plugin.filename}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400">{formatFileSize(plugin.size)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={plugin.enabled ? 'success' : 'neutral'}>
|
||||
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{hasPermission('plugins:toggle') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={plugin.enabled ? ToggleLeft : ToggleRight}
|
||||
onClick={() => handleToggle(plugin)}
|
||||
>
|
||||
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('plugins:remove') && (
|
||||
<Button size="sm" variant="danger" icon={Trash2} onClick={() => handleRemove(plugin)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
src/app/(app)/servers/page.tsx
Normal file
171
src/app/(app)/servers/page.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { Server } from 'lucide-react'
|
||||
import DataManagementTemplate, { Column } from '@/components/templates/DataManagementTemplate'
|
||||
import ServerStatusBadge from '@/components/ServerStatusBadge'
|
||||
import Modal from '@/components/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import type { Server as ServerData, ServerFormData } from '@/types/server'
|
||||
|
||||
const serverTypeOptions = [
|
||||
{ label: 'Vanilla', value: 'vanilla' },
|
||||
{ label: 'Bukkit (Paper/Spigot)', value: 'bukkit' },
|
||||
{ label: 'Forge', value: 'forge' },
|
||||
{ label: 'Fabric', value: 'fabric' },
|
||||
]
|
||||
|
||||
export default function ServersPage() {
|
||||
const [servers, setServers] = useState<ServerData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const { hasPermission } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/servers', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setServers(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to fetch servers', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers()
|
||||
}, [fetchServers])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
const body: ServerFormData = {
|
||||
name: form.get('name') as string,
|
||||
type: form.get('type') as ServerData['type'],
|
||||
version: form.get('version') as string,
|
||||
port: Number(form.get('port')),
|
||||
maxPlayers: Number(form.get('maxPlayers')) || 20,
|
||||
memory: {
|
||||
min: Number(form.get('memoryMin')) || 512,
|
||||
max: Number(form.get('memoryMax')) || 1024,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Server created successfully', 'success')
|
||||
setShowCreate(false)
|
||||
fetchServers()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to create server', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to create server', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<ServerData>[] = [
|
||||
{ key: 'name', label: 'Name', sortable: true },
|
||||
{ key: 'type', label: 'Type', sortable: true, render: (s) => <span className="capitalize">{s.type}</span> },
|
||||
{ key: 'version', label: 'Version', sortable: true },
|
||||
{ key: 'port', label: 'Port' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (s) => <ServerStatusBadge status={s.status} />,
|
||||
},
|
||||
{ key: 'maxPlayers', label: 'Max Players' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataManagementTemplate<ServerData>
|
||||
title="Servers"
|
||||
description="Manage your Minecraft server instances"
|
||||
icon={Server}
|
||||
items={servers}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
getRowKey={(s) => s._id}
|
||||
searchPlaceholder="Search servers..."
|
||||
searchFields={['name', 'type', 'version']}
|
||||
filters={[
|
||||
{
|
||||
label: 'All Types',
|
||||
filterKey: 'type',
|
||||
options: serverTypeOptions,
|
||||
},
|
||||
{
|
||||
label: 'All Statuses',
|
||||
filterKey: 'status',
|
||||
options: [
|
||||
{ label: 'Online', value: 'online' },
|
||||
{ label: 'Offline', value: 'offline' },
|
||||
{ label: 'Starting', value: 'starting' },
|
||||
{ label: 'Stopping', value: 'stopping' },
|
||||
{ label: 'Crashed', value: 'crashed' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
onRowClick={(s) => router.push(`/servers/${s._id}`)}
|
||||
onAdd={() => setShowCreate(true)}
|
||||
addLabel="Create Server"
|
||||
canAdd={hasPermission('servers:create')}
|
||||
emptyIcon={Server}
|
||||
emptyTitle="No servers yet"
|
||||
emptyDescription="Create your first Minecraft server to get started."
|
||||
/>
|
||||
|
||||
{/* Create Server Modal */}
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Create Server" maxWidth="max-w-xl">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input name="name" label="Server Name" placeholder="My Minecraft Server" required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select name="type" label="Server Type" options={serverTypeOptions} required />
|
||||
<Input name="version" label="MC Version" placeholder="1.21.4" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input name="port" label="Port" type="number" placeholder="25565" required />
|
||||
<Input name="memoryMin" label="Min RAM (MB)" type="number" placeholder="512" />
|
||||
<Input name="memoryMax" label="Max RAM (MB)" type="number" placeholder="1024" />
|
||||
</div>
|
||||
<Input name="maxPlayers" label="Max Players" type="number" placeholder="20" />
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={creating}>
|
||||
Create Server
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
280
src/app/(app)/users/page.tsx
Normal file
280
src/app/(app)/users/page.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||
import { Users, Edit, Trash2 } from 'lucide-react'
|
||||
import DataManagementTemplate, { Column } from '@/components/templates/DataManagementTemplate'
|
||||
import Drawer from '@/components/Drawer'
|
||||
import Modal from '@/components/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Select from '@/components/ui/Select'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { formatDateTime } from '@/lib/date-utils'
|
||||
import type { User } from '@/types/user'
|
||||
import type { Role } from '@/types/user'
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const { hasPermission } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const { showConfirmation } = useConfirmation()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [usersRes, rolesRes] = await Promise.all([
|
||||
fetch('/api/users', { credentials: 'include' }),
|
||||
fetch('/api/roles', { credentials: 'include' }),
|
||||
])
|
||||
|
||||
if (usersRes.ok) {
|
||||
const data = await usersRes.json()
|
||||
setUsers(data.data || [])
|
||||
}
|
||||
if (rolesRes.ok) {
|
||||
const data = await rolesRes.json()
|
||||
setRoles(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to fetch data', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
const form = new FormData(e.currentTarget)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: form.get('username'),
|
||||
email: form.get('email'),
|
||||
password: form.get('password'),
|
||||
roles: [form.get('role')].filter(Boolean),
|
||||
status: 'active',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('User created successfully', 'success')
|
||||
setShowCreate(false)
|
||||
fetchData()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to create user', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to create user', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!selectedUser) return
|
||||
setSaving(true)
|
||||
const form = new FormData(e.currentTarget)
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
username: form.get('username'),
|
||||
email: form.get('email'),
|
||||
roles: [form.get('role')].filter(Boolean),
|
||||
status: form.get('status'),
|
||||
}
|
||||
|
||||
const password = form.get('password') as string
|
||||
if (password) body.password = password
|
||||
|
||||
const res = await fetch(`/api/users/${selectedUser._id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('User updated successfully', 'success')
|
||||
setShowEdit(false)
|
||||
setSelectedUser(null)
|
||||
fetchData()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to update user', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to update user', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (user: User) => {
|
||||
const confirmed = await showConfirmation({
|
||||
title: 'Delete User',
|
||||
message: 'Are you sure you want to permanently delete',
|
||||
itemName: user.username,
|
||||
type: 'danger',
|
||||
confirmText: 'Delete User',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/users/${user._id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
showToast('User deleted', 'success')
|
||||
fetchData()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
showToast(data.error || 'Failed to delete user', 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to delete user', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
{ label: 'Locked', value: 'locked' },
|
||||
]
|
||||
|
||||
const columns: Column<User>[] = [
|
||||
{ key: 'username', label: 'Username', sortable: true },
|
||||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (u) => (
|
||||
<Badge variant={u.status === 'active' ? 'success' : u.status === 'locked' ? 'error' : 'neutral'}>
|
||||
{u.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastLogin',
|
||||
label: 'Last Login',
|
||||
sortable: true,
|
||||
render: (u) => <span className="text-gray-400">{u.lastLogin ? formatDateTime(u.lastLogin) : 'Never'}</span>,
|
||||
},
|
||||
{
|
||||
key: '_id',
|
||||
label: 'Actions',
|
||||
render: (u) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasPermission('users:edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={Edit}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedUser(u)
|
||||
setShowEdit(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('users:delete') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={Trash2}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(u)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const roleOptions = roles.map(r => ({ label: r.name, value: r._id }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataManagementTemplate<User>
|
||||
title="Users"
|
||||
description="Manage user accounts and permissions"
|
||||
icon={Users}
|
||||
items={users}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
getRowKey={(u) => u._id}
|
||||
searchPlaceholder="Search users..."
|
||||
searchFields={['username', 'email']}
|
||||
filters={[
|
||||
{ label: 'All Statuses', filterKey: 'status', options: statusOptions },
|
||||
]}
|
||||
onRowClick={(u) => {
|
||||
setSelectedUser(u)
|
||||
setShowEdit(true)
|
||||
}}
|
||||
onAdd={() => setShowCreate(true)}
|
||||
addLabel="Create User"
|
||||
canAdd={hasPermission('users:create')}
|
||||
emptyIcon={Users}
|
||||
emptyTitle="No users yet"
|
||||
emptyDescription="Create the first user account."
|
||||
/>
|
||||
|
||||
{/* Create User Modal */}
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Create User" maxWidth="max-w-lg">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<Input name="username" label="Username" placeholder="johndoe" required />
|
||||
<Input name="email" label="Email" type="email" placeholder="john@example.com" required />
|
||||
<Input name="password" label="Password" type="password" placeholder="Minimum 8 characters" required />
|
||||
<Select name="role" label="Role" options={roleOptions} placeholder="Select a role" />
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button type="submit" loading={saving}>Create User</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit User Drawer */}
|
||||
<Drawer isOpen={showEdit} onClose={() => { setShowEdit(false); setSelectedUser(null) }} title={`Edit User — ${selectedUser?.username}`}>
|
||||
{selectedUser && (
|
||||
<form onSubmit={handleUpdate} className="space-y-4">
|
||||
<Input name="username" label="Username" defaultValue={selectedUser.username} required />
|
||||
<Input name="email" label="Email" type="email" defaultValue={selectedUser.email} required />
|
||||
<Input name="password" label="New Password" type="password" placeholder="Leave blank to keep current" />
|
||||
<Select name="role" label="Role" options={roleOptions} defaultValue={(selectedUser.roles as string[])?.[0] || ''} placeholder="Select a role" />
|
||||
<Select name="status" label="Status" options={statusOptions} defaultValue={selectedUser.status} />
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" type="button" onClick={() => { setShowEdit(false); setSelectedUser(null) }}>Cancel</Button>
|
||||
<Button type="submit" loading={saving}>Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/app/api/audit/route.ts
Normal file
61
src/app/api/audit/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { AuditLog } from '@/lib/models'
|
||||
|
||||
// GET /api/audit — List audit logs (paginated)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'audit:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Math.max(1, Number(searchParams.get('page')) || 1)
|
||||
const limit = Math.min(100, Math.max(1, Number(searchParams.get('limit')) || 50))
|
||||
const search = searchParams.get('search') || ''
|
||||
const entityType = searchParams.get('entityType') || ''
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
const filter: Record<string, unknown> = {}
|
||||
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ action: { $regex: search, $options: 'i' } },
|
||||
{ entityName: { $regex: search, $options: 'i' } },
|
||||
{ userName: { $regex: search, $options: 'i' } },
|
||||
]
|
||||
}
|
||||
|
||||
if (entityType) {
|
||||
filter.entityType = entityType
|
||||
}
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
AuditLog.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.lean(),
|
||||
AuditLog.countDocuments(filter),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fetch audit logs error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
123
src/app/api/auth/login/route.ts
Normal file
123
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { User } from '@/lib/models'
|
||||
import { verifyPassword, generateAccessToken, generate2FACode, hash2FACode } from '@/lib/auth'
|
||||
import { sanitizeObject } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { send2FAEmail } from '@/lib/email'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const raw = await request.json()
|
||||
const { username, password } = sanitizeObject(raw)
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
const user = await User.findOne({
|
||||
$or: [{ username }, { email: username }],
|
||||
}).select('+passwordHash +loginAttempts +lockUntil +twoFactorCode +twoFactorExpiry')
|
||||
|
||||
if (!user) {
|
||||
await createAuditLog({
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityName: username,
|
||||
clientIP,
|
||||
status: 'failure',
|
||||
statusCode: 401,
|
||||
})
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check account lockout
|
||||
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||
const minutesLeft = Math.ceil((user.lockUntil.getTime() - Date.now()) / 60000)
|
||||
return NextResponse.json(
|
||||
{ error: `Account locked. Try again in ${minutesLeft} minutes.` },
|
||||
{ status: 423 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check status
|
||||
if (user.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Account is not active' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const valid = await verifyPassword(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
user.loginAttempts = (user.loginAttempts || 0) + 1
|
||||
|
||||
if (user.loginAttempts >= 5) {
|
||||
user.lockUntil = new Date(Date.now() + 30 * 60 * 1000) // 30 min lock
|
||||
user.loginAttempts = 0
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
await createAuditLog({
|
||||
action: 'login_failed',
|
||||
entityType: 'user',
|
||||
entityId: user._id.toString(),
|
||||
entityName: user.username,
|
||||
userId: user._id.toString(),
|
||||
userName: user.username,
|
||||
userEmail: user.email,
|
||||
clientIP,
|
||||
status: 'failure',
|
||||
statusCode: 401,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Generate and send 2FA code
|
||||
const code = generate2FACode()
|
||||
const hashedCode = hash2FACode(code)
|
||||
|
||||
user.twoFactorCode = hashedCode
|
||||
user.twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1000) // 10 min expiry
|
||||
user.loginAttempts = 0
|
||||
user.lockUntil = null
|
||||
await user.save()
|
||||
|
||||
// Send 2FA email
|
||||
try {
|
||||
await send2FAEmail(user.email, code, user.username)
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send 2FA email:', emailError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send verification email. Please try again.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate a temporary token for the 2FA step
|
||||
const tempToken = generateAccessToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||
|
||||
const response = NextResponse.json({
|
||||
requiresTwoFactor: true,
|
||||
message: 'Verification code sent to your email',
|
||||
})
|
||||
|
||||
// Set a temporary cookie for the 2FA step
|
||||
response.cookies.set('pending_2fa', tempToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 600, // 10 minutes
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
8
src/app/api/auth/logout/route.ts
Normal file
8
src/app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { clearAuthCookies } from '@/lib/auth'
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true })
|
||||
clearAuthCookies(response)
|
||||
return response
|
||||
}
|
||||
25
src/app/api/auth/me/route.ts
Normal file
25
src/app/api/auth/me/route.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession } from '@/lib/auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: session._id,
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
permissions: session.permissions,
|
||||
roles: session.roles,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Session check error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
46
src/app/api/auth/refresh/route.ts
Normal file
46
src/app/api/auth/refresh/route.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { User } from '@/lib/models'
|
||||
import { verifyRefreshToken, generateAccessToken, generateRefreshToken, setAuthCookies } from '@/lib/auth'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const refreshToken = request.cookies.get('refresh_token')?.value
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
|
||||
}
|
||||
|
||||
let payload: { userId: string }
|
||||
try {
|
||||
payload = verifyRefreshToken(refreshToken) as { userId: string }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid refresh token' }, { status: 401 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
const user = await User.findById(payload.userId)
|
||||
if (!user || user.status !== 'active') {
|
||||
return NextResponse.json({ error: 'User not found or inactive' }, { status: 401 })
|
||||
}
|
||||
|
||||
const newAccessToken = generateAccessToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||
const newRefreshToken = generateRefreshToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
},
|
||||
})
|
||||
|
||||
setAuthCookies(response, newAccessToken, newRefreshToken)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
110
src/app/api/auth/verify-2fa/route.ts
Normal file
110
src/app/api/auth/verify-2fa/route.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { User } from '@/lib/models'
|
||||
import {
|
||||
verifyAccessToken, verify2FACode, generateAccessToken,
|
||||
generateRefreshToken, setAuthCookies
|
||||
} from '@/lib/auth'
|
||||
import { sanitizeObject } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const raw = await request.json()
|
||||
const { code } = sanitizeObject(raw)
|
||||
|
||||
if (!code || code.length !== 6) {
|
||||
return NextResponse.json({ error: 'A valid 6-digit code is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify pending 2FA token
|
||||
const pendingToken = request.cookies.get('pending_2fa')?.value
|
||||
if (!pendingToken) {
|
||||
return NextResponse.json({ error: 'No pending verification. Please log in again.' }, { status: 401 })
|
||||
}
|
||||
|
||||
let payload: { userId: string }
|
||||
try {
|
||||
payload = verifyAccessToken(pendingToken) as { userId: string }
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Verification expired. Please log in again.' }, { status: 401 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
const user = await User.findById(payload.userId)
|
||||
.select('+twoFactorCode +twoFactorExpiry')
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check 2FA expiry
|
||||
if (!user.twoFactorExpiry || user.twoFactorExpiry < new Date()) {
|
||||
return NextResponse.json({ error: 'Verification code expired. Please log in again.' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify code
|
||||
if (!user.twoFactorCode || !verify2FACode(code, user.twoFactorCode)) {
|
||||
await createAuditLog({
|
||||
action: '2fa_failed',
|
||||
entityType: 'user',
|
||||
entityId: user._id.toString(),
|
||||
entityName: user.username,
|
||||
userId: user._id.toString(),
|
||||
userName: user.username,
|
||||
userEmail: user.email,
|
||||
clientIP,
|
||||
status: 'failure',
|
||||
statusCode: 401,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Invalid verification code' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Clear 2FA code
|
||||
user.twoFactorCode = null
|
||||
user.twoFactorExpiry = null
|
||||
user.lastLogin = new Date()
|
||||
await user.save()
|
||||
|
||||
// Generate session tokens
|
||||
const accessToken = generateAccessToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||
const refreshToken = generateRefreshToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||
|
||||
await createAuditLog({
|
||||
action: 'login_success',
|
||||
entityType: 'user',
|
||||
entityId: user._id.toString(),
|
||||
entityName: user.username,
|
||||
userId: user._id.toString(),
|
||||
userName: user.username,
|
||||
userEmail: user.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
},
|
||||
})
|
||||
|
||||
setAuthCookies(response, accessToken, refreshToken)
|
||||
|
||||
// Clear pending token
|
||||
response.cookies.delete('pending_2fa')
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('2FA verify error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
19
src/app/api/health/route.ts
Normal file
19
src/app/api/health/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
|
||||
// GET /api/health — Public health check endpoint
|
||||
export async function GET() {
|
||||
try {
|
||||
await connectToDatabase()
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', message: 'Database connection failed' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
161
src/app/api/roles/[id]/route.ts
Normal file
161
src/app/api/roles/[id]/route.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Role, User } from '@/lib/models'
|
||||
import { sanitizeObject, isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// GET /api/roles/[id]
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'roles:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid role ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const role = await Role.findById(id).lean()
|
||||
|
||||
if (!role) {
|
||||
return NextResponse.json({ error: 'Role not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: role })
|
||||
} catch (error) {
|
||||
console.error('Fetch role error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/roles/[id]
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'roles:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid role ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const body = sanitizeObject(raw)
|
||||
|
||||
await connectToDatabase()
|
||||
const role = await Role.findById(id)
|
||||
|
||||
if (!role) {
|
||||
return NextResponse.json({ error: 'Role not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const previousValues = role.toObject()
|
||||
|
||||
if (body.name) role.name = body.name
|
||||
if (body.description !== undefined) role.description = body.description
|
||||
if (body.permissions) role.permissions = body.permissions
|
||||
|
||||
await role.save()
|
||||
|
||||
await createAuditLog({
|
||||
action: 'role_updated',
|
||||
entityType: 'role',
|
||||
entityId: role._id.toString(),
|
||||
entityName: role.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues,
|
||||
newValues: body,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: role })
|
||||
} catch (error) {
|
||||
console.error('Update role error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/roles/[id]
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'roles:delete')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid role ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const role = await Role.findById(id)
|
||||
|
||||
if (!role) {
|
||||
return NextResponse.json({ error: 'Role not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (role.isDefault) {
|
||||
return NextResponse.json({ error: 'Cannot delete the default role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Remove role from all users that have it
|
||||
await User.updateMany({ roles: id }, { $pull: { roles: id } })
|
||||
|
||||
await Role.findByIdAndDelete(id)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'role_deleted',
|
||||
entityType: 'role',
|
||||
entityId: role._id.toString(),
|
||||
entityName: role.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: role.toObject(),
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete role error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
86
src/app/api/roles/route.ts
Normal file
86
src/app/api/roles/route.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Role } from '@/lib/models'
|
||||
import { sanitizeObject } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// GET /api/roles — List all roles
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'roles:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const roles = await Role.find().sort({ name: 1 }).lean()
|
||||
|
||||
return NextResponse.json({ success: true, data: roles })
|
||||
} catch (error) {
|
||||
console.error('Fetch roles error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/roles — Create a new role
|
||||
export async function POST(request: NextRequest) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'roles:create')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const { name, description, permissions } = sanitizeObject(raw)
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Role name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
const existing = await Role.findOne({ name })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Role name already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
const role = new Role({
|
||||
name,
|
||||
description: description || '',
|
||||
permissions: permissions || [],
|
||||
isDefault: false,
|
||||
})
|
||||
|
||||
await role.save()
|
||||
|
||||
await createAuditLog({
|
||||
action: 'role_created',
|
||||
entityType: 'role',
|
||||
entityId: role._id.toString(),
|
||||
entityName: role.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
newValues: { name, description, permissions },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 201,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: role }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create role error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
82
src/app/api/servers/[id]/backups/[backupId]/restore/route.ts
Normal file
82
src/app/api/servers/[id]/backups/[backupId]/restore/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Backup, Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName, getServerPath } from '@/lib/docker'
|
||||
import { execSync } from 'child_process'
|
||||
import path from 'path'
|
||||
|
||||
// POST /api/servers/[id]/backups/[backupId]/restore — Restore a backup
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; backupId: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'backups:restore')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, backupId } = await params
|
||||
if (!isValidObjectId(id) || !isValidObjectId(backupId)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const backup = await Backup.findOne({ _id: backupId, serverId: id })
|
||||
if (!backup || backup.status !== 'completed') {
|
||||
return NextResponse.json({ error: 'Backup not found or incomplete' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serverDir = getServerPath(server._id.toString())
|
||||
|
||||
// Stop server if running
|
||||
if (server.status === 'online') {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
await container.stop({ t: 30 })
|
||||
await Server.findByIdAndUpdate(id, { status: 'offline' })
|
||||
}
|
||||
}
|
||||
|
||||
// Replace world with backup
|
||||
try {
|
||||
execSync(`rm -rf "${path.join(serverDir, 'world')}"`, { timeout: 60000 })
|
||||
execSync(`tar -xzf "${backup.filePath}" -C "${serverDir}"`, { timeout: 300000 })
|
||||
} catch (restoreError) {
|
||||
console.error('Restore failed:', restoreError)
|
||||
return NextResponse.json({ error: 'Failed to restore backup' }, { status: 500 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: 'backup_restored',
|
||||
entityType: 'backup',
|
||||
entityId: backup._id.toString(),
|
||||
entityName: backup.filename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Backup restored. Start the server to use it.' })
|
||||
} catch (error) {
|
||||
console.error('Restore backup error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
63
src/app/api/servers/[id]/backups/[backupId]/route.ts
Normal file
63
src/app/api/servers/[id]/backups/[backupId]/route.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Backup } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { unlink } from 'fs/promises'
|
||||
|
||||
// DELETE /api/servers/[id]/backups/[backupId] — Delete a backup
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; backupId: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'backups:delete')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, backupId } = await params
|
||||
if (!isValidObjectId(id) || !isValidObjectId(backupId)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const backup = await Backup.findOne({ _id: backupId, serverId: id })
|
||||
if (!backup) {
|
||||
return NextResponse.json({ error: 'Backup not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(backup.filePath)
|
||||
} catch {
|
||||
// File may already be gone
|
||||
}
|
||||
|
||||
await Backup.findByIdAndDelete(backupId)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'backup_deleted',
|
||||
entityType: 'backup',
|
||||
entityId: backup._id.toString(),
|
||||
entityName: backup.filename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete backup error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
153
src/app/api/servers/[id]/backups/route.ts
Normal file
153
src/app/api/servers/[id]/backups/route.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server, Backup } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName, getServerPath } from '@/lib/docker'
|
||||
import { execSync } from 'child_process'
|
||||
import path from 'path'
|
||||
import { stat } from 'fs/promises'
|
||||
|
||||
// GET /api/servers/[id]/backups — List backups
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'backups:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const backups = await Backup.find({ serverId: id }).sort({ createdAt: -1 }).lean()
|
||||
|
||||
return NextResponse.json({ success: true, data: backups })
|
||||
} catch (error) {
|
||||
console.error('Fetch backups error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/servers/[id]/backups — Create a manual backup
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'backups:create')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serverDir = getServerPath(server._id.toString())
|
||||
const backupsDir = path.join(serverDir, 'backups')
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `backup-${timestamp}.tar.gz`
|
||||
const filePath = path.join(backupsDir, filename)
|
||||
|
||||
// Create backup record
|
||||
const backup = new Backup({
|
||||
serverId: server._id,
|
||||
filename,
|
||||
filePath,
|
||||
fileSize: 0,
|
||||
type: 'manual',
|
||||
status: 'in_progress',
|
||||
createdBy: session._id,
|
||||
})
|
||||
await backup.save()
|
||||
|
||||
try {
|
||||
// If server is online, pause saving
|
||||
if (server.status === 'online') {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
try {
|
||||
const saveOff = await container.exec({ Cmd: ['rcon-cli', 'save-off'], AttachStdout: true })
|
||||
await saveOff.start({ Detach: false })
|
||||
const saveAll = await container.exec({ Cmd: ['rcon-cli', 'save-all', 'flush'], AttachStdout: true })
|
||||
await saveAll.start({ Detach: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)) // Wait for save
|
||||
} catch {
|
||||
// RCON may not be available — continue anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup directory if needed
|
||||
execSync(`mkdir -p "${backupsDir}"`)
|
||||
|
||||
// Create tar.gz of world directory
|
||||
execSync(`tar -czf "${filePath}" -C "${serverDir}" world`, { timeout: 300000 })
|
||||
|
||||
// Resume saving if online
|
||||
if (server.status === 'online') {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
try {
|
||||
const saveOn = await container.exec({ Cmd: ['rcon-cli', 'save-on'], AttachStdout: true })
|
||||
await saveOn.start({ Detach: false })
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Get file size
|
||||
const stats = await stat(filePath)
|
||||
backup.fileSize = stats.size
|
||||
backup.status = 'completed'
|
||||
await backup.save()
|
||||
} catch (backupError) {
|
||||
console.error('Backup creation failed:', backupError)
|
||||
backup.status = 'failed'
|
||||
await backup.save()
|
||||
|
||||
return NextResponse.json({ error: 'Backup creation failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: 'backup_created',
|
||||
entityType: 'backup',
|
||||
entityId: backup._id.toString(),
|
||||
entityName: filename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 201,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: backup }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create backup error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
134
src/app/api/servers/[id]/configuration/route.ts
Normal file
134
src/app/api/servers/[id]/configuration/route.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { sanitizeObject, isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// GET /api/servers/[id]/configuration — Read server.properties + JVM args
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serverDir = getServerPath(server._id.toString())
|
||||
const propsPath = path.join(serverDir, 'server.properties')
|
||||
|
||||
const properties: Record<string, string> = {}
|
||||
|
||||
try {
|
||||
const content = await readFile(propsPath, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const eqIndex = trimmed.indexOf('=')
|
||||
if (eqIndex > 0) {
|
||||
properties[trimmed.substring(0, eqIndex)] = trimmed.substring(eqIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet — that's OK
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
properties,
|
||||
jvmArgs: server.jvmArgs || [],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fetch config error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/servers/[id]/configuration — Save server.properties + JVM args
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const { properties, jvmArgs } = sanitizeObject(raw)
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serverDir = getServerPath(server._id.toString())
|
||||
const propsPath = path.join(serverDir, 'server.properties')
|
||||
|
||||
// Write server.properties
|
||||
if (properties && typeof properties === 'object') {
|
||||
const lines = Object.entries(properties).map(([k, v]) => `${k}=${v}`)
|
||||
await writeFile(propsPath, lines.join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
// Update JVM args
|
||||
if (Array.isArray(jvmArgs)) {
|
||||
server.jvmArgs = jvmArgs
|
||||
await server.save()
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_config_updated',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Save config error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
148
src/app/api/servers/[id]/console/route.ts
Normal file
148
src/app/api/servers/[id]/console/route.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
// GET /api/servers/[id]/console — SSE log stream
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:console')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (!container) {
|
||||
return NextResponse.json({ error: 'Container not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const logStream = await container.logs({
|
||||
follow: true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: 100,
|
||||
timestamps: true,
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
logStream.on('data', (chunk: Buffer) => {
|
||||
// Docker log lines have 8-byte header, strip it
|
||||
const line = chunk.slice(8).toString('utf-8').trim()
|
||||
if (line) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ line })}\n\n`))
|
||||
}
|
||||
})
|
||||
|
||||
logStream.on('end', () => {
|
||||
controller.close()
|
||||
})
|
||||
|
||||
logStream.on('error', () => {
|
||||
controller.close()
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
(logStream as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Console stream error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/servers/[id]/console — Send command
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:console')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { command } = await request.json()
|
||||
if (!command || typeof command !== 'string') {
|
||||
return NextResponse.json({ error: 'Command is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.status !== 'online') {
|
||||
return NextResponse.json({ error: 'Server is not running' }, { status: 400 })
|
||||
}
|
||||
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (!container) {
|
||||
return NextResponse.json({ error: 'Container not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Send command to server stdin via mc-send-to-console
|
||||
const exec = await container.exec({
|
||||
Cmd: ['mc-send-to-console', command.trim()],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
User: '1000',
|
||||
})
|
||||
|
||||
const execStream = await exec.start({ Detach: false })
|
||||
|
||||
// Wait for exec to finish
|
||||
await new Promise<void>((resolve) => {
|
||||
execStream.on('end', resolve)
|
||||
execStream.on('error', () => resolve())
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
|
||||
// Output will appear in server stdout → SSE stream picks it up
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Console command error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
669
src/app/api/servers/[id]/files/route.ts
Normal file
669
src/app/api/servers/[id]/files/route.ts
Normal file
@ -0,0 +1,669 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { readdir, stat, readFile, writeFile, rm, cp, mkdir, rename } from 'fs/promises'
|
||||
import { join, resolve, relative, extname, dirname, basename } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.toml', '.cfg',
|
||||
'.conf', '.ini', '.xml', '.html', '.css', '.js', '.ts', '.md', '.csv',
|
||||
'.sh', '.bat', '.cmd', '.env', '.mcmeta', '.lang', '.nbt',
|
||||
])
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB read limit
|
||||
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024 // 500 MB upload limit
|
||||
|
||||
/**
|
||||
* Resolves a path and validates it stays within the server root.
|
||||
* Returns { serverRoot, targetPath, relativePath } or null if invalid.
|
||||
*/
|
||||
function resolveSafePath(serverId: string, requestedPath: string) {
|
||||
const serverRoot = resolve(getServerPath(serverId))
|
||||
const targetPath = resolve(serverRoot, requestedPath.replace(/^\/+/, ''))
|
||||
const relativePath = relative(serverRoot, targetPath)
|
||||
|
||||
if (relativePath.startsWith('..') || resolve(serverRoot, relativePath) !== targetPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { serverRoot, targetPath, relativePath }
|
||||
}
|
||||
|
||||
// GET /api/servers/[id]/files — Browse directory or read file
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const requestedPath = request.nextUrl.searchParams.get('path') || '/'
|
||||
const readContent = request.nextUrl.searchParams.get('read') === 'true'
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), requestedPath)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied — path outside server directory' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { targetPath, relativePath } = safe
|
||||
|
||||
const targetStat = await stat(targetPath).catch(() => null)
|
||||
if (!targetStat) {
|
||||
return NextResponse.json({ error: 'Path not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Read file contents
|
||||
if (readContent && targetStat.isFile()) {
|
||||
if (targetStat.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large to view (max 5 MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ext = extname(targetPath).toLowerCase()
|
||||
const isText = TEXT_EXTENSIONS.has(ext) || ext === ''
|
||||
|
||||
if (!isText) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
type: 'binary',
|
||||
name: relativePath.split('/').pop(),
|
||||
size: targetStat.size,
|
||||
message: 'Binary file — cannot be displayed',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const content = await readFile(targetPath, 'utf-8')
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
type: 'text',
|
||||
name: relativePath.split('/').pop(),
|
||||
content,
|
||||
size: targetStat.size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// List directory
|
||||
if (targetStat.isDirectory()) {
|
||||
const entries = await readdir(targetPath)
|
||||
const items = await Promise.all(
|
||||
entries.map(async (name) => {
|
||||
try {
|
||||
const entryStat = await stat(join(targetPath, name))
|
||||
return {
|
||||
name,
|
||||
path: relativePath ? `${relativePath}/${name}` : name,
|
||||
isDirectory: entryStat.isDirectory(),
|
||||
size: entryStat.isFile() ? entryStat.size : null,
|
||||
modifiedAt: entryStat.mtime.toISOString(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const validItems = items
|
||||
.filter(Boolean)
|
||||
// Directories first, then alphabetical
|
||||
.sort((a, b) => {
|
||||
if (a!.isDirectory !== b!.isDirectory) return a!.isDirectory ? -1 : 1
|
||||
return a!.name.localeCompare(b!.name)
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
path: relativePath || '/',
|
||||
items: validItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Single file stat (no read)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
type: 'file',
|
||||
name: relativePath.split('/').pop(),
|
||||
path: relativePath,
|
||||
size: targetStat.size,
|
||||
modifiedAt: targetStat.mtime.toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('File browser error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT — Edit file contents ─────────────────────────────────────
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { path: filePath, content } = body
|
||||
|
||||
if (!filePath || typeof content !== 'string') {
|
||||
return NextResponse.json({ error: 'Path and content are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), filePath)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||
if (!targetStat || !targetStat.isFile()) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await writeFile(safe.targetPath, content, 'utf-8')
|
||||
|
||||
return NextResponse.json({ success: true, message: 'File saved' })
|
||||
} catch (error) {
|
||||
console.error('File edit error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DELETE — Delete file or folder ───────────────────────────────
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { path: filePath } = await request.json()
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), filePath)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Prevent deleting the root directory
|
||||
if (safe.relativePath === '' || safe.relativePath === '.') {
|
||||
return NextResponse.json({ error: 'Cannot delete the server root directory' }, { status: 400 })
|
||||
}
|
||||
|
||||
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||
if (!targetStat) {
|
||||
return NextResponse.json({ error: 'Path not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await rm(safe.targetPath, { recursive: true, force: true })
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Deleted successfully' })
|
||||
} catch (error) {
|
||||
console.error('File delete error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST — Upload, copy, zip, unzip, create folder ──────────────
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serverRoot = resolve(getServerPath(server._id.toString()))
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
|
||||
// ── Upload (multipart/form-data) ──────────────────────────────
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const formData = await request.formData()
|
||||
const targetDir = (formData.get('path') as string) || '/'
|
||||
const files = formData.getAll('files') as File[]
|
||||
|
||||
if (!files.length) {
|
||||
return NextResponse.json({ error: 'No files provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), targetDir)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
await mkdir(safe.targetPath, { recursive: true })
|
||||
|
||||
const results: string[] = []
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_UPLOAD_SIZE) {
|
||||
results.push(`${file.name}: too large (max 500 MB)`)
|
||||
continue
|
||||
}
|
||||
|
||||
const destPath = join(safe.targetPath, file.name)
|
||||
if (!destPath.startsWith(serverRoot)) {
|
||||
results.push(`${file.name}: invalid path`)
|
||||
continue
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(destPath, buffer)
|
||||
results.push(`${file.name}: uploaded`)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: results }, { status: 201 })
|
||||
}
|
||||
|
||||
// ── JSON actions (copy, zip, unzip, create-folder) ────────────
|
||||
const body = await request.json()
|
||||
const { action } = body
|
||||
|
||||
// ── Copy ──────────────────────────────────────────────────────
|
||||
if (action === 'copy') {
|
||||
const { source, destination } = body
|
||||
if (!source || !destination) {
|
||||
return NextResponse.json({ error: 'Source and destination are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeSrc = resolveSafePath(server._id.toString(), source)
|
||||
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||
if (!safeSrc || !safeDest) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||
if (!srcStat) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existsSync(safeDest.targetPath)) {
|
||||
return NextResponse.json({ error: 'Destination already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
await cp(safeSrc.targetPath, safeDest.targetPath, { recursive: true })
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Copied successfully' })
|
||||
}
|
||||
|
||||
// ── Move ──────────────────────────────────────────────────────
|
||||
if (action === 'move') {
|
||||
const { source, destination } = body
|
||||
if (!source || !destination) {
|
||||
return NextResponse.json({ error: 'Source and destination are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeSrc = resolveSafePath(server._id.toString(), source)
|
||||
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||
if (!safeSrc || !safeDest) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (safeSrc.relativePath === '' || safeSrc.relativePath === '.') {
|
||||
return NextResponse.json({ error: 'Cannot move the server root directory' }, { status: 400 })
|
||||
}
|
||||
|
||||
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||
if (!srcStat) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existsSync(safeDest.targetPath)) {
|
||||
return NextResponse.json({ error: 'Destination already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await mkdir(dirname(safeDest.targetPath), { recursive: true })
|
||||
await rename(safeSrc.targetPath, safeDest.targetPath)
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Moved successfully' })
|
||||
}
|
||||
|
||||
// ── Move Multiple ─────────────────────────────────────────────
|
||||
if (action === 'move-multiple') {
|
||||
const { paths, destination } = body
|
||||
if (!Array.isArray(paths) || paths.length === 0 || !destination) {
|
||||
return NextResponse.json({ error: 'Paths array and destination are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||
if (!safeDest) {
|
||||
return NextResponse.json({ error: 'Destination access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
await mkdir(safeDest.targetPath, { recursive: true })
|
||||
|
||||
const results: { path: string; success: boolean; error?: string }[] = []
|
||||
for (const p of paths) {
|
||||
const safeSrc = resolveSafePath(server._id.toString(), p)
|
||||
if (!safeSrc || safeSrc.relativePath === '' || safeSrc.relativePath === '.') {
|
||||
results.push({ path: p, success: false, error: 'Access denied' })
|
||||
continue
|
||||
}
|
||||
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||
if (!srcStat) {
|
||||
results.push({ path: p, success: false, error: 'Not found' })
|
||||
continue
|
||||
}
|
||||
const destItemPath = join(safeDest.targetPath, basename(safeSrc.targetPath))
|
||||
if (existsSync(destItemPath)) {
|
||||
results.push({ path: p, success: false, error: 'Destination already exists' })
|
||||
continue
|
||||
}
|
||||
await rename(safeSrc.targetPath, destItemPath)
|
||||
results.push({ path: p, success: true })
|
||||
}
|
||||
|
||||
const moved = results.filter((r) => r.success).length
|
||||
return NextResponse.json({ success: true, message: `Moved ${moved} of ${paths.length} items`, data: results })
|
||||
}
|
||||
|
||||
// ── Create Folder ─────────────────────────────────────────────
|
||||
if (action === 'create-folder') {
|
||||
const { path: folderPath } = body
|
||||
if (!folderPath) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), folderPath)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (existsSync(safe.targetPath)) {
|
||||
return NextResponse.json({ error: 'Folder already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
await mkdir(safe.targetPath, { recursive: true })
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Folder created' })
|
||||
}
|
||||
|
||||
// ── Zip ───────────────────────────────────────────────────────
|
||||
if (action === 'zip') {
|
||||
const { path: targetPath } = body
|
||||
if (!targetPath) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), targetPath)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||
if (!targetStat) {
|
||||
return NextResponse.json({ error: 'Path not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const zipName = `${basename(safe.targetPath)}.zip`
|
||||
const zipPath = join(dirname(safe.targetPath), zipName)
|
||||
|
||||
if (existsSync(zipPath)) {
|
||||
return NextResponse.json({ error: `${zipName} already exists` }, { status: 409 })
|
||||
}
|
||||
|
||||
const isDir = targetStat.isDirectory()
|
||||
const cmd = isDir
|
||||
? `cd "${dirname(safe.targetPath)}" && zip -r "${zipPath}" "${basename(safe.targetPath)}"`
|
||||
: `cd "${dirname(safe.targetPath)}" && zip "${zipPath}" "${basename(safe.targetPath)}"`
|
||||
|
||||
await execAsync(cmd)
|
||||
|
||||
return NextResponse.json({ success: true, message: `Created ${zipName}` })
|
||||
}
|
||||
|
||||
// ── Unzip ─────────────────────────────────────────────────────
|
||||
if (action === 'unzip') {
|
||||
const { path: targetPath } = body
|
||||
if (!targetPath) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safe = resolveSafePath(server._id.toString(), targetPath)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||
if (!targetStat || !targetStat.isFile()) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const ext = extname(safe.targetPath).toLowerCase()
|
||||
const destDir = dirname(safe.targetPath)
|
||||
|
||||
if (ext === '.zip') {
|
||||
await execAsync(`unzip -o "${safe.targetPath}" -d "${destDir}"`)
|
||||
} else if (ext === '.gz' && safe.targetPath.endsWith('.tar.gz')) {
|
||||
await execAsync(`tar -xzf "${safe.targetPath}" -C "${destDir}"`)
|
||||
} else if (ext === '.tgz') {
|
||||
await execAsync(`tar -xzf "${safe.targetPath}" -C "${destDir}"`)
|
||||
} else if (ext === '.tar') {
|
||||
await execAsync(`tar -xf "${safe.targetPath}" -C "${destDir}"`)
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Unsupported archive format. Supported: .zip, .tar.gz, .tgz, .tar' }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Extracted successfully' })
|
||||
}
|
||||
|
||||
// ── Delete Multiple ────────────────────────────────────────────
|
||||
if (action === 'delete-multiple') {
|
||||
const { paths } = body
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
return NextResponse.json({ error: 'Paths array is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const results: { path: string; success: boolean; error?: string }[] = []
|
||||
for (const p of paths) {
|
||||
const safe = resolveSafePath(server._id.toString(), p)
|
||||
if (!safe || safe.relativePath === '' || safe.relativePath === '.') {
|
||||
results.push({ path: p, success: false, error: 'Access denied' })
|
||||
continue
|
||||
}
|
||||
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||
if (!targetStat) {
|
||||
results.push({ path: p, success: false, error: 'Not found' })
|
||||
continue
|
||||
}
|
||||
await rm(safe.targetPath, { recursive: true, force: true })
|
||||
results.push({ path: p, success: true })
|
||||
}
|
||||
|
||||
const deleted = results.filter((r) => r.success).length
|
||||
return NextResponse.json({ success: true, message: `Deleted ${deleted} of ${paths.length} items`, data: results })
|
||||
}
|
||||
|
||||
// ── Zip Multiple ──────────────────────────────────────────────
|
||||
if (action === 'zip-multiple') {
|
||||
const { paths, zipName } = body
|
||||
if (!Array.isArray(paths) || paths.length === 0) {
|
||||
return NextResponse.json({ error: 'Paths array is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate all paths first
|
||||
const safePaths: { safe: NonNullable<ReturnType<typeof resolveSafePath>>; original: string }[] = []
|
||||
for (const p of paths) {
|
||||
const safe = resolveSafePath(server._id.toString(), p)
|
||||
if (!safe) {
|
||||
return NextResponse.json({ error: `Access denied for path: ${p}` }, { status: 403 })
|
||||
}
|
||||
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||
if (!targetStat) {
|
||||
return NextResponse.json({ error: `Not found: ${p}` }, { status: 404 })
|
||||
}
|
||||
safePaths.push({ safe, original: p })
|
||||
}
|
||||
|
||||
// Determine output zip path (in the common parent directory)
|
||||
const firstDir = dirname(safePaths[0].safe.targetPath)
|
||||
const outputName = zipName || `archive-${Date.now()}.zip`
|
||||
const zipPath = join(firstDir, outputName)
|
||||
|
||||
if (existsSync(zipPath)) {
|
||||
return NextResponse.json({ error: `${outputName} already exists` }, { status: 409 })
|
||||
}
|
||||
|
||||
// Build zip command with all items relative to the parent dir
|
||||
const items = safePaths.map((sp) => `"${basename(sp.safe.targetPath)}"`).join(' ')
|
||||
const dirItems = safePaths.filter((sp) => {
|
||||
const s = require('fs').statSync(sp.safe.targetPath)
|
||||
return s.isDirectory()
|
||||
})
|
||||
const fileItems = safePaths.filter((sp) => {
|
||||
const s = require('fs').statSync(sp.safe.targetPath)
|
||||
return s.isFile()
|
||||
})
|
||||
|
||||
let cmd = `cd "${firstDir}"`
|
||||
// Add directories with -r flag
|
||||
if (dirItems.length > 0) {
|
||||
const dirNames = dirItems.map((sp) => `"${basename(sp.safe.targetPath)}"`).join(' ')
|
||||
cmd += ` && zip -r "${zipPath}" ${dirNames}`
|
||||
}
|
||||
// Add files (append if zip already started)
|
||||
if (fileItems.length > 0) {
|
||||
const fileNames = fileItems.map((sp) => `"${basename(sp.safe.targetPath)}"`).join(' ')
|
||||
if (dirItems.length > 0) {
|
||||
cmd += ` && zip "${zipPath}" ${fileNames}`
|
||||
} else {
|
||||
cmd += ` && zip "${zipPath}" ${fileNames}`
|
||||
}
|
||||
}
|
||||
|
||||
await execAsync(cmd)
|
||||
|
||||
return NextResponse.json({ success: true, message: `Created ${outputName}` })
|
||||
}
|
||||
|
||||
// ── Copy Multiple ─────────────────────────────────────────────
|
||||
if (action === 'copy-multiple') {
|
||||
const { paths, destination } = body
|
||||
if (!Array.isArray(paths) || paths.length === 0 || !destination) {
|
||||
return NextResponse.json({ error: 'Paths array and destination are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||
if (!safeDest) {
|
||||
return NextResponse.json({ error: 'Destination access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Ensure destination exists and is a directory
|
||||
await mkdir(safeDest.targetPath, { recursive: true })
|
||||
|
||||
const results: { path: string; success: boolean; error?: string }[] = []
|
||||
for (const p of paths) {
|
||||
const safeSrc = resolveSafePath(server._id.toString(), p)
|
||||
if (!safeSrc) {
|
||||
results.push({ path: p, success: false, error: 'Access denied' })
|
||||
continue
|
||||
}
|
||||
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||
if (!srcStat) {
|
||||
results.push({ path: p, success: false, error: 'Not found' })
|
||||
continue
|
||||
}
|
||||
const destItemPath = join(safeDest.targetPath, basename(safeSrc.targetPath))
|
||||
if (existsSync(destItemPath)) {
|
||||
results.push({ path: p, success: false, error: 'Destination already exists' })
|
||||
continue
|
||||
}
|
||||
await cp(safeSrc.targetPath, destItemPath, { recursive: true })
|
||||
results.push({ path: p, success: true })
|
||||
}
|
||||
|
||||
const copied = results.filter((r) => r.success).length
|
||||
return NextResponse.json({ success: true, message: `Copied ${copied} of ${paths.length} items`, data: results })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error('File action error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
111
src/app/api/servers/[id]/logs/route.ts
Normal file
111
src/app/api/servers/[id]/logs/route.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { readdir, stat, readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
// GET /api/servers/[id]/logs — List log files or read a specific log
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:console')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const logsDir = join(getServerPath(server._id.toString()), 'logs')
|
||||
const fileName = request.nextUrl.searchParams.get('file')
|
||||
|
||||
// If a file is requested, return its contents
|
||||
if (fileName) {
|
||||
// Prevent path traversal
|
||||
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '')
|
||||
if (safeName !== fileName) {
|
||||
return NextResponse.json({ error: 'Invalid file name' }, { status: 400 })
|
||||
}
|
||||
|
||||
const filePath = join(logsDir, safeName)
|
||||
|
||||
// Ensure we're still within the logs directory
|
||||
if (!filePath.startsWith(logsDir)) {
|
||||
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Support .gz files
|
||||
if (safeName.endsWith('.gz')) {
|
||||
const { createGunzip } = await import('zlib')
|
||||
const { pipeline } = await import('stream/promises')
|
||||
const { createReadStream } = await import('fs')
|
||||
const { PassThrough } = await import('stream')
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
const gunzip = createGunzip()
|
||||
const collector = new PassThrough()
|
||||
collector.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
|
||||
await pipeline(createReadStream(filePath), gunzip, collector)
|
||||
const content = Buffer.concat(chunks).toString('utf-8')
|
||||
|
||||
return NextResponse.json({ success: true, data: { fileName: safeName, content } })
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
return NextResponse.json({ success: true, data: { fileName: safeName, content } })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Log file not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// List all log files
|
||||
try {
|
||||
const entries = await readdir(logsDir)
|
||||
const files = await Promise.all(
|
||||
entries.map(async (name) => {
|
||||
try {
|
||||
const fileStat = await stat(join(logsDir, name))
|
||||
return {
|
||||
name,
|
||||
size: fileStat.size,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const validFiles = files
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => new Date(b!.modifiedAt).getTime() - new Date(a!.modifiedAt).getTime())
|
||||
|
||||
return NextResponse.json({ success: true, data: validFiles })
|
||||
} catch {
|
||||
// Logs directory doesn't exist yet
|
||||
return NextResponse.json({ success: true, data: [] })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logs error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
67
src/app/api/servers/[id]/mods/[filename]/route.ts
Normal file
67
src/app/api/servers/[id]/mods/[filename]/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// DELETE /api/servers/[id]/mods/[filename] — Remove a mod
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'mods:remove')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, filename } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const decodedFilename = decodeURIComponent(filename)
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server || (server.type !== 'forge' && server.type !== 'fabric')) {
|
||||
return NextResponse.json({ error: 'Server not found or not a Forge/Fabric server' }, { status: 404 })
|
||||
}
|
||||
|
||||
const filePath = path.join(getServerPath(server._id.toString()), 'mods', decodedFilename)
|
||||
|
||||
try {
|
||||
await unlink(filePath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Mod file not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: 'mod_removed',
|
||||
entityType: 'mod',
|
||||
entityName: decodedFilename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: { serverId: server._id.toString(), filename: decodedFilename },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Remove mod error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
75
src/app/api/servers/[id]/mods/[filename]/toggle/route.ts
Normal file
75
src/app/api/servers/[id]/mods/[filename]/toggle/route.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { rename } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// POST /api/servers/[id]/mods/[filename]/toggle — Enable/disable a mod
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'mods:toggle')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, filename } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const decodedFilename = decodeURIComponent(filename)
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server || (server.type !== 'forge' && server.type !== 'fabric')) {
|
||||
return NextResponse.json({ error: 'Server not found or not a Forge/Fabric server' }, { status: 404 })
|
||||
}
|
||||
|
||||
const modsDir = path.join(getServerPath(server._id.toString()), 'mods')
|
||||
const currentPath = path.join(modsDir, decodedFilename)
|
||||
|
||||
const isEnabled = decodedFilename.endsWith('.jar') && !decodedFilename.endsWith('.jar.disabled')
|
||||
const newFilename = isEnabled
|
||||
? `${decodedFilename}.disabled`
|
||||
: decodedFilename.replace('.jar.disabled', '.jar')
|
||||
const newPath = path.join(modsDir, newFilename)
|
||||
|
||||
try {
|
||||
await rename(currentPath, newPath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Mod file not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: isEnabled ? 'mod_disabled' : 'mod_enabled',
|
||||
entityType: 'mod',
|
||||
entityName: decodedFilename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: { filename: decodedFilename },
|
||||
newValues: { filename: newFilename },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: `Mod ${isEnabled ? 'disabled' : 'enabled'}. Restart to apply.` })
|
||||
} catch (error) {
|
||||
console.error('Toggle mod error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
134
src/app/api/servers/[id]/mods/route.ts
Normal file
134
src/app/api/servers/[id]/mods/route.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { readdir, stat, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// GET /api/servers/[id]/mods — List mods
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'mods:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.type !== 'forge' && server.type !== 'fabric') {
|
||||
return NextResponse.json({ error: 'Mods are only available for Forge/Fabric servers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const modsDir = path.join(getServerPath(server._id.toString()), 'mods')
|
||||
const mods = []
|
||||
|
||||
try {
|
||||
const files = await readdir(modsDir)
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jar') || file.endsWith('.jar.disabled')) {
|
||||
const filePath = path.join(modsDir, file)
|
||||
const stats = await stat(filePath)
|
||||
const enabled = file.endsWith('.jar') && !file.endsWith('.jar.disabled')
|
||||
const name = file.replace(/\.jar(\.disabled)?$/, '')
|
||||
mods.push({
|
||||
name,
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist yet
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: mods })
|
||||
} catch (error) {
|
||||
console.error('Fetch mods error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/servers/[id]/mods — Upload a mod
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'mods:install')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.type !== 'forge' && server.type !== 'fabric') {
|
||||
return NextResponse.json({ error: 'Mods are only available for Forge/Fabric servers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file || !file.name.endsWith('.jar')) {
|
||||
return NextResponse.json({ error: 'A .jar file is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const modsDir = path.join(getServerPath(server._id.toString()), 'mods')
|
||||
const { execSync } = await import('child_process')
|
||||
execSync(`mkdir -p "${modsDir}"`)
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(path.join(modsDir, file.name), buffer)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'mod_installed',
|
||||
entityType: 'mod',
|
||||
entityName: file.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
newValues: { serverId: server._id.toString(), filename: file.name },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 201,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Mod uploaded. Restart the server to load it.' }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Upload mod error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
104
src/app/api/servers/[id]/players/[name]/[action]/route.ts
Normal file
104
src/app/api/servers/[id]/players/[name]/[action]/route.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
// POST /api/servers/[id]/players/[name]/[action]
|
||||
// Actions: whitelist, unwhitelist, op, deop, ban, unban
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; name: string; action: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id, name: playerName, action } = await params
|
||||
const decodedName = decodeURIComponent(playerName)
|
||||
|
||||
// Map action to permission
|
||||
const permissionMap: Record<string, string> = {
|
||||
whitelist: 'players:whitelist',
|
||||
unwhitelist: 'players:whitelist',
|
||||
op: 'players:op',
|
||||
deop: 'players:op',
|
||||
ban: 'players:ban',
|
||||
unban: 'players:ban',
|
||||
}
|
||||
|
||||
const requiredPermission = permissionMap[action]
|
||||
if (!requiredPermission) {
|
||||
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, requiredPermission)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Map action to MC command
|
||||
const commandMap: Record<string, string> = {
|
||||
whitelist: `whitelist add ${decodedName}`,
|
||||
unwhitelist: `whitelist remove ${decodedName}`,
|
||||
op: `op ${decodedName}`,
|
||||
deop: `deop ${decodedName}`,
|
||||
ban: `ban ${decodedName}`,
|
||||
unban: `pardon ${decodedName}`,
|
||||
}
|
||||
|
||||
const mcCommand = commandMap[action]
|
||||
|
||||
if (server.status === 'online') {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
try {
|
||||
const exec = await container.exec({
|
||||
Cmd: ['rcon-cli', mcCommand],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
await exec.start({ Detach: false })
|
||||
} catch (execError) {
|
||||
console.error('RCON command failed:', execError)
|
||||
return NextResponse.json({ error: 'Failed to execute command' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Server must be online to manage players' }, { status: 400 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: `player_${action}`,
|
||||
entityType: 'player',
|
||||
entityName: decodedName,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
newValues: { player: decodedName, action, serverId: server._id.toString() },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: `Player ${action} successful` })
|
||||
} catch (error) {
|
||||
console.error('Player action error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
158
src/app/api/servers/[id]/players/route.ts
Normal file
158
src/app/api/servers/[id]/players/route.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { getContainerByName, getServerPath } from '@/lib/docker'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
interface PlayerInfo {
|
||||
name: string
|
||||
uuid?: string
|
||||
isOp: boolean
|
||||
isWhitelisted: boolean
|
||||
isBanned: boolean
|
||||
isOnline: boolean
|
||||
}
|
||||
|
||||
// GET /api/servers/[id]/players — List all known players
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'players:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serverDir = getServerPath(server._id.toString())
|
||||
|
||||
// Parse player lists from JSON files
|
||||
const whitelist = await readJsonFile(path.join(serverDir, 'whitelist.json'))
|
||||
const ops = await readJsonFile(path.join(serverDir, 'ops.json'))
|
||||
const bannedPlayers = await readJsonFile(path.join(serverDir, 'banned-players.json'))
|
||||
|
||||
// Get online players via RCON
|
||||
let onlineNames: string[] = []
|
||||
if (server.status === 'online') {
|
||||
try {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
const exec = await container.exec({
|
||||
Cmd: ['rcon-cli', 'list'],
|
||||
AttachStdout: true,
|
||||
})
|
||||
const stream = await exec.start({ Detach: false })
|
||||
let output = ''
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.on('data', (chunk: Buffer) => { output += chunk.slice(8).toString('utf-8') })
|
||||
stream.on('end', resolve)
|
||||
setTimeout(resolve, 3000)
|
||||
})
|
||||
|
||||
// Parse "There are X of a max of Y players online: player1, player2"
|
||||
const match = output.match(/players online:(.*)/)
|
||||
if (match) {
|
||||
onlineNames = match[1].split(',').map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// RCON may not be available
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all sources
|
||||
const playerMap = new Map<string, PlayerInfo>()
|
||||
|
||||
for (const entry of whitelist) {
|
||||
const name = entry.name
|
||||
playerMap.set(name, {
|
||||
name,
|
||||
uuid: entry.uuid,
|
||||
isOp: false,
|
||||
isWhitelisted: true,
|
||||
isBanned: false,
|
||||
isOnline: onlineNames.includes(name),
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of ops) {
|
||||
const name = entry.name
|
||||
const existing = playerMap.get(name)
|
||||
if (existing) {
|
||||
existing.isOp = true
|
||||
} else {
|
||||
playerMap.set(name, {
|
||||
name,
|
||||
uuid: entry.uuid,
|
||||
isOp: true,
|
||||
isWhitelisted: false,
|
||||
isBanned: false,
|
||||
isOnline: onlineNames.includes(name),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of bannedPlayers) {
|
||||
const name = entry.name
|
||||
const existing = playerMap.get(name)
|
||||
if (existing) {
|
||||
existing.isBanned = true
|
||||
} else {
|
||||
playerMap.set(name, {
|
||||
name,
|
||||
uuid: entry.uuid,
|
||||
isOp: false,
|
||||
isWhitelisted: false,
|
||||
isBanned: true,
|
||||
isOnline: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add online-only players not in any list
|
||||
for (const name of onlineNames) {
|
||||
if (!playerMap.has(name)) {
|
||||
playerMap.set(name, {
|
||||
name,
|
||||
isOp: false,
|
||||
isWhitelisted: false,
|
||||
isBanned: false,
|
||||
isOnline: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const players = Array.from(playerMap.values())
|
||||
return NextResponse.json({ success: true, data: players })
|
||||
} catch (error) {
|
||||
console.error('Fetch players error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<Array<{ name: string; uuid?: string }>> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
67
src/app/api/servers/[id]/plugins/[filename]/route.ts
Normal file
67
src/app/api/servers/[id]/plugins/[filename]/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { unlink } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// DELETE /api/servers/[id]/plugins/[filename] — Remove a plugin
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'plugins:remove')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, filename } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const decodedFilename = decodeURIComponent(filename)
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server || server.type !== 'bukkit') {
|
||||
return NextResponse.json({ error: 'Server not found or not a Bukkit server' }, { status: 404 })
|
||||
}
|
||||
|
||||
const filePath = path.join(getServerPath(server._id.toString()), 'plugins', decodedFilename)
|
||||
|
||||
try {
|
||||
await unlink(filePath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Plugin file not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: 'plugin_removed',
|
||||
entityType: 'plugin',
|
||||
entityName: decodedFilename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: { serverId: server._id.toString(), filename: decodedFilename },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Remove plugin error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
75
src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts
Normal file
75
src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { rename } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// POST /api/servers/[id]/plugins/[filename]/toggle — Enable/disable a plugin
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'plugins:toggle')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id, filename } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const decodedFilename = decodeURIComponent(filename)
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server || server.type !== 'bukkit') {
|
||||
return NextResponse.json({ error: 'Server not found or not a Bukkit server' }, { status: 404 })
|
||||
}
|
||||
|
||||
const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins')
|
||||
const currentPath = path.join(pluginsDir, decodedFilename)
|
||||
|
||||
const isEnabled = decodedFilename.endsWith('.jar') && !decodedFilename.endsWith('.jar.disabled')
|
||||
const newFilename = isEnabled
|
||||
? `${decodedFilename}.disabled`
|
||||
: decodedFilename.replace('.jar.disabled', '.jar')
|
||||
const newPath = path.join(pluginsDir, newFilename)
|
||||
|
||||
try {
|
||||
await rename(currentPath, newPath)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Plugin file not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: isEnabled ? 'plugin_disabled' : 'plugin_enabled',
|
||||
entityType: 'plugin',
|
||||
entityName: decodedFilename,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: { filename: decodedFilename },
|
||||
newValues: { filename: newFilename },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: `Plugin ${isEnabled ? 'disabled' : 'enabled'}. Restart to apply.` })
|
||||
} catch (error) {
|
||||
console.error('Toggle plugin error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
134
src/app/api/servers/[id]/plugins/route.ts
Normal file
134
src/app/api/servers/[id]/plugins/route.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getServerPath } from '@/lib/docker'
|
||||
import { readdir, stat, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
// GET /api/servers/[id]/plugins — List plugins
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'plugins:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.type !== 'bukkit') {
|
||||
return NextResponse.json({ error: 'Plugins are only available for Bukkit-type servers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins')
|
||||
const plugins = []
|
||||
|
||||
try {
|
||||
const files = await readdir(pluginsDir)
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jar') || file.endsWith('.jar.disabled')) {
|
||||
const filePath = path.join(pluginsDir, file)
|
||||
const stats = await stat(filePath)
|
||||
const enabled = file.endsWith('.jar')
|
||||
const name = file.replace(/\.jar(\.disabled)?$/, '')
|
||||
plugins.push({
|
||||
name,
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist yet
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: plugins })
|
||||
} catch (error) {
|
||||
console.error('Fetch plugins error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/servers/[id]/plugins — Upload a plugin
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'plugins:install')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.type !== 'bukkit') {
|
||||
return NextResponse.json({ error: 'Plugins are only available for Bukkit-type servers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
if (!file || !file.name.endsWith('.jar')) {
|
||||
return NextResponse.json({ error: 'A .jar file is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins')
|
||||
const { execSync } = await import('child_process')
|
||||
execSync(`mkdir -p "${pluginsDir}"`)
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(path.join(pluginsDir, file.name), buffer)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'plugin_installed',
|
||||
entityType: 'plugin',
|
||||
entityName: file.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
newValues: { serverId: server._id.toString(), filename: file.name },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 201,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Plugin uploaded. Restart the server to load it.' }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Upload plugin error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
90
src/app/api/servers/[id]/restart/route.ts
Normal file
90
src/app/api/servers/[id]/restart/route.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:restart')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// If the server is currently offline, check for port conflicts before restarting
|
||||
if (server.status !== 'online' && server.status !== 'starting') {
|
||||
const portConflict = await Server.findOne({
|
||||
_id: { $ne: server._id },
|
||||
port: server.port,
|
||||
status: { $in: ['online', 'starting'] },
|
||||
})
|
||||
if (portConflict) {
|
||||
return NextResponse.json(
|
||||
{ error: `Port ${server.port} is already in use by "${portConflict.name}"` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (!container) {
|
||||
return NextResponse.json({ error: 'Docker container not found' }, { status: 500 })
|
||||
}
|
||||
|
||||
await Server.findByIdAndUpdate(id, { status: 'stopping' })
|
||||
await container.restart({ t: 30 })
|
||||
await Server.findByIdAndUpdate(id, { status: 'starting' })
|
||||
|
||||
// Poll for running state
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const inspect = await container.inspect()
|
||||
const status = inspect.State?.Running ? 'online' : 'offline'
|
||||
await Server.findByIdAndUpdate(id, { status })
|
||||
} catch {
|
||||
await Server.findByIdAndUpdate(id, { status: 'crashed' })
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_restarted',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Server restarting' })
|
||||
} catch (error) {
|
||||
console.error('Restart server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
196
src/app/api/servers/[id]/route.ts
Normal file
196
src/app/api/servers/[id]/route.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { sanitizeObject, isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
// GET /api/servers/[id] — Get server details
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id).lean()
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Reconcile status with Docker
|
||||
try {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
const inspect = await container.inspect()
|
||||
const running = inspect.State?.Running
|
||||
const actualStatus = running ? 'online' : 'offline'
|
||||
if (server.status !== actualStatus && server.status !== 'starting' && server.status !== 'stopping') {
|
||||
await Server.findByIdAndUpdate(id, { status: actualStatus })
|
||||
server.status = actualStatus
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Container may not exist yet
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: server })
|
||||
} catch (error) {
|
||||
console.error('Fetch server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/servers/[id] — Update server
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const body = sanitizeObject(raw)
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const previousValues = server.toObject()
|
||||
|
||||
// Allowlisted update fields
|
||||
const allowed = ['name', 'maxPlayers', 'memory', 'jvmArgs', 'autoStart', 'autoRestart', 'backupSchedule', 'backupRetention']
|
||||
for (const key of allowed) {
|
||||
if (body[key] !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(server as any)[key] = body[key]
|
||||
}
|
||||
}
|
||||
|
||||
await server.save()
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_updated',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues,
|
||||
newValues: body,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: server })
|
||||
} catch (error) {
|
||||
console.error('Update server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/servers/[id] — Delete server
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:delete')) {
|
||||
await createAuditLog({
|
||||
action: 'server_delete_denied',
|
||||
entityType: 'server',
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'failure',
|
||||
statusCode: 403,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Remove Docker container
|
||||
try {
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (container) {
|
||||
await container.remove({ force: true })
|
||||
}
|
||||
} catch (dockerError) {
|
||||
console.error('Failed to remove container:', dockerError)
|
||||
}
|
||||
|
||||
await Server.findByIdAndDelete(id)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_deleted',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: server.toObject(),
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
91
src/app/api/servers/[id]/start/route.ts
Normal file
91
src/app/api/servers/[id]/start/route.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:start')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.status === 'online' || server.status === 'starting') {
|
||||
return NextResponse.json({ error: 'Server is already running or starting' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if another server is already using this port
|
||||
const portConflict = await Server.findOne({
|
||||
_id: { $ne: server._id },
|
||||
port: server.port,
|
||||
status: { $in: ['online', 'starting'] },
|
||||
})
|
||||
if (portConflict) {
|
||||
return NextResponse.json(
|
||||
{ error: `Port ${server.port} is already in use by "${portConflict.name}"` },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (!container) {
|
||||
return NextResponse.json({ error: 'Docker container not found. Try recreating the server.' }, { status: 500 })
|
||||
}
|
||||
|
||||
await Server.findByIdAndUpdate(id, { status: 'starting' })
|
||||
await container.start()
|
||||
|
||||
// Poll for running state
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const inspect = await container.inspect()
|
||||
const status = inspect.State?.Running ? 'online' : 'offline'
|
||||
await Server.findByIdAndUpdate(id, { status })
|
||||
} catch {
|
||||
await Server.findByIdAndUpdate(id, { status: 'crashed' })
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_started',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Server starting' })
|
||||
} catch (error) {
|
||||
console.error('Start server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
160
src/app/api/servers/[id]/stats/route.ts
Normal file
160
src/app/api/servers/[id]/stats/route.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
interface DockerCpuStats {
|
||||
cpu_stats: {
|
||||
cpu_usage: { total_usage: number }
|
||||
system_cpu_usage: number
|
||||
online_cpus: number
|
||||
}
|
||||
precpu_stats: {
|
||||
cpu_usage: { total_usage: number }
|
||||
system_cpu_usage: number
|
||||
}
|
||||
}
|
||||
|
||||
interface DockerMemoryStats {
|
||||
memory_stats: {
|
||||
usage: number
|
||||
limit: number
|
||||
stats?: { cache?: number }
|
||||
}
|
||||
}
|
||||
|
||||
type DockerStats = DockerCpuStats & DockerMemoryStats
|
||||
|
||||
function calculateCpuPercent(stats: DockerStats): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage
|
||||
const cpuCount = stats.cpu_stats.online_cpus || 1
|
||||
|
||||
if (systemDelta <= 0 || cpuDelta < 0) return 0
|
||||
return Math.min(100, (cpuDelta / systemDelta) * cpuCount * 100)
|
||||
}
|
||||
|
||||
// GET /api/servers/[id]/stats — SSE stream of container CPU & memory stats
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:view')) {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid server ID' }), { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id).lean()
|
||||
if (!server) {
|
||||
return new Response(JSON.stringify({ error: 'Server not found' }), { status: 404 })
|
||||
}
|
||||
|
||||
const container = await getContainerByName(`mc-${server._id}`).catch(() => null)
|
||||
if (!container) {
|
||||
return new Response(JSON.stringify({ error: 'Container not found' }), { status: 404 })
|
||||
}
|
||||
|
||||
// Check if container is running
|
||||
try {
|
||||
const inspect = await container.inspect()
|
||||
if (!inspect.State?.Running) {
|
||||
return new Response(JSON.stringify({ error: 'Container is not running' }), { status: 400 })
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Failed to inspect container' }), { status: 500 })
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let cancelled = false
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const statsStream = await container.stats({ stream: true }) as NodeJS.ReadableStream
|
||||
|
||||
let buffer = ''
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
if (cancelled) return
|
||||
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const stats: DockerStats = JSON.parse(line)
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats)
|
||||
const memUsage = stats.memory_stats.usage || 0
|
||||
const memLimit = stats.memory_stats.limit || 0
|
||||
const memCache = stats.memory_stats.stats?.cache || 0
|
||||
const memActual = memUsage - memCache
|
||||
|
||||
const payload = {
|
||||
cpu: Math.round(cpuPercent * 100) / 100,
|
||||
memUsed: memActual,
|
||||
memLimit: memLimit,
|
||||
memPercent: memLimit > 0 ? Math.round((memActual / memLimit) * 10000) / 100 : 0,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
|
||||
} catch {
|
||||
// Skip malformed JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onEnd = () => {
|
||||
if (!cancelled) {
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
if (!cancelled) {
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
statsStream.on('data', onData)
|
||||
statsStream.on('end', onEnd)
|
||||
statsStream.on('error', onError)
|
||||
|
||||
// Cleanup on cancel
|
||||
request.signal.addEventListener('abort', () => {
|
||||
cancelled = true
|
||||
statsStream.removeListener('data', onData)
|
||||
statsStream.removeListener('end', onEnd)
|
||||
statsStream.removeListener('error', onError)
|
||||
try { (statsStream as any).destroy?.() } catch {}
|
||||
try { controller.close() } catch {}
|
||||
})
|
||||
} catch {
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
69
src/app/api/servers/[id]/stop/route.ts
Normal file
69
src/app/api/servers/[id]/stop/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { isValidObjectId } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { getContainerByName } from '@/lib/docker'
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:stop')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const server = await Server.findById(id)
|
||||
|
||||
if (!server) {
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (server.status === 'offline' || server.status === 'stopping') {
|
||||
return NextResponse.json({ error: 'Server is already stopped or stopping' }, { status: 400 })
|
||||
}
|
||||
|
||||
const container = await getContainerByName(`mc-${server._id}`)
|
||||
if (!container) {
|
||||
await Server.findByIdAndUpdate(id, { status: 'offline' })
|
||||
return NextResponse.json({ success: true, message: 'Server marked as offline' })
|
||||
}
|
||||
|
||||
await Server.findByIdAndUpdate(id, { status: 'stopping' })
|
||||
await container.stop({ t: 30 }) // 30 second graceful shutdown
|
||||
await Server.findByIdAndUpdate(id, { status: 'offline' })
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_stopped',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Server stopped' })
|
||||
} catch (error) {
|
||||
console.error('Stop server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
150
src/app/api/servers/route.ts
Normal file
150
src/app/api/servers/route.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { Server } from '@/lib/models'
|
||||
import { sanitizeObject, isValidPort } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
import { createServerContainer, getServerPath, ensureServerDirectory } from '@/lib/docker'
|
||||
|
||||
// GET /api/servers — List all servers
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const servers = await Server.find().sort({ createdAt: -1 }).lean()
|
||||
|
||||
return NextResponse.json({ success: true, data: servers })
|
||||
} catch (error) {
|
||||
console.error('Fetch servers error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/servers — Create a new server
|
||||
export async function POST(request: NextRequest) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'servers:create')) {
|
||||
await createAuditLog({
|
||||
action: 'server_create_denied',
|
||||
entityType: 'server',
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'failure',
|
||||
statusCode: 403,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const body = sanitizeObject(raw)
|
||||
|
||||
const { name, type, version, port, maxPlayers, memory, dockerImage, jvmArgs } = body
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !type || !version || !port) {
|
||||
return NextResponse.json({ error: 'Name, type, version, and port are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validTypes = ['vanilla', 'bukkit', 'forge', 'fabric']
|
||||
if (!validTypes.includes(type)) {
|
||||
return NextResponse.json({ error: 'Invalid server type' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!isValidPort(port)) {
|
||||
return NextResponse.json({ error: 'Invalid port number' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
// Check for name conflict
|
||||
const existingName = await Server.findOne({ name })
|
||||
if (existingName) {
|
||||
return NextResponse.json({ error: `Server name "${name}" already exists` }, { status: 409 })
|
||||
}
|
||||
|
||||
const server = new Server({
|
||||
name,
|
||||
type,
|
||||
version,
|
||||
port: Number(port),
|
||||
maxPlayers: Number(maxPlayers) || 20,
|
||||
memory: {
|
||||
min: Number(memory?.min) || 512,
|
||||
max: Number(memory?.max) || 1024,
|
||||
},
|
||||
dockerImage: dockerImage || 'itzg/minecraft-server',
|
||||
jvmArgs: jvmArgs || [],
|
||||
status: 'offline',
|
||||
autoStart: false,
|
||||
autoRestart: true,
|
||||
createdBy: session._id,
|
||||
})
|
||||
|
||||
// Set containerName before save — schema requires it
|
||||
server.containerName = `mc-${server._id}`
|
||||
|
||||
await server.save()
|
||||
|
||||
// Create server data directory with correct permissions and Docker container
|
||||
const serverPath = getServerPath(server._id.toString())
|
||||
await ensureServerDirectory(serverPath)
|
||||
|
||||
try {
|
||||
const container = await createServerContainer({
|
||||
containerName: `mc-${server._id}`,
|
||||
dockerImage: server.dockerImage || 'itzg/minecraft-server',
|
||||
serverType: server.type,
|
||||
version: server.version,
|
||||
port: server.port,
|
||||
rconPort: server.rconPort || null,
|
||||
memoryMax: server.memory?.max || 1024,
|
||||
memoryMin: server.memory?.min || 512,
|
||||
jvmArgs: server.jvmArgs || [],
|
||||
autoRestart: server.autoRestart || false,
|
||||
serverPath,
|
||||
maxPlayers: server.maxPlayers || 20,
|
||||
})
|
||||
server.containerId = container.id
|
||||
await server.save()
|
||||
} catch (dockerError) {
|
||||
console.error('Docker container creation failed:', dockerError)
|
||||
// Server record is saved but container failed — user can retry
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
action: 'server_created',
|
||||
entityType: 'server',
|
||||
entityId: server._id.toString(),
|
||||
entityName: server.name,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
newValues: { name, type, version, port },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 201,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, data: server }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create server error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
187
src/app/api/users/[id]/route.ts
Normal file
187
src/app/api/users/[id]/route.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission, hashPassword } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { User } from '@/lib/models'
|
||||
import { sanitizeObject, isValidObjectId, isValidEmail } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// GET /api/users/[id]
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'users:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const user = await User.findById(id)
|
||||
.select('-passwordHash -twoFactorCode -twoFactorExpiry -loginAttempts -lockUntil')
|
||||
.lean()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: user })
|
||||
} catch (error) {
|
||||
console.error('Fetch user error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/users/[id]
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'users:edit')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const body = sanitizeObject(raw)
|
||||
|
||||
await connectToDatabase()
|
||||
const user = await User.findById(id)
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const previousValues = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roles: user.roles,
|
||||
}
|
||||
|
||||
if (body.username) user.username = body.username
|
||||
if (body.email) {
|
||||
if (!isValidEmail(body.email)) {
|
||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
|
||||
}
|
||||
user.email = body.email
|
||||
}
|
||||
if (body.password) {
|
||||
if (body.password.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 })
|
||||
}
|
||||
user.passwordHash = await hashPassword(body.password)
|
||||
}
|
||||
if (body.roles !== undefined) user.roles = body.roles
|
||||
if (body.status) user.status = body.status
|
||||
|
||||
await user.save()
|
||||
|
||||
await createAuditLog({
|
||||
action: 'user_updated',
|
||||
entityType: 'user',
|
||||
entityId: user._id.toString(),
|
||||
entityName: user.username,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues,
|
||||
newValues: { username: body.username, email: body.email, status: body.status, roles: body.roles },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roles: user.roles,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update user error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/users/[id]
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'users:delete')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
if (!isValidObjectId(id)) {
|
||||
return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Prevent self-deletion
|
||||
if (id === session._id) {
|
||||
return NextResponse.json({ error: 'You cannot delete your own account' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const user = await User.findById(id)
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await User.findByIdAndDelete(id)
|
||||
|
||||
await createAuditLog({
|
||||
action: 'user_deleted',
|
||||
entityType: 'user',
|
||||
entityId: user._id.toString(),
|
||||
entityName: user.username,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
previousValues: { username: user.username, email: user.email },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 200,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
119
src/app/api/users/route.ts
Normal file
119
src/app/api/users/route.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { validateSession, hasPermission, hashPassword } from '@/lib/auth'
|
||||
import connectToDatabase from '@/lib/mongodb'
|
||||
import { User } from '@/lib/models'
|
||||
import { sanitizeObject, isValidEmail } from '@/lib/input-validation'
|
||||
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
// GET /api/users — List all users
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'users:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
const users = await User.find()
|
||||
.select('-passwordHash -twoFactorCode -twoFactorExpiry -loginAttempts -lockUntil')
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
|
||||
return NextResponse.json({ success: true, data: users })
|
||||
} catch (error) {
|
||||
console.error('Fetch users error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/users — Create a new user
|
||||
export async function POST(request: NextRequest) {
|
||||
const clientIP = getClientIP(request)
|
||||
|
||||
try {
|
||||
const session = await validateSession(request)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!hasPermission(session, 'users:create')) {
|
||||
await createAuditLog({
|
||||
action: 'user_create_denied',
|
||||
entityType: 'user',
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
clientIP,
|
||||
status: 'failure',
|
||||
statusCode: 403,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const raw = await request.json()
|
||||
const { username, email, password, roles, status } = sanitizeObject(raw)
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return NextResponse.json({ error: 'Username, email, and password are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 })
|
||||
}
|
||||
|
||||
await connectToDatabase()
|
||||
|
||||
const existingUser = await User.findOne({ $or: [{ username }, { email }] })
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Username or email already exists' }, { status: 409 })
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const user = new User({
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
roles: roles || [],
|
||||
status: status || 'active',
|
||||
})
|
||||
|
||||
await user.save()
|
||||
|
||||
await createAuditLog({
|
||||
action: 'user_created',
|
||||
entityType: 'user',
|
||||
entityId: user._id.toString(),
|
||||
entityName: user.username,
|
||||
userId: session._id,
|
||||
userName: session.username,
|
||||
userEmail: session.email,
|
||||
newValues: { username, email, roles, status },
|
||||
clientIP,
|
||||
status: 'success',
|
||||
statusCode: 201,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
_id: user._id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
status: user.status,
|
||||
roles: user.roles,
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Create user error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,26 +1,75 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
background-color: #030712; /* gray-950 */
|
||||
color: #f3f4f6; /* gray-100 */
|
||||
}
|
||||
|
||||
/* ─── Animations ──────────────────────────────────────────────── */
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 200ms ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 300ms ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 300ms ease-out;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar (Dark Theme) ──────────────────────────────────── */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #111827; /* gray-900 */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #374151; /* gray-700 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4b5563; /* gray-600 */
|
||||
}
|
||||
|
||||
/* ─── Console Font ────────────────────────────────────────────── */
|
||||
|
||||
.font-mono {
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
}
|
||||
|
||||
@ -1,34 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { ToastProvider } from '@/contexts/ToastContext'
|
||||
import { ConfirmationProvider } from '@/contexts/ConfirmationContext'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
title: 'MC-Manager',
|
||||
description: 'Minecraft Server Manager',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<body className="min-h-screen bg-gray-950 text-gray-100 antialiased">
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmationProvider>
|
||||
{children}
|
||||
</ConfirmationProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
146
src/app/login/page.tsx
Normal file
146
src/app/login/page.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Gamepad2, ArrowRight } from 'lucide-react'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Input from '@/components/ui/Input'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [step, setStep] = useState<'credentials' | '2fa'>('credentials')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { login, verify2FA } = useAuth()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect') || '/dashboard'
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await login(username, password)
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else if (result.requiresTwoFactor) {
|
||||
setStep('2fa')
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await verify2FA(code)
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else if (result.success) {
|
||||
router.push(redirect)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-cyan-500/20 rounded-2xl mb-4">
|
||||
<Gamepad2 size={32} className="text-cyan-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-100">MC-Manager</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Minecraft Server Manager</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 shadow-xl p-8">
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-gray-100 text-center">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={loading} icon={ArrowRight} className="w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerify} className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-gray-100 text-center">Verification Code</h2>
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
A 6-digit code has been sent to your email.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
required
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={loading} className="w-full">
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('credentials'); setError('') }}
|
||||
className="w-full text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,65 +1,27 @@
|
||||
import Image from "next/image";
|
||||
'use client'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, loading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace('/dashboard')
|
||||
} else {
|
||||
router.replace('/login')
|
||||
}
|
||||
}
|
||||
}, [user, loading, router])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-950">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
136
src/components/ConsoleViewer.tsx
Normal file
136
src/components/ConsoleViewer.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Send, Trash2 } from 'lucide-react'
|
||||
|
||||
interface ConsoleViewerProps {
|
||||
serverId: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function ConsoleViewer({ serverId, readOnly = false }: ConsoleViewerProps) {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [command, setCommand] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Auto-scroll to bottom on new lines
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
}, [lines])
|
||||
|
||||
// SSE log streaming
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(`/api/servers/${serverId}/console`)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
const line = data.line || event.data
|
||||
setLines(prev => [...prev.slice(-500), line])
|
||||
} catch {
|
||||
setLines(prev => [...prev.slice(-500), event.data])
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [serverId])
|
||||
|
||||
const handleSendCommand = async () => {
|
||||
if (!command.trim() || sending) return
|
||||
|
||||
const cmd = command.trim()
|
||||
// Show the command in the console
|
||||
setLines(prev => [...prev.slice(-500), `> ${cmd}`])
|
||||
setCommand('')
|
||||
inputRef.current?.focus()
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/console`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setLines(prev => [...prev.slice(-500), `§c Error: ${data.error || 'Command failed'}`])
|
||||
}
|
||||
// Output arrives via SSE log stream — no need to parse response
|
||||
} catch (error) {
|
||||
console.error('Failed to send command:', error)
|
||||
setLines(prev => [...prev.slice(-500), '§c Error: Failed to send command'])
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSendCommand()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-950 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{/* Console Output */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto p-4 font-mono text-sm text-gray-300 space-y-0.5 min-h-[400px]"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-gray-600 italic">Waiting for server output...</p>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all hover:bg-gray-900/50">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command Input */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2 p-3 border-t border-gray-700/50 bg-gray-900/50">
|
||||
<span className="text-cyan-500 font-mono text-sm">></span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={e => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a command..."
|
||||
className="flex-1 bg-transparent text-gray-100 font-mono text-sm placeholder-gray-600 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setLines([])}
|
||||
className="p-1.5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors"
|
||||
aria-label="Clear console"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendCommand}
|
||||
disabled={!command.trim() || sending}
|
||||
className="p-1.5 rounded text-cyan-400 hover:text-cyan-300 hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Send command"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/components/Drawer.tsx
Normal file
61
src/components/Drawer.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface DrawerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Drawer({ isOpen, onClose, title, children }: DrawerProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Drawer Panel */}
|
||||
<div className="absolute inset-y-0 right-0 w-full max-w-3xl animate-slide-in-right">
|
||||
<div className="h-full flex flex-col bg-gray-900/95 backdrop-blur-md border-l border-gray-700/50 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-700/50 bg-gray-900/60 backdrop-blur-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
src/components/Modal.tsx
Normal file
57
src/components/Modal.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
maxWidth?: string
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal Panel */}
|
||||
<div className={`relative ${maxWidth} w-full mx-4 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl animate-scale-in`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-700/50">
|
||||
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/components/PageHeader.tsx
Normal file
25
src/components/PageHeader.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: LucideIcon
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, description, icon: Icon, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg shadow-lg border border-gray-700/50 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && <Icon size={28} className="text-gray-400" />}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-100">{title}</h1>
|
||||
{description && <p className="text-sm text-gray-400 mt-1">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/components/ProtectedComponent.tsx
Normal file
23
src/components/ProtectedComponent.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface ProtectedComponentProps {
|
||||
permission: string
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally renders children based on the user's permissions.
|
||||
* NOTE: This is a UI hint only — server-side checks are authoritative.
|
||||
*/
|
||||
export default function ProtectedComponent({ permission, children, fallback = null }: ProtectedComponentProps) {
|
||||
const { hasPermission } = useAuth()
|
||||
|
||||
if (!hasPermission(permission)) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
46
src/components/ServerStatusBadge.tsx
Normal file
46
src/components/ServerStatusBadge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import type { ServerStatus } from '@/types/server'
|
||||
|
||||
interface ServerStatusBadgeProps {
|
||||
status: ServerStatus
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const statusConfig: Record<ServerStatus, { label: string; className: string; dot: string }> = {
|
||||
online: {
|
||||
label: 'Online',
|
||||
className: 'bg-emerald-500/20 text-emerald-400',
|
||||
dot: 'bg-emerald-400',
|
||||
},
|
||||
offline: {
|
||||
label: 'Offline',
|
||||
className: 'bg-gray-500/20 text-gray-400',
|
||||
dot: 'bg-gray-400',
|
||||
},
|
||||
starting: {
|
||||
label: 'Starting',
|
||||
className: 'bg-amber-500/20 text-amber-400',
|
||||
dot: 'bg-amber-400 animate-pulse',
|
||||
},
|
||||
stopping: {
|
||||
label: 'Stopping',
|
||||
className: 'bg-amber-500/20 text-amber-400',
|
||||
dot: 'bg-amber-400 animate-pulse',
|
||||
},
|
||||
crashed: {
|
||||
label: 'Crashed',
|
||||
className: 'bg-red-500/20 text-red-400',
|
||||
dot: 'bg-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
export default function ServerStatusBadge({ status, size = 'sm' }: ServerStatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
const sizeClasses = size === 'sm' ? 'px-2.5 py-0.5 text-xs' : 'px-3 py-1 text-sm'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full font-semibold ${sizeClasses} ${config.className}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${config.dot}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
110
src/components/Sidebar.tsx
Normal file
110
src/components/Sidebar.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Users,
|
||||
Shield,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Gamepad2,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: typeof LayoutDashboard
|
||||
permission?: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Servers', href: '/servers', icon: Server, permission: 'servers:view' },
|
||||
{ label: 'Users', href: '/users', icon: Users, permission: 'users:view' },
|
||||
{ label: 'Roles', href: '/roles', icon: Shield, permission: 'roles:view' },
|
||||
{ label: 'Audit Log', href: '/audit', icon: ScrollText, permission: 'audit:view' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const { user, logout, hasPermission } = useAuth()
|
||||
|
||||
const filteredItems = navItems.filter(
|
||||
item => !item.permission || hasPermission(item.permission)
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex flex-col bg-gray-900 border-r border-gray-700/50 transition-all duration-300 ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-gray-700/50">
|
||||
<Gamepad2 size={28} className="text-cyan-500 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold text-gray-100 truncate">MC-Manager</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{filteredItems.map(item => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-sm font-medium ${
|
||||
isActive
|
||||
? 'bg-cyan-500/20 text-cyan-400'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} className="flex-shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-700/50 p-2 space-y-1">
|
||||
{/* User Info */}
|
||||
{user && !collapsed && (
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-medium text-gray-200 truncate">{user.username}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-400 hover:bg-red-500/10 hover:text-red-400 transition-colors text-sm font-medium w-full"
|
||||
>
|
||||
<LogOut size={20} className="flex-shrink-0" />
|
||||
{!collapsed && <span>Logout</span>}
|
||||
</button>
|
||||
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(prev => !prev)}
|
||||
className="flex items-center justify-center gap-3 px-3 py-2.5 rounded-lg text-gray-500 hover:bg-gray-800 hover:text-gray-300 transition-colors w-full"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={20} /> : <ChevronLeft size={20} />}
|
||||
{!collapsed && <span className="text-sm">Collapse</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
127
src/components/StatsChart.tsx
Normal file
127
src/components/StatsChart.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
interface StatsChartProps {
|
||||
data: number[]
|
||||
maxDataPoints?: number
|
||||
height?: number
|
||||
label: string
|
||||
value: string
|
||||
subValue?: string
|
||||
color: 'cyan' | 'emerald' | 'amber' | 'red'
|
||||
maxY?: number
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
cyan: { stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.15)', text: 'text-cyan-400', bg: 'bg-cyan-500/20' },
|
||||
emerald: { stroke: '#10b981', fill: 'rgba(16, 185, 129, 0.15)', text: 'text-emerald-400', bg: 'bg-emerald-500/20' },
|
||||
amber: { stroke: '#f59e0b', fill: 'rgba(245, 158, 11, 0.15)', text: 'text-amber-400', bg: 'bg-amber-500/20' },
|
||||
red: { stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.15)', text: 'text-red-400', bg: 'bg-red-500/20' },
|
||||
}
|
||||
|
||||
export default function StatsChart({
|
||||
data,
|
||||
maxDataPoints = 60,
|
||||
height = 120,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
color,
|
||||
maxY = 100,
|
||||
unit = '%',
|
||||
}: StatsChartProps) {
|
||||
const theme = COLORS[color]
|
||||
|
||||
const { linePath, areaPath } = useMemo(() => {
|
||||
if (data.length < 2) return { linePath: '', areaPath: '' }
|
||||
|
||||
const w = 400
|
||||
const h = height
|
||||
const padding = 2
|
||||
const effectiveH = h - padding * 2
|
||||
const clampedMax = Math.max(maxY, 1)
|
||||
|
||||
const points = data.map((val, i) => {
|
||||
const x = (i / (maxDataPoints - 1)) * w
|
||||
const y = padding + effectiveH - (Math.min(val, clampedMax) / clampedMax) * effectiveH
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
// Smooth the line with quadratic bezier curves
|
||||
let line = `M ${points[0].x},${points[0].y}`
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1]
|
||||
const curr = points[i]
|
||||
const cpx = (prev.x + curr.x) / 2
|
||||
line += ` Q ${cpx},${prev.y} ${curr.x},${curr.y}`
|
||||
}
|
||||
|
||||
const lastPoint = points[points.length - 1]
|
||||
const firstPoint = points[0]
|
||||
const area = `${line} L ${lastPoint.x},${h} L ${firstPoint.x},${h} Z`
|
||||
|
||||
return { linePath: line, areaPath: area }
|
||||
}, [data, maxDataPoints, height, maxY])
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${theme.bg}`}>
|
||||
<div className={`w-2 h-2 rounded-full animate-pulse`} style={{ backgroundColor: theme.stroke }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-lg font-semibold ${theme.text}`}>{value}</span>
|
||||
{subValue && (
|
||||
<span className="text-xs text-gray-500 ml-1">{subValue}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative" style={{ height }}>
|
||||
{data.length < 2 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-xs text-gray-600">Collecting data…</span>
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
viewBox={`0 0 400 ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full"
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((pct) => (
|
||||
<line
|
||||
key={pct}
|
||||
x1={0}
|
||||
y1={2 + (height - 4) * pct}
|
||||
x2={400}
|
||||
y2={2 + (height - 4) * pct}
|
||||
stroke="rgba(55, 65, 81, 0.3)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Area fill */}
|
||||
<path d={areaPath} fill={theme.fill} />
|
||||
|
||||
{/* Line */}
|
||||
<path d={linePath} fill="none" stroke={theme.stroke} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute top-0 left-0 h-full flex flex-col justify-between pointer-events-none py-0.5">
|
||||
<span className="text-[10px] text-gray-600">{maxY}{unit}</span>
|
||||
<span className="text-[10px] text-gray-600">0{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
src/components/templates/DataManagementTemplate.tsx
Normal file
239
src/components/templates/DataManagementTemplate.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Search, Plus, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
label: string
|
||||
sortable?: boolean
|
||||
render?: (item: T) => React.ReactNode
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
label: string
|
||||
filterKey: string
|
||||
options: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface DataManagementTemplateProps<T extends Record<string, any>> {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: LucideIcon
|
||||
|
||||
items: T[]
|
||||
loading: boolean
|
||||
|
||||
columns: Column<T>[]
|
||||
getRowKey: (item: T) => string
|
||||
|
||||
searchPlaceholder?: string
|
||||
searchFields?: string[]
|
||||
filters?: Filter[]
|
||||
|
||||
onRowClick?: (item: T) => void
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
canAdd?: boolean
|
||||
|
||||
emptyIcon?: LucideIcon
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export default function DataManagementTemplate<T extends Record<string, any>>({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
items,
|
||||
loading,
|
||||
columns,
|
||||
getRowKey,
|
||||
searchPlaceholder = 'Search...',
|
||||
searchFields = [],
|
||||
filters = [],
|
||||
onRowClick,
|
||||
onAdd,
|
||||
addLabel = 'Create New',
|
||||
canAdd = true,
|
||||
emptyIcon: EmptyIcon,
|
||||
emptyTitle = 'No items found',
|
||||
emptyDescription = 'Get started by creating one.',
|
||||
}: DataManagementTemplateProps<T>) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({})
|
||||
|
||||
// Filter + search
|
||||
const filteredItems = useMemo(() => {
|
||||
let result = [...items]
|
||||
|
||||
// Apply search
|
||||
if (search && searchFields.length > 0) {
|
||||
const lowerSearch = search.toLowerCase()
|
||||
result = result.filter(item =>
|
||||
searchFields.some(field => {
|
||||
const val = item[field]
|
||||
return typeof val === 'string' && val.toLowerCase().includes(lowerSearch)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
for (const [key, value] of Object.entries(activeFilters)) {
|
||||
if (value) {
|
||||
result = result.filter(item => String(item[key]) === value)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
if (sortKey) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = String(a[sortKey] ?? '')
|
||||
const bVal = String(b[sortKey] ?? '')
|
||||
const cmp = aVal.localeCompare(bVal)
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}, [items, search, searchFields, activeFilters, sortKey, sortDir])
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
icon={icon}
|
||||
actions={
|
||||
canAdd && onAdd ? (
|
||||
<Button icon={Plus} onClick={onAdd}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Search & Filters Bar */}
|
||||
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg
|
||||
placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{filters.map(filter => (
|
||||
<select
|
||||
key={filter.filterKey}
|
||||
value={activeFilters[filter.filterKey] || ''}
|
||||
onChange={e => setActiveFilters(prev => ({ ...prev, [filter.filterKey]: e.target.value }))}
|
||||
className="px-3 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg text-sm
|
||||
focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">{filter.label}</option>
|
||||
{filter.options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
{EmptyIcon && (
|
||||
<div className="bg-gray-800 rounded-full p-4 mb-4">
|
||||
<EmptyIcon size={32} className="text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-200 mb-2">{emptyTitle}</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">{emptyDescription}</p>
|
||||
{canAdd && onAdd && (
|
||||
<Button icon={Plus} onClick={onAdd}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-700">
|
||||
<thead className="bg-gray-800/50">
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider ${
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-gray-200' : ''
|
||||
}`}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && sortKey === col.key && (
|
||||
sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700">
|
||||
{filteredItems.map(item => (
|
||||
<tr
|
||||
key={getRowKey(item)}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={`hover:bg-gray-800 transition-colors ${
|
||||
onRowClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
{col.render
|
||||
? col.render(item)
|
||||
: String(item[col.key] ?? '—')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/components/ui/Badge.tsx
Normal file
23
src/components/ui/Badge.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
interface BadgeProps {
|
||||
variant?: 'info' | 'success' | 'warning' | 'error' | 'neutral'
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const badgeVariants = {
|
||||
info: 'bg-cyan-500/20 text-cyan-400',
|
||||
success: 'bg-emerald-500/20 text-emerald-400',
|
||||
warning: 'bg-amber-500/20 text-amber-400',
|
||||
error: 'bg-red-500/20 text-red-400',
|
||||
neutral: 'bg-gray-500/20 text-gray-400',
|
||||
}
|
||||
|
||||
export default function Badge({ variant = 'neutral', children, className = '' }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${badgeVariants[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
53
src/components/ui/Button.tsx
Normal file
53
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import Spinner from './Spinner'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
icon?: LucideIcon
|
||||
loading?: boolean
|
||||
as?: React.ElementType
|
||||
}
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-cyan-500 hover:bg-cyan-600 text-white shadow-sm hover:shadow-md',
|
||||
secondary: 'bg-gray-700 hover:bg-gray-600 text-gray-200 border border-gray-600',
|
||||
danger: 'bg-red-500 hover:bg-red-600 text-white',
|
||||
ghost: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
loading = false,
|
||||
as: Component = 'button',
|
||||
children,
|
||||
disabled,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<Component
|
||||
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg
|
||||
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-gray-900
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size="sm" />
|
||||
) : Icon ? (
|
||||
<Icon size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16} />
|
||||
) : null}
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
35
src/components/ui/Input.tsx
Normal file
35
src/components/ui/Input.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, required, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full px-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg
|
||||
placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||
error ? 'border-red-500 focus:ring-red-500' : ''
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
export default Input
|
||||
48
src/components/ui/Select.tsx
Normal file
48
src/components/ui/Select.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
options: { label: string; value: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, error, required, options, placeholder, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{required && <span className="text-red-400 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
ref={ref}
|
||||
className={`w-full px-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg
|
||||
focus:ring-2 focus:ring-cyan-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||
error ? 'border-red-500 focus:ring-red-500' : ''
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" className="text-gray-500">
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Select.displayName = 'Select'
|
||||
export default Select
|
||||
18
src/components/ui/Spinner.tsx
Normal file
18
src/components/ui/Spinner.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
export default function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`}>
|
||||
<div className={`animate-spin rounded-full border-b-2 border-cyan-500 ${sizes[size]}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/contexts/AuthContext.tsx
Normal file
118
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { AuthUser } from '@/types/user'
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<{ requiresTwoFactor: boolean; error?: string }>
|
||||
verify2FA: (code: string) => Promise<{ success: boolean; error?: string }>
|
||||
logout: () => Promise<void>
|
||||
refreshUser: () => Promise<void>
|
||||
hasPermission: (permission: string) => boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUser(data.data)
|
||||
} else {
|
||||
setUser(null)
|
||||
}
|
||||
} catch {
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshUser()
|
||||
}, [refreshUser])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
return { requiresTwoFactor: false, error: data.error || 'Login failed' }
|
||||
}
|
||||
|
||||
return { requiresTwoFactor: true }
|
||||
} catch {
|
||||
return { requiresTwoFactor: false, error: 'Network error' }
|
||||
}
|
||||
}, [])
|
||||
|
||||
const verify2FA = useCallback(async (code: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify-2fa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: data.error || 'Verification failed' }
|
||||
}
|
||||
|
||||
await refreshUser()
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: 'Network error' }
|
||||
}
|
||||
}, [refreshUser])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
} finally {
|
||||
setUser(null)
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const hasPermission = useCallback((permission: string) => {
|
||||
if (!user) return false
|
||||
if (user.permissions.includes('*:*')) return true
|
||||
return user.permissions.includes(permission)
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, verify2FA, logout, refreshUser, hasPermission }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
121
src/contexts/ConfirmationContext.tsx
Normal file
121
src/contexts/ConfirmationContext.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface ConfirmationOptions {
|
||||
title: string
|
||||
message: string
|
||||
itemName?: string
|
||||
type?: 'danger' | 'warning' | 'info'
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
interface ConfirmationContextType {
|
||||
showConfirmation: (options: ConfirmationOptions) => Promise<boolean>
|
||||
}
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextType | undefined>(undefined)
|
||||
|
||||
const typeStyles = {
|
||||
danger: {
|
||||
icon: 'bg-red-500/20 text-red-400',
|
||||
button: 'bg-red-500 hover:bg-red-600 text-white',
|
||||
},
|
||||
warning: {
|
||||
icon: 'bg-amber-500/20 text-amber-400',
|
||||
button: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
},
|
||||
info: {
|
||||
icon: 'bg-cyan-500/20 text-cyan-400',
|
||||
button: 'bg-cyan-500 hover:bg-cyan-600 text-white',
|
||||
},
|
||||
}
|
||||
|
||||
export function ConfirmationProvider({ children }: { children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [options, setOptions] = useState<ConfirmationOptions | null>(null)
|
||||
const [resolver, setResolver] = useState<((value: boolean) => void) | null>(null)
|
||||
|
||||
const showConfirmation = useCallback((opts: ConfirmationOptions): Promise<boolean> => {
|
||||
setOptions(opts)
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setResolver(() => resolve)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
resolver?.(true)
|
||||
setResolver(null)
|
||||
}, [resolver])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
resolver?.(false)
|
||||
setResolver(null)
|
||||
}, [resolver])
|
||||
|
||||
const type = options?.type || 'danger'
|
||||
const styles = typeStyles[type]
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={{ showConfirmation }}>
|
||||
{children}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{isOpen && options && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={handleCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative bg-gray-900 border border-gray-700 rounded-lg shadow-2xl p-6 max-w-md w-full mx-4 animate-scale-in">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full ${styles.icon}`}>
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-100">{options.title}</h3>
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
{options.message}
|
||||
{options.itemName && (
|
||||
<span className="font-semibold text-gray-200"> {options.itemName}</span>
|
||||
)}
|
||||
?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg transition-colors text-sm font-medium"
|
||||
>
|
||||
{options.cancelText || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`px-4 py-2 rounded-lg transition-colors text-sm font-medium ${styles.button}`}
|
||||
>
|
||||
{options.confirmText || 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConfirmation() {
|
||||
const context = useContext(ConfirmationContext)
|
||||
if (!context) {
|
||||
throw new Error('useConfirmation must be used within a ConfirmationProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
85
src/contexts/ToastContext.tsx
Normal file
85
src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type: ToastType) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined)
|
||||
|
||||
const TOAST_DURATION = 5000
|
||||
|
||||
const toastStyles: Record<ToastType, string> = {
|
||||
success: 'bg-emerald-500/20 border-emerald-500/50 text-emerald-400',
|
||||
error: 'bg-red-500/20 border-red-500/50 text-red-400',
|
||||
info: 'bg-cyan-500/20 border-cyan-500/50 text-cyan-400',
|
||||
warning: 'bg-amber-500/20 border-amber-500/50 text-amber-400',
|
||||
}
|
||||
|
||||
const toastIcons: Record<ToastType, typeof CheckCircle> = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
setToasts(prev => [...prev, { id, message, type }])
|
||||
|
||||
setTimeout(() => removeToast(id), TOAST_DURATION)
|
||||
}, [removeToast])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => {
|
||||
const Icon = toastIcons[toast.type]
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-lg shadow-xl animate-slide-in-right ${toastStyles[toast.type]}`}
|
||||
>
|
||||
<Icon size={18} className="flex-shrink-0" />
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-2 opacity-60 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
71
src/hooks/useConsole.ts
Normal file
71
src/hooks/useConsole.ts
Normal file
@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
interface UseConsoleOptions {
|
||||
maxLines?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for SSE console log streaming and command sending.
|
||||
*/
|
||||
export function useConsole(serverId: string, options: UseConsoleOptions = {}) {
|
||||
const { maxLines = 500 } = options
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Connect to SSE stream
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(`/api/servers/${serverId}/console`)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
setLines(prev => [...prev.slice(-(maxLines - 1)), event.data])
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
setConnected(false)
|
||||
}
|
||||
}, [serverId, maxLines])
|
||||
|
||||
// Send command
|
||||
const sendCommand = useCallback(async (command: string) => {
|
||||
if (!command.trim()) return
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}/console`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ command: command.trim() }),
|
||||
})
|
||||
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [serverId])
|
||||
|
||||
// Clear console
|
||||
const clearConsole = useCallback(() => {
|
||||
setLines([])
|
||||
}, [])
|
||||
|
||||
return { lines, connected, sending, sendCommand, clearConsole }
|
||||
}
|
||||
85
src/hooks/useServerStatus.ts
Normal file
85
src/hooks/useServerStatus.ts
Normal file
@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ServerStatus } from '@/types/server'
|
||||
|
||||
interface UseServerStatusOptions {
|
||||
pollInterval?: number // ms, default 10000
|
||||
}
|
||||
|
||||
interface ServerStatusData {
|
||||
id: string
|
||||
status: ServerStatus
|
||||
players?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll server status at regular intervals.
|
||||
*/
|
||||
export function useServerStatus(serverId: string | null, options: UseServerStatusOptions = {}) {
|
||||
const { pollInterval = 10000 } = options
|
||||
const [status, setStatus] = useState<ServerStatus>('offline')
|
||||
const [players, setPlayers] = useState<number>(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!serverId) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${serverId}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStatus(data.data.status)
|
||||
setPlayers(data.data.onlinePlayers || 0)
|
||||
}
|
||||
} catch {
|
||||
// Silent fail — will retry on next poll
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [serverId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, pollInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus, pollInterval])
|
||||
|
||||
return { status, players, loading, refresh: fetchStatus }
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll all servers status for dashboard overview.
|
||||
*/
|
||||
export function useAllServersStatus(pollInterval = 15000) {
|
||||
const [servers, setServers] = useState<ServerStatusData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/servers', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setServers(
|
||||
data.data.map((s: Record<string, unknown>) => ({
|
||||
id: s._id,
|
||||
status: s.status,
|
||||
players: s.onlinePlayers || 0,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll()
|
||||
const interval = setInterval(fetchAll, pollInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchAll, pollInterval])
|
||||
|
||||
return { servers, loading, refresh: fetchAll }
|
||||
}
|
||||
69
src/lib/audit.ts
Normal file
69
src/lib/audit.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import connectToDatabase from './mongodb'
|
||||
import { AuditLog } from './models'
|
||||
|
||||
interface AuditLogParams {
|
||||
action: string
|
||||
entityType: string
|
||||
entityId?: string
|
||||
entityName?: string
|
||||
userId?: string
|
||||
userName?: string
|
||||
userEmail?: string
|
||||
previousValues?: unknown
|
||||
newValues?: unknown
|
||||
changes?: Record<string, string> | null
|
||||
clientIP: string
|
||||
status: 'SUCCESS' | 'FAILED' | 'success' | 'failure'
|
||||
statusCode: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an audit log entry. Always call after mutations (success or failure).
|
||||
*/
|
||||
export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
await connectToDatabase()
|
||||
|
||||
// Normalize status to uppercase
|
||||
const statusMap: Record<string, string> = { success: 'SUCCESS', failure: 'FAILED' }
|
||||
const normalizedStatus = statusMap[params.status] || params.status.toUpperCase()
|
||||
|
||||
await AuditLog.create({
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId || undefined,
|
||||
entityName: params.entityName || undefined,
|
||||
userId: params.userId || undefined,
|
||||
userName: params.userName || undefined,
|
||||
userEmail: params.userEmail || undefined,
|
||||
previousValues: params.previousValues ?? null,
|
||||
newValues: params.newValues ?? null,
|
||||
changes: params.changes || null,
|
||||
clientIP: params.clientIP,
|
||||
status: normalizedStatus,
|
||||
statusCode: params.statusCode,
|
||||
errorMessage: params.errorMessage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Audit] Failed to create audit log:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP from the request headers.
|
||||
*/
|
||||
export function getClientIP(request: NextRequest): string {
|
||||
const forwarded = request.headers.get('x-forwarded-for')
|
||||
if (forwarded) {
|
||||
return forwarded.split(',')[0].trim()
|
||||
}
|
||||
|
||||
const realIP = request.headers.get('x-real-ip')
|
||||
if (realIP) {
|
||||
return realIP
|
||||
}
|
||||
|
||||
return '127.0.0.1'
|
||||
}
|
||||
151
src/lib/auth.ts
Normal file
151
src/lib/auth.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import crypto from 'crypto'
|
||||
import connectToDatabase from './mongodb'
|
||||
import { User } from './models'
|
||||
import type { AuthUser, SessionPayload } from '@/types/user'
|
||||
|
||||
// ─── Password Hashing ────────────────────────────────────────────
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword)
|
||||
}
|
||||
|
||||
// ─── JWT Tokens ──────────────────────────────────────────────────
|
||||
|
||||
export function generateAccessToken(payload: SessionPayload): string {
|
||||
return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
export function generateRefreshToken(payload: SessionPayload): string {
|
||||
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET!, { expiresIn: '7d' })
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token: string): SessionPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_SECRET!) as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(token: string): SessionPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, process.env.JWT_REFRESH_SECRET!) as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2FA Code ────────────────────────────────────────────────────
|
||||
|
||||
export function generate2FACode(): string {
|
||||
return crypto.randomInt(100000, 999999).toString()
|
||||
}
|
||||
|
||||
export function hash2FACode(code: string): string {
|
||||
const hmac = crypto.createHmac('sha256', process.env.JWT_SECRET!)
|
||||
hmac.update(code)
|
||||
return hmac.digest('hex')
|
||||
}
|
||||
|
||||
export function verify2FACode(inputCode: string, hashedCode: string): boolean {
|
||||
const inputHash = hash2FACode(inputCode.trim())
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(inputHash, 'hex'),
|
||||
Buffer.from(hashedCode, 'hex')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session Validation ──────────────────────────────────────────
|
||||
|
||||
export async function validateSession(request: NextRequest): Promise<AuthUser | null> {
|
||||
const token = request.cookies.get('session-token')?.value
|
||||
|
||||
if (!token) return null
|
||||
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload) return null
|
||||
|
||||
try {
|
||||
await connectToDatabase()
|
||||
|
||||
const user = await User.findById(payload.userId)
|
||||
.populate('roles')
|
||||
.lean()
|
||||
|
||||
if (!user || user.status !== 'active') return null
|
||||
|
||||
const permissions = new Set<string>()
|
||||
const roleNames: string[] = []
|
||||
|
||||
if (Array.isArray(user.roles)) {
|
||||
for (const role of user.roles) {
|
||||
if (typeof role === 'object' && role !== null) {
|
||||
const r = role as unknown as { name: string; permissions: { resource: string; actions: string[] }[] }
|
||||
roleNames.push(r.name)
|
||||
for (const perm of r.permissions) {
|
||||
for (const action of perm.actions) {
|
||||
permissions.add(`${perm.resource}:${action}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
_id: user._id.toString(),
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
permissions: Array.from(permissions),
|
||||
roles: roleNames,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cookie Helpers ──────────────────────────────────────────────
|
||||
|
||||
export function setAuthCookies(response: NextResponse, accessToken: string, refreshToken: string): void {
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
response.cookies.set('session-token', accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600, // 1 hour
|
||||
path: '/',
|
||||
})
|
||||
|
||||
response.cookies.set('refresh-token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 3600, // 7 days
|
||||
path: '/',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearAuthCookies(response: NextResponse): void {
|
||||
response.cookies.set('session-token', '', { maxAge: 0, path: '/' })
|
||||
response.cookies.set('refresh-token', '', { maxAge: 0, path: '/' })
|
||||
}
|
||||
|
||||
// ─── Permission Check ────────────────────────────────────────────
|
||||
|
||||
export function hasPermission(user: AuthUser, permission: string): boolean {
|
||||
if (user.permissions.includes('*:*')) return true
|
||||
return user.permissions.includes(permission)
|
||||
}
|
||||
205
src/lib/backup-scheduler.ts
Normal file
205
src/lib/backup-scheduler.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import cron, { ScheduledTask } from 'node-cron'
|
||||
import connectToDatabase from './mongodb'
|
||||
import { Server, Backup } from './models'
|
||||
import { getContainerById, getServerPath } from './docker'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
// In-memory map of active cron jobs
|
||||
const scheduledJobs = new Map<string, ScheduledTask>()
|
||||
|
||||
/**
|
||||
* Initialize all backup schedules from the database on app startup.
|
||||
*/
|
||||
export async function initBackupScheduler(): Promise<void> {
|
||||
try {
|
||||
await connectToDatabase()
|
||||
|
||||
const servers = await Server.find({
|
||||
backupSchedule: { $ne: null },
|
||||
status: { $ne: 'offline' },
|
||||
}).lean()
|
||||
|
||||
for (const server of servers) {
|
||||
if (server.backupSchedule && cron.validate(server.backupSchedule)) {
|
||||
registerBackupJob(server._id.toString(), server.backupSchedule)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[BackupScheduler] Initialized ${scheduledJobs.size} scheduled backup jobs`)
|
||||
} catch (error) {
|
||||
console.error('[BackupScheduler] Failed to initialize:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a cron job for a server's backup schedule.
|
||||
*/
|
||||
export function registerBackupJob(serverId: string, cronExpression: string): void {
|
||||
// Remove existing job if any
|
||||
removeBackupJob(serverId)
|
||||
|
||||
if (!cron.validate(cronExpression)) {
|
||||
console.error(`[BackupScheduler] Invalid cron expression for server ${serverId}: ${cronExpression}`)
|
||||
return
|
||||
}
|
||||
|
||||
const task = cron.schedule(cronExpression, async () => {
|
||||
await runScheduledBackup(serverId)
|
||||
})
|
||||
|
||||
scheduledJobs.set(serverId, task)
|
||||
console.log(`[BackupScheduler] Registered job for server ${serverId}: ${cronExpression}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a server's backup cron job.
|
||||
*/
|
||||
export function removeBackupJob(serverId: string): void {
|
||||
const existing = scheduledJobs.get(serverId)
|
||||
if (existing) {
|
||||
existing.stop()
|
||||
scheduledJobs.delete(serverId)
|
||||
console.log(`[BackupScheduler] Removed job for server ${serverId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a server's backup schedule (remove old, register new).
|
||||
*/
|
||||
export function updateBackupJob(serverId: string, cronExpression: string | null): void {
|
||||
if (cronExpression) {
|
||||
registerBackupJob(serverId, cronExpression)
|
||||
} else {
|
||||
removeBackupJob(serverId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a scheduled backup for a server.
|
||||
*/
|
||||
async function runScheduledBackup(serverId: string): Promise<void> {
|
||||
try {
|
||||
await connectToDatabase()
|
||||
|
||||
const server = await Server.findById(serverId)
|
||||
if (!server || server.status !== 'online') {
|
||||
console.log(`[BackupScheduler] Skipping backup for ${serverId} — server not online`)
|
||||
return
|
||||
}
|
||||
|
||||
const serverPath = getServerPath(serverId)
|
||||
const backupsDir = path.join(serverPath, 'backups')
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `backup-${timestamp}.tar.gz`
|
||||
const filePath = path.join(backupsDir, filename)
|
||||
|
||||
// Ensure backups directory exists
|
||||
await fs.mkdir(backupsDir, { recursive: true })
|
||||
|
||||
// Create backup record
|
||||
const backup = await Backup.create({
|
||||
serverId: server._id,
|
||||
filename,
|
||||
filePath,
|
||||
type: 'scheduled',
|
||||
status: 'in_progress',
|
||||
createdBy: server.createdBy,
|
||||
})
|
||||
|
||||
try {
|
||||
// Pause world saving if container is running
|
||||
const container = server.containerId ? await getContainerById(server.containerId) : null
|
||||
if (container) {
|
||||
try {
|
||||
const saveOffExec = await container.exec({
|
||||
Cmd: ['rcon-cli', 'save-off'],
|
||||
AttachStdout: true,
|
||||
})
|
||||
await saveOffExec.start({})
|
||||
|
||||
const saveAllExec = await container.exec({
|
||||
Cmd: ['rcon-cli', 'save-all', 'flush'],
|
||||
AttachStdout: true,
|
||||
})
|
||||
await saveAllExec.start({})
|
||||
|
||||
// Brief pause for save to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
} catch {
|
||||
console.warn(`[BackupScheduler] Could not pause saves for ${serverId}, proceeding anyway`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tar the world directory
|
||||
await execAsync(`tar -czf "${filePath}" -C "${serverPath}" world`)
|
||||
|
||||
// Resume world saving
|
||||
if (container) {
|
||||
try {
|
||||
const saveOnExec = await container.exec({
|
||||
Cmd: ['rcon-cli', 'save-on'],
|
||||
AttachStdout: true,
|
||||
})
|
||||
await saveOnExec.start({})
|
||||
} catch {
|
||||
console.warn(`[BackupScheduler] Could not resume saves for ${serverId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Get file size
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
// Update backup record
|
||||
backup.fileSize = stats.size
|
||||
backup.status = 'completed'
|
||||
await backup.save()
|
||||
|
||||
console.log(`[BackupScheduler] Completed backup for ${serverId}: ${filename}`)
|
||||
|
||||
// Enforce retention policy
|
||||
await enforceRetention(serverId, server.backupRetention)
|
||||
} catch (error) {
|
||||
backup.status = 'failed'
|
||||
await backup.save()
|
||||
console.error(`[BackupScheduler] Backup failed for ${serverId}:`, error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[BackupScheduler] Error running backup for ${serverId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete oldest backups that exceed the retention limit.
|
||||
*/
|
||||
async function enforceRetention(serverId: string, retention: number): Promise<void> {
|
||||
const backups = await Backup.find({ serverId, status: 'completed' })
|
||||
.sort({ createdAt: -1 })
|
||||
.lean()
|
||||
|
||||
if (backups.length <= retention) return
|
||||
|
||||
const toDelete = backups.slice(retention)
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
await fs.unlink(backup.filePath)
|
||||
} catch {
|
||||
// File may already be deleted
|
||||
}
|
||||
await Backup.findByIdAndDelete(backup._id)
|
||||
}
|
||||
|
||||
console.log(`[BackupScheduler] Enforced retention for ${serverId}: removed ${toDelete.length} old backups`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active scheduled jobs.
|
||||
*/
|
||||
export function getActiveJobCount(): number {
|
||||
return scheduledJobs.size
|
||||
}
|
||||
86
src/lib/date-utils.ts
Normal file
86
src/lib/date-utils.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Format date for display: "Jan 15, 2026"
|
||||
*/
|
||||
export function formatDate(date: string | Date | null | undefined): string {
|
||||
if (!date) return '—'
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return '—'
|
||||
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time for display: "Jan 15, 2026 at 2:30 PM"
|
||||
*/
|
||||
export function formatDateTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return '—'
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return '—'
|
||||
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for HTML input[type="date"]: "2026-01-15"
|
||||
*/
|
||||
export function formatDateForInput(date: string | Date | null | undefined): string {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display: "1.5 GB", "256 MB", "12 KB"
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time: "2 hours ago", "just now"
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return '—'
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return '—'
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffSeconds < 60) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
|
||||
return formatDate(date)
|
||||
}
|
||||
186
src/lib/docker.ts
Normal file
186
src/lib/docker.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import Docker from 'dockerode'
|
||||
import { mkdir, chown } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
let dockerClient: Docker | null = null
|
||||
|
||||
/**
|
||||
* Returns a cached dockerode instance connected to the Docker Engine socket.
|
||||
*/
|
||||
export function getDockerClient(): Docker {
|
||||
if (!dockerClient) {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'
|
||||
dockerClient = new Docker({ socketPath })
|
||||
}
|
||||
return dockerClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Docker container state to our ServerStatus.
|
||||
*/
|
||||
export function mapContainerState(state: string): 'online' | 'offline' | 'starting' | 'stopping' | 'crashed' {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return 'online'
|
||||
case 'created':
|
||||
case 'restarting':
|
||||
return 'starting'
|
||||
case 'removing':
|
||||
case 'paused':
|
||||
return 'stopping'
|
||||
case 'exited':
|
||||
return 'offline'
|
||||
case 'dead':
|
||||
return 'crashed'
|
||||
default:
|
||||
return 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the itzg/minecraft-server TYPE env var from our server.type.
|
||||
*/
|
||||
export function getItzgServerType(type: string): string {
|
||||
switch (type) {
|
||||
case 'vanilla':
|
||||
return 'VANILLA'
|
||||
case 'bukkit':
|
||||
return 'PAPER' // Default bukkit to Paper — override if needed
|
||||
case 'forge':
|
||||
return 'FORGE'
|
||||
case 'fabric':
|
||||
return 'FABRIC'
|
||||
default:
|
||||
return 'VANILLA'
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateContainerOptions {
|
||||
containerName: string
|
||||
dockerImage: string
|
||||
serverType: string
|
||||
version: string
|
||||
port: number
|
||||
rconPort: number | null
|
||||
memoryMax: number
|
||||
memoryMin: number
|
||||
jvmArgs: string[]
|
||||
autoRestart: boolean
|
||||
serverPath: string
|
||||
maxPlayers: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Docker container for a Minecraft server.
|
||||
*/
|
||||
export async function createServerContainer(options: CreateContainerOptions): Promise<Docker.Container> {
|
||||
const docker = getDockerClient()
|
||||
const isItzg = options.dockerImage === 'itzg/minecraft-server'
|
||||
|
||||
const env: string[] = [
|
||||
`EULA=TRUE`,
|
||||
`MAX_PLAYERS=${options.maxPlayers}`,
|
||||
]
|
||||
|
||||
// Only set itzg-specific env vars for the default image
|
||||
if (isItzg) {
|
||||
env.push(
|
||||
`TYPE=${getItzgServerType(options.serverType)}`,
|
||||
`VERSION=${options.version}`,
|
||||
`MEMORY=${options.memoryMax}M`,
|
||||
`JVM_XX_OPTS=${options.jvmArgs.join(' ')}`,
|
||||
'CREATE_CONSOLE_IN_PIPE=true',
|
||||
)
|
||||
}
|
||||
|
||||
const portBindings: Docker.PortMap = {
|
||||
'25565/tcp': [{ HostPort: options.port.toString() }],
|
||||
}
|
||||
|
||||
if (options.rconPort) {
|
||||
portBindings['25575/tcp'] = [{ HostPort: options.rconPort.toString() }]
|
||||
}
|
||||
|
||||
const exposedPorts: Record<string, object> = { '25565/tcp': {} }
|
||||
if (options.rconPort) {
|
||||
exposedPorts['25575/tcp'] = {}
|
||||
}
|
||||
|
||||
const container = await docker.createContainer({
|
||||
name: options.containerName,
|
||||
Image: options.dockerImage,
|
||||
Env: env,
|
||||
ExposedPorts: exposedPorts,
|
||||
HostConfig: {
|
||||
PortBindings: portBindings,
|
||||
Binds: [`${options.serverPath}:/data:Z`],
|
||||
Memory: options.memoryMax * 1024 * 1024,
|
||||
RestartPolicy: {
|
||||
Name: options.autoRestart ? 'unless-stopped' : 'no',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the server data directory exists with correct ownership (uid=1000)
|
||||
* for the itzg/minecraft-server container.
|
||||
*/
|
||||
export async function ensureServerDirectory(serverPath: string): Promise<void> {
|
||||
if (!existsSync(serverPath)) {
|
||||
await mkdir(serverPath, { recursive: true })
|
||||
}
|
||||
|
||||
// itzg/minecraft-server runs as uid=1000 gid=1000
|
||||
try {
|
||||
await chown(serverPath, 1000, 1000)
|
||||
} catch (err) {
|
||||
console.warn(`[Docker] Could not chown ${serverPath} to 1000:1000 — container may have permission issues:`, (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a container by name. Returns null if not found.
|
||||
*/
|
||||
export async function getContainerByName(name: string): Promise<Docker.Container | null> {
|
||||
const docker = getDockerClient()
|
||||
|
||||
try {
|
||||
const container = docker.getContainer(name)
|
||||
await container.inspect() // Verify it exists
|
||||
return container
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a container by ID. Returns null if not found.
|
||||
*/
|
||||
export async function getContainerById(id: string): Promise<Docker.Container | null> {
|
||||
const docker = getDockerClient()
|
||||
|
||||
try {
|
||||
const container = docker.getContainer(id)
|
||||
await container.inspect()
|
||||
return container
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base server path for volumes.
|
||||
*/
|
||||
export function getServersBasePath(): string {
|
||||
return process.env.MC_SERVERS_PATH || '/opt/mc-servers'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the full path for a specific server's data directory.
|
||||
*/
|
||||
export function getServerPath(serverId: string): string {
|
||||
return `${getServersBasePath()}/${serverId}`
|
||||
}
|
||||
87
src/lib/email-graph.ts
Normal file
87
src/lib/email-graph.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node'
|
||||
import { Client } from '@microsoft/microsoft-graph-client'
|
||||
|
||||
let msalClient: ConfidentialClientApplication | null = null
|
||||
let graphClient: Client | null = null
|
||||
|
||||
/**
|
||||
* Get or create the MSAL confidential client.
|
||||
*/
|
||||
function getMsalClient(): ConfidentialClientApplication {
|
||||
if (!msalClient) {
|
||||
const clientId = process.env.AZURE_CLIENT_ID
|
||||
const clientSecret = process.env.AZURE_CLIENT_SECRET
|
||||
const tenantId = process.env.AZURE_TENANT_ID
|
||||
|
||||
if (!clientId || !clientSecret || !tenantId) {
|
||||
throw new Error('Azure credentials not configured (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)')
|
||||
}
|
||||
|
||||
msalClient = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authority: `https://login.microsoftonline.com/${tenantId}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return msalClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an authenticated Microsoft Graph client.
|
||||
*/
|
||||
async function getGraphClient(): Promise<Client> {
|
||||
const msal = getMsalClient()
|
||||
|
||||
const tokenResponse = await msal.acquireTokenByClientCredential({
|
||||
scopes: ['https://graph.microsoft.com/.default'],
|
||||
})
|
||||
|
||||
if (!tokenResponse?.accessToken) {
|
||||
throw new Error('Failed to acquire Graph API access token')
|
||||
}
|
||||
|
||||
graphClient = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, tokenResponse.accessToken)
|
||||
},
|
||||
})
|
||||
|
||||
return graphClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email via Microsoft Graph API.
|
||||
*/
|
||||
export async function sendGraphEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
htmlContent: string
|
||||
): Promise<void> {
|
||||
const client = await getGraphClient()
|
||||
const sender = process.env.EMAIL_USER
|
||||
|
||||
if (!sender) {
|
||||
throw new Error('EMAIL_USER not configured')
|
||||
}
|
||||
|
||||
await client.api(`/users/${sender}/sendMail`).post({
|
||||
message: {
|
||||
subject,
|
||||
body: {
|
||||
contentType: 'HTML',
|
||||
content: htmlContent,
|
||||
},
|
||||
toRecipients: [
|
||||
{
|
||||
emailAddress: {
|
||||
address: to,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
saveToSentItems: false,
|
||||
})
|
||||
}
|
||||
109
src/lib/email.ts
Normal file
109
src/lib/email.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { sendGraphEmail } from './email-graph'
|
||||
|
||||
/**
|
||||
* Determines the configured email provider.
|
||||
* Returns 'none' if neither Graph nor SMTP credentials are set.
|
||||
*/
|
||||
function getEmailProvider(): 'graph' | 'smtp' | 'none' {
|
||||
if (process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_TENANT_ID) {
|
||||
return 'graph'
|
||||
}
|
||||
if (process.env.EMAIL_USER && process.env.EMAIL_PASS) {
|
||||
return 'smtp'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email via SMTP (nodemailer).
|
||||
*/
|
||||
async function sendSmtpEmail(to: string, subject: string, html: string): Promise<void> {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST || 'smtp.office365.com',
|
||||
port: Number(process.env.EMAIL_PORT) || 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
})
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_USER,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email with automatic fallback: Graph API → SMTP.
|
||||
* Always use this function for sending emails.
|
||||
*/
|
||||
export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
|
||||
const provider = getEmailProvider()
|
||||
|
||||
if (provider === 'none') {
|
||||
console.warn('[Email] No email provider configured (Graph API or SMTP). Email not sent.')
|
||||
console.warn(`[Email] To: ${to} | Subject: ${subject}`)
|
||||
throw new Error('No email provider configured')
|
||||
}
|
||||
|
||||
if (provider === 'graph') {
|
||||
try {
|
||||
await sendGraphEmail(to, subject, html)
|
||||
console.log(`[Email] Sent via Graph API to ${to}`)
|
||||
return
|
||||
} catch (graphError) {
|
||||
console.error('[Email] Graph API failed, trying SMTP fallback:', (graphError as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendSmtpEmail(to, subject, html)
|
||||
console.log(`[Email] Sent via SMTP to ${to}`)
|
||||
} catch (smtpError) {
|
||||
console.error('[Email] All providers failed:', (smtpError as Error).message)
|
||||
throw new Error('Failed to send email via all providers')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a 2FA verification code email.
|
||||
*/
|
||||
export async function send2FAEmail(to: string, code: string, username: string): Promise<void> {
|
||||
const provider = getEmailProvider()
|
||||
|
||||
if (provider === 'none') {
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
console.log('║ MC-Manager — 2FA Verification Code ║')
|
||||
console.log('╠══════════════════════════════════════════════════════╣')
|
||||
console.log(`║ User: ${username.padEnd(44)}║`)
|
||||
console.log(`║ Email: ${to.padEnd(44)}║`)
|
||||
console.log(`║ Code: ${code.padEnd(44)}║`)
|
||||
console.log('╠══════════════════════════════════════════════════════╣')
|
||||
console.log('║ ⚠ No email provider configured (Graph/SMTP) ║')
|
||||
console.log('║ Configure AZURE_* or EMAIL_* env vars for email. ║')
|
||||
console.log('╚══════════════════════════════════════════════════════╝')
|
||||
console.log('')
|
||||
return
|
||||
}
|
||||
|
||||
const subject = 'MC-Manager - Verification Code'
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 24px;">
|
||||
<h2 style="color: #06b6d4; margin-bottom: 16px;">MC-Manager</h2>
|
||||
<p style="color: #374151; font-size: 16px;">Hi ${username},</p>
|
||||
<p style="color: #374151; font-size: 16px;">Your verification code is:</p>
|
||||
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; text-align: center; margin: 24px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #111827;">${code}</span>
|
||||
</div>
|
||||
<p style="color: #6b7280; font-size: 14px;">This code expires in 5 minutes.</p>
|
||||
<p style="color: #6b7280; font-size: 14px;">If you didn't request this, please ignore this email.</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
await sendEmail(to, subject, html)
|
||||
}
|
||||
69
src/lib/input-validation.ts
Normal file
69
src/lib/input-validation.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Sanitize a string: remove null bytes, control characters, trim, limit length.
|
||||
*/
|
||||
export function sanitizeString(input: string, maxLength = 10000): string {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/\0/g, '')
|
||||
.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '')
|
||||
.substring(0, maxLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggressively sanitize HTML content.
|
||||
*/
|
||||
export function sanitizeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||
.replace(/javascript:/gi, '')
|
||||
.replace(/data:text\/html/gi, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize all string values in an object.
|
||||
*/
|
||||
export function sanitizeObject<T>(obj: T): T {
|
||||
if (typeof obj === 'string') return sanitizeString(obj) as T
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeObject) as T
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
sanitized[key] = sanitizeObject((obj as Record<string, unknown>)[key])
|
||||
}
|
||||
}
|
||||
return sanitized as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a MongoDB ObjectId format.
|
||||
*/
|
||||
export function isValidObjectId(id: string): boolean {
|
||||
return /^[0-9a-fA-F]{24}$/.test(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an email address format.
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email) && email.length <= 254
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression (basic check — node-cron validates further).
|
||||
*/
|
||||
export function isValidCron(expression: string): boolean {
|
||||
const parts = expression.trim().split(/\s+/)
|
||||
return parts.length === 5 || parts.length === 6
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a port number.
|
||||
*/
|
||||
export function isValidPort(port: number): boolean {
|
||||
return Number.isInteger(port) && port >= 1024 && port <= 65535
|
||||
}
|
||||
197
src/lib/models.ts
Normal file
197
src/lib/models.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import mongoose, { Schema, Document, Model } from 'mongoose'
|
||||
|
||||
// ─── User ────────────────────────────────────────────────────────
|
||||
|
||||
export interface IUser extends Document {
|
||||
username: string
|
||||
email: string
|
||||
passwordHash: string
|
||||
roles: mongoose.Types.ObjectId[]
|
||||
twoFactorCode: string | null
|
||||
twoFactorExpiry: Date | null
|
||||
loginAttempts: number
|
||||
lockUntil: Date | null
|
||||
status: 'active' | 'inactive' | 'locked'
|
||||
lastLogin: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUser>(
|
||||
{
|
||||
username: { type: String, required: true, unique: true, trim: true },
|
||||
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
roles: [{ type: Schema.Types.ObjectId, ref: 'Role' }],
|
||||
twoFactorCode: { type: String, default: null },
|
||||
twoFactorExpiry: { type: Date, default: null },
|
||||
loginAttempts: { type: Number, default: 0 },
|
||||
lockUntil: { type: Date, default: null },
|
||||
status: { type: String, enum: ['active', 'inactive', 'locked'], default: 'active' },
|
||||
lastLogin: { type: Date, default: null },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
// ─── Role ────────────────────────────────────────────────────────
|
||||
|
||||
export interface IPermission {
|
||||
resource: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface IRole extends Document {
|
||||
name: string
|
||||
permissions: IPermission[]
|
||||
description: string
|
||||
isDefault: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const roleSchema = new Schema<IRole>(
|
||||
{
|
||||
name: { type: String, required: true, unique: true, trim: true },
|
||||
permissions: [
|
||||
{
|
||||
resource: { type: String, required: true },
|
||||
actions: [{ type: String, required: true }],
|
||||
},
|
||||
],
|
||||
description: { type: String, default: '' },
|
||||
isDefault: { type: Boolean, default: false },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
// ─── Server ──────────────────────────────────────────────────────
|
||||
|
||||
export interface IServer extends Document {
|
||||
name: string
|
||||
type: 'vanilla' | 'bukkit' | 'forge' | 'fabric'
|
||||
version: string
|
||||
dockerImage: string
|
||||
containerId: string | null
|
||||
containerName: string
|
||||
port: number
|
||||
rconPort: number | null
|
||||
status: 'online' | 'offline' | 'starting' | 'stopping' | 'crashed'
|
||||
maxPlayers: number
|
||||
memory: { min: number; max: number }
|
||||
jvmArgs: string[]
|
||||
autoStart: boolean
|
||||
autoRestart: boolean
|
||||
backupSchedule: string | null
|
||||
backupRetention: number
|
||||
createdBy: mongoose.Types.ObjectId
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const serverSchema = new Schema<IServer>(
|
||||
{
|
||||
name: { type: String, required: true, trim: true },
|
||||
type: { type: String, required: true, enum: ['vanilla', 'bukkit', 'forge', 'fabric'] },
|
||||
version: { type: String, required: true },
|
||||
dockerImage: { type: String, default: 'itzg/minecraft-server' },
|
||||
containerId: { type: String, default: null },
|
||||
containerName: { type: String, required: true, unique: true },
|
||||
port: { type: Number, required: true },
|
||||
rconPort: { type: Number, default: null },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['online', 'offline', 'starting', 'stopping', 'crashed'],
|
||||
default: 'offline',
|
||||
},
|
||||
maxPlayers: { type: Number, default: 20 },
|
||||
memory: {
|
||||
min: { type: Number, default: 512 },
|
||||
max: { type: Number, default: 1024 },
|
||||
},
|
||||
jvmArgs: [{ type: String }],
|
||||
autoStart: { type: Boolean, default: false },
|
||||
autoRestart: { type: Boolean, default: true },
|
||||
backupSchedule: { type: String, default: null },
|
||||
backupRetention: { type: Number, default: 5 },
|
||||
createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
// ─── Backup ──────────────────────────────────────────────────────
|
||||
|
||||
export interface IBackup extends Document {
|
||||
serverId: mongoose.Types.ObjectId
|
||||
filename: string
|
||||
filePath: string
|
||||
fileSize: number
|
||||
type: 'manual' | 'scheduled'
|
||||
status: 'completed' | 'in_progress' | 'failed'
|
||||
createdBy: mongoose.Types.ObjectId
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
const backupSchema = new Schema<IBackup>(
|
||||
{
|
||||
serverId: { type: Schema.Types.ObjectId, ref: 'Server', required: true, index: true },
|
||||
filename: { type: String, required: true },
|
||||
filePath: { type: String, required: true },
|
||||
fileSize: { type: Number, default: 0 },
|
||||
type: { type: String, enum: ['manual', 'scheduled'], required: true },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['completed', 'in_progress', 'failed'],
|
||||
default: 'in_progress',
|
||||
},
|
||||
createdBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
// ─── Audit Log ───────────────────────────────────────────────────
|
||||
|
||||
export interface IAuditLog extends Document {
|
||||
action: string
|
||||
entityType: string
|
||||
entityId?: string
|
||||
entityName?: string
|
||||
userId?: string
|
||||
userName?: string
|
||||
userEmail?: string
|
||||
previousValues: Record<string, unknown> | null
|
||||
newValues: Record<string, unknown> | null
|
||||
changes: Record<string, string> | null
|
||||
clientIP: string
|
||||
status: 'SUCCESS' | 'FAILED'
|
||||
statusCode: number
|
||||
errorMessage?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
const auditLogSchema = new Schema<IAuditLog>(
|
||||
{
|
||||
action: { type: String, required: true, index: true },
|
||||
entityType: { type: String, required: true, index: true },
|
||||
entityId: { type: String, default: '' },
|
||||
entityName: { type: String, default: '' },
|
||||
userId: { type: String, default: '', index: true },
|
||||
userName: { type: String, default: '' },
|
||||
userEmail: { type: String, default: '' },
|
||||
previousValues: { type: Schema.Types.Mixed, default: null },
|
||||
newValues: { type: Schema.Types.Mixed, default: null },
|
||||
changes: { type: Schema.Types.Mixed, default: null },
|
||||
clientIP: { type: String, default: '' },
|
||||
status: { type: String, enum: ['SUCCESS', 'FAILED'], required: true },
|
||||
statusCode: { type: Number, required: true },
|
||||
errorMessage: { type: String },
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
// ─── Model Exports (prevent re-compilation in dev) ───────────────
|
||||
|
||||
export const User: Model<IUser> = mongoose.models.User || mongoose.model<IUser>('User', userSchema)
|
||||
export const Role: Model<IRole> = mongoose.models.Role || mongoose.model<IRole>('Role', roleSchema)
|
||||
export const Server: Model<IServer> = mongoose.models.Server || mongoose.model<IServer>('Server', serverSchema)
|
||||
export const Backup: Model<IBackup> = mongoose.models.Backup || mongoose.model<IBackup>('Backup', backupSchema)
|
||||
export const AuditLog: Model<IAuditLog> = mongoose.models.AuditLog || mongoose.model<IAuditLog>('AuditLog', auditLogSchema)
|
||||
43
src/lib/mongodb.ts
Normal file
43
src/lib/mongodb.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
interface MongooseCache {
|
||||
conn: typeof mongoose | null
|
||||
promise: Promise<typeof mongoose> | null
|
||||
}
|
||||
|
||||
declare global {
|
||||
var mongoose: MongooseCache | undefined
|
||||
}
|
||||
|
||||
const cached: MongooseCache = global.mongoose || { conn: null, promise: null }
|
||||
|
||||
if (!global.mongoose) {
|
||||
global.mongoose = cached
|
||||
}
|
||||
|
||||
export default async function connectToDatabase(): Promise<typeof mongoose> {
|
||||
if (cached.conn) {
|
||||
return cached.conn
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
const MONGODB_URI = process.env.MONGODB_URI
|
||||
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error('Please define the MONGODB_URI environment variable')
|
||||
}
|
||||
|
||||
cached.promise = mongoose.connect(MONGODB_URI, {
|
||||
bufferCommands: false,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
cached.conn = await cached.promise
|
||||
} catch (e) {
|
||||
cached.promise = null
|
||||
throw e
|
||||
}
|
||||
|
||||
return cached.conn
|
||||
}
|
||||
66
src/middleware.ts
Normal file
66
src/middleware.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const publicRoutes = ['/login', '/api/auth/login', '/api/auth/verify-2fa', '/api/health']
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Allow public routes
|
||||
if (publicRoutes.some(route => pathname.startsWith(route))) {
|
||||
return addSecurityHeaders(NextResponse.next())
|
||||
}
|
||||
|
||||
// Allow static assets
|
||||
if (
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/favicon') ||
|
||||
pathname.includes('.')
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Check for session token
|
||||
const sessionToken = request.cookies.get('session-token')?.value
|
||||
|
||||
if (!sessionToken) {
|
||||
// API routes return 401
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Pages redirect to login
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('redirect', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
// CORS enforcement in production
|
||||
if (process.env.NODE_ENV === 'production' && pathname.startsWith('/api/')) {
|
||||
const origin = request.headers.get('origin')
|
||||
const allowedOrigins = [
|
||||
process.env.NEXT_PUBLIC_APP_URL,
|
||||
].filter(Boolean)
|
||||
|
||||
if (origin && !allowedOrigins.includes(origin)) {
|
||||
return NextResponse.json({ error: 'CORS: Origin not allowed' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
return addSecurityHeaders(NextResponse.next())
|
||||
}
|
||||
|
||||
function addSecurityHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set('X-Frame-Options', 'DENY')
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||
response.headers.set('X-XSS-Protection', '1; mode=block')
|
||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
response.headers.set(
|
||||
'Permissions-Policy',
|
||||
'geolocation=(), microphone=(), camera=()'
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||
}
|
||||
15
src/types/backup.ts
Normal file
15
src/types/backup.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export type BackupType = 'manual' | 'scheduled'
|
||||
|
||||
export type BackupStatus = 'completed' | 'in_progress' | 'failed'
|
||||
|
||||
export interface Backup {
|
||||
_id: string
|
||||
serverId: string
|
||||
filename: string
|
||||
filePath: string
|
||||
fileSize: number
|
||||
type: BackupType
|
||||
status: BackupStatus
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
}
|
||||
37
src/types/index.ts
Normal file
37
src/types/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export * from './user'
|
||||
export * from './server'
|
||||
export * from './backup'
|
||||
|
||||
export interface AuditLog {
|
||||
_id: string
|
||||
action: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName: string
|
||||
userId: string
|
||||
userName: string
|
||||
userEmail: string
|
||||
previousValues: Record<string, unknown> | null
|
||||
newValues: Record<string, unknown> | null
|
||||
changes: Record<string, string> | null
|
||||
clientIP: string
|
||||
status: 'SUCCESS' | 'FAILED'
|
||||
statusCode: number
|
||||
errorMessage?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T = unknown> {
|
||||
success: boolean
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
66
src/types/server.ts
Normal file
66
src/types/server.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export type ServerType = 'vanilla' | 'bukkit' | 'forge' | 'fabric'
|
||||
|
||||
export type ServerStatus = 'online' | 'offline' | 'starting' | 'stopping' | 'crashed'
|
||||
|
||||
export interface ServerMemory {
|
||||
min: number // MB
|
||||
max: number // MB
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
_id: string
|
||||
name: string
|
||||
type: ServerType
|
||||
version: string
|
||||
dockerImage: string
|
||||
containerId: string | null
|
||||
containerName: string
|
||||
port: number
|
||||
rconPort: number | null
|
||||
status: ServerStatus
|
||||
maxPlayers: number
|
||||
memory: ServerMemory
|
||||
jvmArgs: string[]
|
||||
autoStart: boolean
|
||||
autoRestart: boolean
|
||||
backupSchedule: string | null
|
||||
backupRetention: number
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ServerFormData {
|
||||
name: string
|
||||
type: ServerType
|
||||
version: string
|
||||
dockerImage?: string
|
||||
port: number
|
||||
rconPort?: number
|
||||
maxPlayers: number
|
||||
memory: ServerMemory
|
||||
jvmArgs?: string[]
|
||||
autoStart?: boolean
|
||||
autoRestart?: boolean
|
||||
backupSchedule?: string | null
|
||||
backupRetention?: number
|
||||
}
|
||||
|
||||
export interface OnlinePlayer {
|
||||
name: string
|
||||
uuid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the server type supports plugin management (bukkit-based).
|
||||
*/
|
||||
export function supportsPlugins(type: ServerType): boolean {
|
||||
return type === 'bukkit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the server type supports mod management (forge/fabric).
|
||||
*/
|
||||
export function supportsMods(type: ServerType): boolean {
|
||||
return type === 'forge' || type === 'fabric'
|
||||
}
|
||||
42
src/types/user.ts
Normal file
42
src/types/user.ts
Normal file
@ -0,0 +1,42 @@
|
||||
export interface Permission {
|
||||
resource: string
|
||||
actions: string[]
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
_id: string
|
||||
name: string
|
||||
permissions: Permission[]
|
||||
description: string
|
||||
isDefault: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
_id: string
|
||||
username: string
|
||||
email: string
|
||||
roles: Role[] | string[]
|
||||
status: 'active' | 'inactive' | 'locked'
|
||||
lastLogin: string | null
|
||||
loginAttempts: number
|
||||
lockUntil: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
_id: string
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
permissions: string[]
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user