initial commit

This commit is contained in:
rmoren97 2026-02-07 12:20:12 -08:00
parent df98be5555
commit 79b41409e5
91 changed files with 13009 additions and 113 deletions

213
.github/copilot-instructions.md vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"editor.fontFamily": "'CaskaydiaMono Nerd Font Mono'",
"editor.allowVariableFonts": true
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 []
}
}

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View 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">&gt;</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
View 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
View 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>
)
}

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

View 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}</>
}

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

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

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

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

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

View 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

View 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

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

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

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

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

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

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