mirror of
https://github.com/rmoren97/mc-manager.git
synced 2026-02-10 17:40:30 -08:00
initial commit
This commit is contained in:
parent
df98be5555
commit
79b41409e5
213
.github/copilot-instructions.md
vendored
Normal file
213
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
# MC-Manager — Copilot Instructions
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Minecraft Server Manager — a full-stack Next.js 15+ (App Router) web application for managing Minecraft server instances. Built following Rezzect's organization-wide Next.js standards.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Framework:** Next.js 15+ (App Router, Turbopack), React 19+, TypeScript 5.9+ (strict mode)
|
||||||
|
- **Styling:** Tailwind CSS 3.4+ with dark glassmorphism design system (cyan-500 primary, `bg-gray-900/80 backdrop-blur-lg`)
|
||||||
|
- **Database:** MongoDB 6+ via Mongoose 8+ with serverless-safe cached connections (`src/lib/mongodb.ts`)
|
||||||
|
- **Auth:** Dual-token JWT (1h access + 7d refresh) in HTTP-only cookies, mandatory 2FA via email, bcryptjs (12 rounds)
|
||||||
|
- **Containers:** Docker via dockerode — each MC server runs as its own container
|
||||||
|
- **Icons:** Lucide React only — no other icon libraries
|
||||||
|
- **Email:** Microsoft Graph API primary, SMTP (nodemailer) fallback — always implement dual-provider
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
src/app/ — Pages ('use client' on every page) and API routes
|
||||||
|
src/components/ — Shared components; templates/ for reusable CRUD patterns (DataManagementTemplate)
|
||||||
|
src/contexts/ — AuthContext, ToastContext, ConfirmationContext
|
||||||
|
src/lib/ — Server utilities: auth.ts, mongodb.ts, models.ts, docker.ts, date-utils.ts, input-validation.ts, audit.ts
|
||||||
|
src/hooks/ — Custom React hooks
|
||||||
|
src/types/ — TypeScript interfaces and types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Conventions
|
||||||
|
- **Every page** must start with `'use client'`; add `export const dynamic = 'force-dynamic'` if using `useSearchParams`
|
||||||
|
- **Date formatting:** NEVER create custom formatters — always use `formatDate()`, `formatDateTime()`, `formatDateForInput()` from `@/lib/date-utils`
|
||||||
|
- **Images:** Always `next/image` — never raw `<img>` tags
|
||||||
|
- **CRUD pages:** Use `DataManagementTemplate` from `@/components/templates/` for consistent data management UIs
|
||||||
|
- **Drawers** over modals for detail views (`max-w-3xl`, `animate-slide-in-right`)
|
||||||
|
|
||||||
|
## API Route Pattern (every route must follow this order)
|
||||||
|
1. `validateSession(request)` → 401 if missing
|
||||||
|
2. `getClientIP(request)` for audit trail
|
||||||
|
3. `sanitizeObject(await request.json())` — sanitize ALL input
|
||||||
|
4. `isValidObjectId()` / `isValidEmail()` — validate params
|
||||||
|
5. Permission check → 403 + audit log if denied
|
||||||
|
6. `connectToDatabase()` then business logic
|
||||||
|
7. `createAuditLog()` for every CREATE/UPDATE/DELETE (success AND failure)
|
||||||
|
8. Return proper HTTP status: 200/201/400/401/403/404/409/500
|
||||||
|
|
||||||
|
## Security (non-negotiable)
|
||||||
|
- Server-side permission checks are **authoritative**; client-side checks are UI hints only
|
||||||
|
- Permission format: `resource:action` (e.g., `servers:edit`, `servers:view:department`)
|
||||||
|
- Sanitize all inputs via `sanitizeObject()` from `@/lib/input-validation.ts`
|
||||||
|
- Parameterized DB queries only — never string concatenation
|
||||||
|
- Audit log ALL mutations with previous/new values and client IP
|
||||||
|
|
||||||
|
## UI Patterns (Dark Theme)
|
||||||
|
- **Base background:** `bg-gray-950` (page), `bg-gray-900` (surfaces), `bg-gray-800` (elevated elements)
|
||||||
|
- **Color semantics:** cyan=primary, emerald=success/active, amber=warning/pending, red=error/danger, gray=neutral
|
||||||
|
- **Text colors:** `text-gray-100` (primary), `text-gray-400` (secondary), `text-gray-500` (muted)
|
||||||
|
- **Animations:** Only animate `transform` and `opacity`, keep under 300ms
|
||||||
|
- **Buttons:** `bg-cyan-500 hover:bg-cyan-600 text-white rounded-lg` (primary), `bg-red-500 hover:bg-red-600` (danger), `bg-gray-700 hover:bg-gray-600 text-gray-200` (secondary)
|
||||||
|
- **Inputs:** `bg-gray-800 border-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-cyan-500 focus:border-transparent placeholder-gray-500`
|
||||||
|
- **Cards:** `bg-gray-900/80 backdrop-blur-lg rounded-lg shadow-lg border border-gray-700/50 p-6`
|
||||||
|
- **Glassmorphism card:** `bg-gray-800/60 backdrop-blur-lg rounded-lg border border-gray-700/50 shadow-xl`
|
||||||
|
- **Tables:** `bg-gray-900` body, `bg-gray-800/50` header, `divide-gray-700`, `hover:bg-gray-800` rows
|
||||||
|
- **Badges:** `bg-cyan-500/20 text-cyan-400` (info), `bg-emerald-500/20 text-emerald-400` (success), `bg-amber-500/20 text-amber-400` (warning), `bg-red-500/20 text-red-400` (error)
|
||||||
|
- **Borders:** `border-gray-700` (standard), `border-gray-700/50` (subtle)
|
||||||
|
- **Import order:** React hooks → Next.js hooks → contexts → lib utilities → components
|
||||||
|
|
||||||
|
## Minecraft Domain & Server Types
|
||||||
|
|
||||||
|
Each server instance has a **type** that determines its capabilities:
|
||||||
|
|
||||||
|
| Type | Examples | Supports Plugins | Supports Mods |
|
||||||
|
|------|----------|:-:|:-:|
|
||||||
|
| `vanilla` | Official Mojang JAR | ✗ | ✗ |
|
||||||
|
| `bukkit` | Spigot, PaperMC, Purpur, etc. | ✓ | ✗ |
|
||||||
|
| `forge` | Forge | ✗ | ✓ |
|
||||||
|
| `fabric` | Fabric | ✗ | ✓ |
|
||||||
|
|
||||||
|
**Feature matrix by type — gate UI and API logic on `server.type`:**
|
||||||
|
- **All types:** start/stop/restart, console streaming, command execution, server.properties editing, JVM args, backup/restore, player management (whitelist, ops, bans)
|
||||||
|
- **Bukkit-based only:** plugin install/remove/enable/disable (JAR-based in `plugins/` directory)
|
||||||
|
- **Forge/Fabric only:** mod install/remove/enable/disable (JAR-based in `mods/` directory)
|
||||||
|
- **Vanilla:** no extension management — only core server features
|
||||||
|
|
||||||
|
### Core Feature Areas
|
||||||
|
1. **Server Lifecycle** — create, start, stop, restart, delete instances; real-time status (online/offline/starting/stopping)
|
||||||
|
2. **Console** — live log streaming (tail server stdout), send commands to server stdin
|
||||||
|
3. **Configuration** — edit server.properties, JVM memory/flags, world settings per instance
|
||||||
|
4. **Backups** — manual + scheduled backups of world data; restore to point-in-time
|
||||||
|
5. **Plugins** (bukkit only) — upload/install/remove JARs; enable/disable without removing
|
||||||
|
6. **Mods** (forge/fabric only) — upload/install/remove JARs; enable/disable without removing
|
||||||
|
7. **Player Management** — whitelist add/remove, op/deop, ban/unban, view online players
|
||||||
|
|
||||||
|
## Data Models (Mongoose schemas in `src/lib/models.ts`)
|
||||||
|
|
||||||
|
### User
|
||||||
|
- `username`, `email`, `passwordHash` (bcrypt 12 rounds)
|
||||||
|
- `roles: [ObjectId]` → references Role
|
||||||
|
- `twoFactorCode`, `twoFactorExpiry` — for mandatory 2FA
|
||||||
|
- `loginAttempts`, `lockUntil` — account lockout (5 attempts → 30 min)
|
||||||
|
- `status`: active | inactive | locked
|
||||||
|
- `lastLogin`, `createdAt`, `updatedAt`
|
||||||
|
|
||||||
|
### Role
|
||||||
|
- `name` (e.g., Admin, Operator, Viewer)
|
||||||
|
- `permissions: [{ resource: string, actions: string[] }]`
|
||||||
|
- `description`, `isDefault`, `createdAt`
|
||||||
|
|
||||||
|
### Server
|
||||||
|
- `name`, `type`: vanilla | bukkit | forge | fabric
|
||||||
|
- `version` (MC version, e.g., "1.21.4")
|
||||||
|
- `dockerImage` (default `itzg/minecraft-server`, or a custom image)
|
||||||
|
- `containerId` (Docker container ID — set after creation)
|
||||||
|
- `containerName` (e.g., `mc-{serverId}`)
|
||||||
|
- `port` (host port mapped to container 25565)
|
||||||
|
- `rconPort` (host port mapped to container 25575, optional)
|
||||||
|
- `status`: online | offline | starting | stopping | crashed
|
||||||
|
- `maxPlayers`, `memory` (min/max heap in MB)
|
||||||
|
- `jvmArgs: [string]`
|
||||||
|
- `autoStart: boolean`, `autoRestart: boolean`
|
||||||
|
- `backupSchedule`: cron expression (e.g., `0 */6 * * *` = every 6h) or `null` if manual-only
|
||||||
|
- `backupRetention`: max number of backups to keep per server (oldest auto-deleted)
|
||||||
|
- `createdBy: ObjectId` → User who created it
|
||||||
|
- `createdAt`, `updatedAt`
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
- `serverId: ObjectId` → Server
|
||||||
|
- `filename`, `filePath`, `fileSize`
|
||||||
|
- `type`: manual | scheduled
|
||||||
|
- `status`: completed | in_progress | failed
|
||||||
|
- `createdBy: ObjectId`, `createdAt`
|
||||||
|
|
||||||
|
### AuditLog
|
||||||
|
- Standard org pattern: `action`, `entityType`, `entityId`, `entityName`, `userId`, `userName`, `userEmail`, `previousValues`, `newValues`, `changes`, `clientIP`, `status`, `statusCode`
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Format: `resource:action`. Relevant resources for this project:
|
||||||
|
|
||||||
|
```
|
||||||
|
servers:view servers:create servers:edit servers:delete
|
||||||
|
servers:start servers:stop servers:restart servers:console
|
||||||
|
backups:view backups:create backups:restore backups:delete
|
||||||
|
plugins:view plugins:install plugins:remove plugins:toggle
|
||||||
|
mods:view mods:install mods:remove mods:toggle
|
||||||
|
players:view players:whitelist players:op players:ban
|
||||||
|
users:view users:create users:edit users:delete
|
||||||
|
roles:view roles:create roles:edit roles:delete
|
||||||
|
audit:view
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin role gets `*:*` (wildcard). Always check `server.type` before allowing plugin/mod actions — return 400 if mismatched (e.g., plugin install on a Forge server).
|
||||||
|
|
||||||
|
## Docker Architecture
|
||||||
|
|
||||||
|
Every Minecraft server runs as an isolated Docker container. Use **dockerode** (`src/lib/docker.ts`) to interact with the Docker Engine API via Unix socket.
|
||||||
|
|
||||||
|
### Docker Images
|
||||||
|
- **Default:** `itzg/minecraft-server` — supports all server types via `TYPE` env var (e.g., `VANILLA`, `PAPER`, `SPIGOT`, `FORGE`, `FABRIC`)
|
||||||
|
- **Custom:** Users can specify any Docker image in `server.dockerImage`; when custom, the app still bind-mounts the same volume layout but skips `itzg`-specific env vars — the user is responsible for image compatibility
|
||||||
|
- Store `dockerImage` on the Server model; default to `itzg/minecraft-server` if not provided
|
||||||
|
|
||||||
|
### Container Lifecycle
|
||||||
|
- **Create:** `docker.createContainer()` with `server.dockerImage`, bind-mount a host volume for persistent data
|
||||||
|
- **Start/Stop/Restart:** `container.start()`, `container.stop()`, `container.restart()` — update `server.status` in MongoDB to match
|
||||||
|
- **Delete:** `container.remove({ force: true })`, then optionally clean up host volume
|
||||||
|
- **Status sync:** On app startup and periodically, reconcile `server.status` with `container.inspect()` state
|
||||||
|
|
||||||
|
### Volume Layout
|
||||||
|
Each server gets a host-mounted volume at a configurable base path (env `MC_SERVERS_PATH`, default `/opt/mc-servers/`):
|
||||||
|
```
|
||||||
|
/opt/mc-servers/{serverId}/
|
||||||
|
├── server.properties # MC config (editable via API)
|
||||||
|
├── world/ # World data (backed up)
|
||||||
|
├── plugins/ # Bukkit-type only
|
||||||
|
├── mods/ # Forge/Fabric only
|
||||||
|
├── logs/ # Server logs
|
||||||
|
└── backups/ # Backup archives
|
||||||
|
```
|
||||||
|
|
||||||
|
### Console & Logs
|
||||||
|
- **Log streaming:** `container.logs({ follow: true, stdout: true, stderr: true })` → stream to client via SSE or WebSocket
|
||||||
|
- **Command execution:** `container.exec()` to run `rcon-cli` or attach to stdin to send commands
|
||||||
|
- Alternative: use RCON protocol directly on the `rconPort` if RCON is enabled in server.properties
|
||||||
|
|
||||||
|
### Key Patterns for `src/lib/docker.ts`
|
||||||
|
- Export a singleton `getDockerClient()` that returns a cached dockerode instance
|
||||||
|
- All container operations must catch Docker API errors and map to proper HTTP status codes
|
||||||
|
- Container names follow convention: `mc-{server._id}` for easy lookup
|
||||||
|
- Always set resource limits: `--memory` from `server.memory.max`, CPU shares as needed
|
||||||
|
- Use `RestartPolicy: { Name: 'unless-stopped' }` when `server.autoRestart` is true
|
||||||
|
|
||||||
|
### Backups
|
||||||
|
- Pause world saving (`save-off` + `save-all` via RCON/exec) → tar/gzip the `world/` directory → resume (`save-on`)
|
||||||
|
- Store backup archives in `{serverDir}/backups/` and record metadata in the Backup model
|
||||||
|
- Restore: stop container → replace `world/` with extracted backup → start container
|
||||||
|
|
||||||
|
### Scheduled Backups (node-cron)
|
||||||
|
- Use **node-cron** running inside the Next.js process — no external scheduler needed
|
||||||
|
- Each server stores a `backupSchedule` cron expression (e.g., `0 */6 * * *`) and `backupRetention` count
|
||||||
|
- On app startup, query all servers with a `backupSchedule` and register cron jobs via `cron.schedule()`
|
||||||
|
- When a server's schedule is created/updated/deleted via API, dynamically add/update/remove the cron job
|
||||||
|
- Keep a `Map<serverId, CronJob>` in memory for lifecycle management
|
||||||
|
- `backupRetention`: after each successful backup, delete oldest archives exceeding the limit
|
||||||
|
- Scheduled backups set `backup.type = 'scheduled'`; manual backups set `backup.type = 'manual'`
|
||||||
|
|
||||||
|
### Environment Variables (Docker-specific)
|
||||||
|
```env
|
||||||
|
MC_SERVERS_PATH=/opt/mc-servers # Base path for server volumes
|
||||||
|
DOCKER_SOCKET=/var/run/docker.sock # Docker socket path (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server (port 3000, Turbopack)
|
||||||
|
npm run build # Production build
|
||||||
|
npm run lint # ESLint check
|
||||||
|
```
|
||||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.fontFamily": "'CaskaydiaMono Nerd Font Mono'",
|
||||||
|
"editor.allowVariableFonts": true
|
||||||
|
}
|
||||||
@ -1,7 +1,21 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
1699
package-lock.json
generated
1699
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -6,21 +6,38 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"seed": "tsx scripts/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.1.6",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/dockerode": "^4.0.1",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv": "^17.2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
218
scripts/seed.ts
Normal file
218
scripts/seed.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Seed script — creates the initial Admin role and admin user.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/seed.ts
|
||||||
|
*
|
||||||
|
* Requires a valid MONGODB_URI in .env (or .env.local).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import * as readline from 'readline'
|
||||||
|
import * as dotenv from 'dotenv'
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config({ path: '.env.local' })
|
||||||
|
dotenv.config({ path: '.env' })
|
||||||
|
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI
|
||||||
|
|
||||||
|
if (!MONGODB_URI) {
|
||||||
|
console.error('❌ MONGODB_URI is not defined. Create a .env or .env.local file first.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Inline Schemas (avoid importing from src/ which needs TS paths) ─────
|
||||||
|
|
||||||
|
const permissionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
resource: { type: String, required: true },
|
||||||
|
actions: [{ type: String, required: true }],
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const roleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, unique: true, trim: true },
|
||||||
|
permissions: [permissionSchema],
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
isDefault: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
username: { type: String, required: true, unique: true, trim: true },
|
||||||
|
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||||
|
passwordHash: { type: String, required: true },
|
||||||
|
roles: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Role' }],
|
||||||
|
twoFactorCode: { type: String, default: null },
|
||||||
|
twoFactorExpiry: { type: Date, default: null },
|
||||||
|
loginAttempts: { type: Number, default: 0 },
|
||||||
|
lockUntil: { type: Date, default: null },
|
||||||
|
status: { type: String, enum: ['active', 'inactive', 'locked'], default: 'active' },
|
||||||
|
lastLogin: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const Role = mongoose.models.Role || mongoose.model('Role', roleSchema)
|
||||||
|
const User = mongoose.models.User || mongoose.model('User', userSchema)
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function prompt(question: string): Promise<string> {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||||
|
return new Promise(resolve => {
|
||||||
|
rl.question(question, answer => {
|
||||||
|
rl.close()
|
||||||
|
resolve(answer.trim())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin Permissions (wildcard on every resource) ──────────────
|
||||||
|
|
||||||
|
const ADMIN_PERMISSIONS = [
|
||||||
|
{ resource: '*', actions: ['*'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Main ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 MC-Manager Seed Script\n')
|
||||||
|
|
||||||
|
await mongoose.connect(MONGODB_URI!)
|
||||||
|
console.log('✅ Connected to MongoDB\n')
|
||||||
|
|
||||||
|
// ── 1. Create or update Admin Role ──────────────────────────────
|
||||||
|
let adminRole = await Role.findOne({ name: 'Admin' })
|
||||||
|
|
||||||
|
if (adminRole) {
|
||||||
|
console.log('ℹ️ Admin role already exists — updating permissions...')
|
||||||
|
adminRole.permissions = ADMIN_PERMISSIONS
|
||||||
|
adminRole.description = 'Full system administrator with all permissions'
|
||||||
|
await adminRole.save()
|
||||||
|
} else {
|
||||||
|
adminRole = await Role.create({
|
||||||
|
name: 'Admin',
|
||||||
|
permissions: ADMIN_PERMISSIONS,
|
||||||
|
description: 'Full system administrator with all permissions',
|
||||||
|
isDefault: false,
|
||||||
|
})
|
||||||
|
console.log('✅ Admin role created')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Create default Viewer role ───────────────────────────────
|
||||||
|
let viewerRole = await Role.findOne({ name: 'Viewer' })
|
||||||
|
|
||||||
|
if (!viewerRole) {
|
||||||
|
viewerRole = await Role.create({
|
||||||
|
name: 'Viewer',
|
||||||
|
permissions: [
|
||||||
|
{ resource: 'servers', actions: ['view'] },
|
||||||
|
{ resource: 'backups', actions: ['view'] },
|
||||||
|
{ resource: 'plugins', actions: ['view'] },
|
||||||
|
{ resource: 'mods', actions: ['view'] },
|
||||||
|
{ resource: 'players', actions: ['view'] },
|
||||||
|
],
|
||||||
|
description: 'Read-only access to servers and related resources',
|
||||||
|
isDefault: true,
|
||||||
|
})
|
||||||
|
console.log('✅ Viewer role created')
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Viewer role already exists — skipped')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Create Operator role ─────────────────────────────────────
|
||||||
|
let operatorRole = await Role.findOne({ name: 'Operator' })
|
||||||
|
|
||||||
|
if (!operatorRole) {
|
||||||
|
operatorRole = await Role.create({
|
||||||
|
name: 'Operator',
|
||||||
|
permissions: [
|
||||||
|
{ resource: 'servers', actions: ['view', 'start', 'stop', 'restart', 'console'] },
|
||||||
|
{ resource: 'backups', actions: ['view', 'create'] },
|
||||||
|
{ resource: 'plugins', actions: ['view', 'install', 'remove', 'toggle'] },
|
||||||
|
{ resource: 'mods', actions: ['view', 'install', 'remove', 'toggle'] },
|
||||||
|
{ resource: 'players', actions: ['view', 'whitelist', 'op', 'ban'] },
|
||||||
|
],
|
||||||
|
description: 'Can operate servers but cannot create/delete them or manage users',
|
||||||
|
isDefault: false,
|
||||||
|
})
|
||||||
|
console.log('✅ Operator role created')
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Operator role already exists — skipped')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Create Admin User ────────────────────────────────────────
|
||||||
|
const existingAdmin = await User.findOne({ username: 'admin' })
|
||||||
|
|
||||||
|
if (existingAdmin) {
|
||||||
|
console.log('\nℹ️ Admin user already exists.')
|
||||||
|
const reset = await prompt('Reset admin password? (y/N): ')
|
||||||
|
|
||||||
|
if (reset.toLowerCase() === 'y') {
|
||||||
|
const newPassword = await prompt('New password (min 8 chars): ')
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
console.error('❌ Password must be at least 8 characters.')
|
||||||
|
await mongoose.disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
existingAdmin.passwordHash = await bcrypt.hash(newPassword, 12)
|
||||||
|
existingAdmin.status = 'active'
|
||||||
|
existingAdmin.loginAttempts = 0
|
||||||
|
existingAdmin.lockUntil = null
|
||||||
|
|
||||||
|
// Ensure admin has the Admin role
|
||||||
|
if (!existingAdmin.roles.some((r: mongoose.Types.ObjectId) => r.equals(adminRole._id))) {
|
||||||
|
existingAdmin.roles.push(adminRole._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await existingAdmin.save()
|
||||||
|
console.log('✅ Admin password reset successfully')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n📝 Create initial admin user:\n')
|
||||||
|
|
||||||
|
const email = await prompt('Admin email: ')
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
console.error('❌ A valid email is required.')
|
||||||
|
await mongoose.disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await prompt('Admin password (min 8 chars): ')
|
||||||
|
if (password.length < 8) {
|
||||||
|
console.error('❌ Password must be at least 8 characters.')
|
||||||
|
await mongoose.disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
username: 'admin',
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
roles: [adminRole._id],
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ Admin user created (username: admin)')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Done ────────────────────────────────────────────────────────
|
||||||
|
console.log('\n🎉 Seed complete! You can now log in at http://localhost:3000')
|
||||||
|
|
||||||
|
await mongoose.disconnect()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('❌ Seed failed:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
169
src/app/(app)/audit/page.tsx
Normal file
169
src/app/(app)/audit/page.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FileText, Search } from 'lucide-react'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
|
import { formatDateTime } from '@/lib/date-utils'
|
||||||
|
import type { AuditLog } from '@/types'
|
||||||
|
|
||||||
|
export default function AuditPage() {
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [entityFilter, setEntityFilter] = useState('')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [totalPages, setTotalPages] = useState(1)
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), limit: '50' })
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
if (entityFilter) params.set('entityType', entityFilter)
|
||||||
|
|
||||||
|
const res = await fetch(`/api/audit?${params}`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLogs(data.data || [])
|
||||||
|
setTotalPages(data.totalPages || 1)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to fetch audit logs')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, search, entityFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs()
|
||||||
|
}, [fetchLogs])
|
||||||
|
|
||||||
|
const entityTypes = [
|
||||||
|
{ label: 'Server', value: 'server' },
|
||||||
|
{ label: 'Backup', value: 'backup' },
|
||||||
|
{ label: 'User', value: 'user' },
|
||||||
|
{ label: 'Role', value: 'role' },
|
||||||
|
{ label: 'Plugin', value: 'plugin' },
|
||||||
|
{ label: 'Mod', value: 'mod' },
|
||||||
|
{ label: 'Player', value: 'player' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Audit Log"
|
||||||
|
description="Track all system changes and user actions"
|
||||||
|
icon={FileText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px] max-w-md">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||||
|
placeholder="Search audit logs..."
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 text-gray-100 text-sm rounded-lg pl-10 pr-4 py-2 focus:ring-2 focus:ring-cyan-500 focus:border-transparent placeholder-gray-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={entityFilter}
|
||||||
|
onChange={e => { setEntityFilter(e.target.value); setPage(1) }}
|
||||||
|
options={entityTypes}
|
||||||
|
placeholder="All Entity Types"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<FileText size={40} className="mx-auto mb-3 text-gray-600" />
|
||||||
|
<p className="font-medium">No audit logs found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-800/50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Timestamp</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">User</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Action</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Entity</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{logs.map(log => (
|
||||||
|
<tr key={log._id} className="hover:bg-gray-800">
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-400 whitespace-nowrap">
|
||||||
|
{formatDateTime(log.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-gray-200">
|
||||||
|
{log.userName}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-sm text-gray-200 font-mono text-xs">
|
||||||
|
{log.action}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<div>
|
||||||
|
<Badge variant="neutral">{log.entityType}</Badge>
|
||||||
|
<span className="ml-2 text-sm text-gray-300">{log.entityName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Badge variant={log.status === 'SUCCESS' ? 'success' : 'error'}>
|
||||||
|
{log.statusCode || log.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-xs text-gray-500 font-mono">
|
||||||
|
{log.clientIP}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-700">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1.5 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm bg-gray-800 text-gray-300 rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
src/app/(app)/dashboard/page.tsx
Normal file
163
src/app/(app)/dashboard/page.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { LayoutDashboard, Server, HardDrive, Users, Activity } from 'lucide-react'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import ServerStatusBadge from '@/components/ServerStatusBadge'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import type { Server as ServerType } from '@/types/server'
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
totalServers: number
|
||||||
|
onlineServers: number
|
||||||
|
totalPlayers: number
|
||||||
|
totalBackups: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [servers, setServers] = useState<ServerType[]>([])
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({ totalServers: 0, onlineServers: 0, totalPlayers: 0, totalBackups: 0 })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/servers', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const serverList: ServerType[] = data.data || []
|
||||||
|
setServers(serverList)
|
||||||
|
setStats({
|
||||||
|
totalServers: serverList.length,
|
||||||
|
onlineServers: serverList.filter(s => s.status === 'online').length,
|
||||||
|
totalPlayers: 0, // TODO: aggregate from online servers
|
||||||
|
totalBackups: 0, // TODO: fetch from backups API
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard data:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
const interval = setInterval(fetchData, 15000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={`Welcome back, ${user?.username}`}
|
||||||
|
description="Overview of your Minecraft servers"
|
||||||
|
icon={LayoutDashboard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Server}
|
||||||
|
label="Total Servers"
|
||||||
|
value={stats.totalServers}
|
||||||
|
color="cyan"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Online"
|
||||||
|
value={stats.onlineServers}
|
||||||
|
color="emerald"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Players Online"
|
||||||
|
value={stats.totalPlayers}
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={HardDrive}
|
||||||
|
label="Total Backups"
|
||||||
|
value={stats.totalBackups}
|
||||||
|
color="gray"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server List */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-100">Servers</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{servers.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
No servers yet. Create your first Minecraft server!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-700">
|
||||||
|
{servers.map(server => (
|
||||||
|
<a
|
||||||
|
key={server._id}
|
||||||
|
href={`/servers/${server._id}`}
|
||||||
|
className="flex items-center justify-between px-6 py-4 hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Server size={20} className="text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-200">{server.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{server.type} · {server.version} · Port {server.port}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ServerStatusBadge status={server.status} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: typeof Server
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
}) {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
cyan: 'bg-cyan-500/20 text-cyan-400',
|
||||||
|
emerald: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
amber: 'bg-amber-500/20 text-amber-400',
|
||||||
|
gray: 'bg-gray-500/20 text-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2.5 rounded-lg ${colorMap[color]}`}>
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-100">{value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/app/(app)/layout.tsx
Normal file
28
src/app/(app)/layout.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Sidebar from '@/components/Sidebar'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-950">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null // Middleware will redirect
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-950">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
319
src/app/(app)/roles/page.tsx
Normal file
319
src/app/(app)/roles/page.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
import { Shield, Edit, Trash2 } from 'lucide-react'
|
||||||
|
import DataManagementTemplate, { Column } from '@/components/templates/DataManagementTemplate'
|
||||||
|
import Drawer from '@/components/Drawer'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import type { Role, Permission } from '@/types/user'
|
||||||
|
|
||||||
|
const allResources = [
|
||||||
|
'servers', 'backups', 'plugins', 'mods', 'players', 'users', 'roles', 'audit',
|
||||||
|
]
|
||||||
|
|
||||||
|
const allActions: Record<string, string[]> = {
|
||||||
|
servers: ['view', 'create', 'edit', 'delete', 'start', 'stop', 'restart', 'console'],
|
||||||
|
backups: ['view', 'create', 'restore', 'delete'],
|
||||||
|
plugins: ['view', 'install', 'remove', 'toggle'],
|
||||||
|
mods: ['view', 'install', 'remove', 'toggle'],
|
||||||
|
players: ['view', 'whitelist', 'op', 'ban'],
|
||||||
|
users: ['view', 'create', 'edit', 'delete'],
|
||||||
|
roles: ['view', 'create', 'edit', 'delete'],
|
||||||
|
audit: ['view'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [editPermissions, setEditPermissions] = useState<Permission[]>([])
|
||||||
|
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/roles', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setRoles(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to fetch roles', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles()
|
||||||
|
}, [fetchRoles])
|
||||||
|
|
||||||
|
const togglePermission = (resource: string, action: string) => {
|
||||||
|
setEditPermissions(prev => {
|
||||||
|
const existing = prev.find(p => p.resource === resource)
|
||||||
|
if (existing) {
|
||||||
|
const hasAction = existing.actions.includes(action)
|
||||||
|
const newActions = hasAction
|
||||||
|
? existing.actions.filter(a => a !== action)
|
||||||
|
: [...existing.actions, action]
|
||||||
|
|
||||||
|
if (newActions.length === 0) {
|
||||||
|
return prev.filter(p => p.resource !== resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.map(p => p.resource === resource ? { ...p, actions: newActions } : p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, { resource, actions: [action] }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermissionToggle = (resource: string, action: string) => {
|
||||||
|
return editPermissions.some(p => p.resource === resource && p.actions.includes(action))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
const form = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/roles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.get('name'),
|
||||||
|
description: form.get('description'),
|
||||||
|
permissions: editPermissions,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Role created successfully', 'success')
|
||||||
|
setShowCreate(false)
|
||||||
|
setEditPermissions([])
|
||||||
|
fetchRoles()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to create role', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to create role', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selectedRole) return
|
||||||
|
setSaving(true)
|
||||||
|
const form = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/roles/${selectedRole._id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: form.get('name'),
|
||||||
|
description: form.get('description'),
|
||||||
|
permissions: editPermissions,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Role updated successfully', 'success')
|
||||||
|
setShowEdit(false)
|
||||||
|
setSelectedRole(null)
|
||||||
|
setEditPermissions([])
|
||||||
|
fetchRoles()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to update role', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to update role', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (role: Role) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Delete Role',
|
||||||
|
message: 'Are you sure you want to permanently delete',
|
||||||
|
itemName: role.name,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Delete Role',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/roles/${role._id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Role deleted', 'success')
|
||||||
|
fetchRoles()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to delete role', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete role', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<Role>[] = [
|
||||||
|
{ key: 'name', label: 'Name', sortable: true },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{
|
||||||
|
key: 'permissions',
|
||||||
|
label: 'Permissions',
|
||||||
|
render: (r) => {
|
||||||
|
const count = r.permissions.reduce((acc, p) => acc + p.actions.length, 0)
|
||||||
|
return <Badge variant="info">{count} permissions</Badge>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isDefault',
|
||||||
|
label: 'Default',
|
||||||
|
render: (r) => r.isDefault ? <Badge variant="success">Default</Badge> : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_id',
|
||||||
|
label: 'Actions',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasPermission('roles:edit') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={Edit}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedRole(r)
|
||||||
|
setEditPermissions(r.permissions || [])
|
||||||
|
setShowEdit(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasPermission('roles:delete') && !r.isDefault && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(r)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const PermissionGrid = () => (
|
||||||
|
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||||
|
{allResources.map(resource => (
|
||||||
|
<div key={resource} className="bg-gray-800/50 rounded-lg p-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wider mb-2">{resource}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allActions[resource].map(action => (
|
||||||
|
<button
|
||||||
|
key={`${resource}:${action}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePermission(resource, action)}
|
||||||
|
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
hasPermissionToggle(resource, action)
|
||||||
|
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/50'
|
||||||
|
: 'bg-gray-700 text-gray-400 border border-gray-600 hover:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataManagementTemplate<Role>
|
||||||
|
title="Roles"
|
||||||
|
description="Manage roles and their permissions"
|
||||||
|
icon={Shield}
|
||||||
|
items={roles}
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(r) => r._id}
|
||||||
|
searchPlaceholder="Search roles..."
|
||||||
|
searchFields={['name', 'description']}
|
||||||
|
onRowClick={(r) => {
|
||||||
|
setSelectedRole(r)
|
||||||
|
setEditPermissions(r.permissions || [])
|
||||||
|
setShowEdit(true)
|
||||||
|
}}
|
||||||
|
onAdd={() => { setEditPermissions([]); setShowCreate(true) }}
|
||||||
|
addLabel="Create Role"
|
||||||
|
canAdd={hasPermission('roles:create')}
|
||||||
|
emptyIcon={Shield}
|
||||||
|
emptyTitle="No roles yet"
|
||||||
|
emptyDescription="Create roles to manage user permissions."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Role Modal */}
|
||||||
|
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Create Role" maxWidth="max-w-2xl">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<Input name="name" label="Role Name" placeholder="Operator" required />
|
||||||
|
<Input name="description" label="Description" placeholder="Can manage servers and players" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Permissions</label>
|
||||||
|
<PermissionGrid />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" loading={saving}>Create Role</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Role Drawer */}
|
||||||
|
<Drawer isOpen={showEdit} onClose={() => { setShowEdit(false); setSelectedRole(null) }} title={`Edit Role — ${selectedRole?.name}`}>
|
||||||
|
{selectedRole && (
|
||||||
|
<form onSubmit={handleUpdate} className="space-y-4">
|
||||||
|
<Input name="name" label="Role Name" defaultValue={selectedRole.name} required />
|
||||||
|
<Input name="description" label="Description" defaultValue={selectedRole.description || ''} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">Permissions</label>
|
||||||
|
<PermissionGrid />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="secondary" type="button" onClick={() => { setShowEdit(false); setSelectedRole(null) }}>Cancel</Button>
|
||||||
|
<Button type="submit" loading={saving}>Save Changes</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
212
src/app/(app)/servers/[id]/backups/page.tsx
Normal file
212
src/app/(app)/servers/[id]/backups/page.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ArrowLeft, HardDrive, RotateCcw, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
import { formatDateTime, formatFileSize } from '@/lib/date-utils'
|
||||||
|
import type { Backup } from '@/types/backup'
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const [backups, setBackups] = useState<Backup[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const fetchBackups = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/backups`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setBackups(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load backups', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBackups()
|
||||||
|
}, [fetchBackups])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/backups`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Backup started', 'success')
|
||||||
|
fetchBackups()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to create backup', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to create backup', 'error')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (backup: Backup) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Restore Backup',
|
||||||
|
message: 'This will stop the server and replace the current world with the backup. Continue?',
|
||||||
|
itemName: backup.filename,
|
||||||
|
type: 'warning',
|
||||||
|
confirmText: 'Restore',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/backups/${backup._id}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Backup restoration started', 'success')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to restore backup', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to restore backup', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (backup: Backup) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Delete Backup',
|
||||||
|
message: 'Are you sure you want to permanently delete',
|
||||||
|
itemName: backup.filename,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Delete',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/backups/${backup._id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Backup deleted', 'success')
|
||||||
|
fetchBackups()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to delete backup', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete backup', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Backups"
|
||||||
|
description="Create and manage world backups"
|
||||||
|
icon={HardDrive}
|
||||||
|
actions={
|
||||||
|
hasPermission('backups:create') ? (
|
||||||
|
<Button icon={Plus} onClick={handleCreate} loading={creating}>
|
||||||
|
Create Backup
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<HardDrive size={40} className="mx-auto mb-3 text-gray-600" />
|
||||||
|
<p className="font-medium">No backups yet</p>
|
||||||
|
<p className="text-sm mt-1">Create your first backup to protect your world data.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-800/50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Filename</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Type</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Size</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Created</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{backups.map(backup => (
|
||||||
|
<tr key={backup._id} className="hover:bg-gray-800">
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-200 font-mono">{backup.filename}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Badge variant={backup.type === 'manual' ? 'info' : 'neutral'}>
|
||||||
|
{backup.type}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-400">{formatFileSize(backup.fileSize)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Badge variant={backup.status === 'completed' ? 'success' : backup.status === 'failed' ? 'error' : 'warning'}>
|
||||||
|
{backup.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-400">{formatDateTime(backup.createdAt)}</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{hasPermission('backups:restore') && backup.status === 'completed' && (
|
||||||
|
<Button size="sm" variant="secondary" icon={RotateCcw} onClick={() => handleRestore(backup)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('backups:delete') && (
|
||||||
|
<Button size="sm" variant="danger" icon={Trash2} onClick={() => handleDelete(backup)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
src/app/(app)/servers/[id]/configuration/page.tsx
Normal file
161
src/app/(app)/servers/[id]/configuration/page.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ArrowLeft, Settings, Save } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
|
||||||
|
interface ServerProperties {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfigurationPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
|
const [properties, setProperties] = useState<ServerProperties>({})
|
||||||
|
const [jvmArgs, setJvmArgs] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/configuration`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setProperties(data.data?.properties || {})
|
||||||
|
setJvmArgs((data.data?.jvmArgs || []).join(' '))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load configuration', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig()
|
||||||
|
}, [fetchConfig])
|
||||||
|
|
||||||
|
const handlePropertyChange = (key: string, value: string) => {
|
||||||
|
setProperties(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/configuration`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
properties,
|
||||||
|
jvmArgs: jvmArgs.split(/\s+/).filter(Boolean),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Configuration saved. Restart the server to apply changes.', 'success')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to save configuration', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to save configuration', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = [
|
||||||
|
'server-port', 'max-players', 'motd', 'difficulty', 'gamemode',
|
||||||
|
'pvp', 'allow-nether', 'spawn-protection', 'view-distance',
|
||||||
|
'online-mode', 'white-list', 'enable-command-block', 'level-seed',
|
||||||
|
'level-name', 'level-type', 'spawn-npcs', 'spawn-animals',
|
||||||
|
'spawn-monsters', 'generate-structures'
|
||||||
|
]
|
||||||
|
|
||||||
|
const sortedKeys = Object.keys(properties).sort((a, b) => {
|
||||||
|
const aCommon = commonProps.indexOf(a)
|
||||||
|
const bCommon = commonProps.indexOf(b)
|
||||||
|
if (aCommon !== -1 && bCommon !== -1) return aCommon - bCommon
|
||||||
|
if (aCommon !== -1) return -1
|
||||||
|
if (bCommon !== -1) return 1
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Configuration"
|
||||||
|
description="Edit server.properties and JVM arguments"
|
||||||
|
icon={Settings}
|
||||||
|
actions={
|
||||||
|
<Button icon={Save} onClick={handleSave} loading={saving}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* JVM Args */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200 mb-3">JVM Arguments</h3>
|
||||||
|
<Input
|
||||||
|
value={jvmArgs}
|
||||||
|
onChange={e => setJvmArgs(e.target.value)}
|
||||||
|
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Space-separated JVM flags. Memory flags are set automatically from server settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Properties */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200">server.properties</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-700">
|
||||||
|
{sortedKeys.map(key => (
|
||||||
|
<div key={key} className="flex items-center gap-4 px-6 py-3">
|
||||||
|
<label className="w-1/3 text-sm text-gray-400 font-mono truncate" title={key}>
|
||||||
|
{key}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={properties[key]}
|
||||||
|
onChange={e => handlePropertyChange(key, e.target.value)}
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 text-gray-100 text-sm rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-cyan-500 focus:border-transparent placeholder-gray-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sortedKeys.length === 0 && (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
No server.properties found. Start the server once to generate the default configuration.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/app/(app)/servers/[id]/console/page.tsx
Normal file
33
src/app/(app)/servers/[id]/console/page.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ArrowLeft, Terminal } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import ConsoleViewer from '@/components/ConsoleViewer'
|
||||||
|
|
||||||
|
export default function ConsolePage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Console"
|
||||||
|
description="View live server logs and execute commands"
|
||||||
|
icon={Terminal}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="h-[calc(100vh-220px)]">
|
||||||
|
<ConsoleViewer serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1076
src/app/(app)/servers/[id]/files/page.tsx
Normal file
1076
src/app/(app)/servers/[id]/files/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
182
src/app/(app)/servers/[id]/logs/page.tsx
Normal file
182
src/app/(app)/servers/[id]/logs/page.tsx
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ArrowLeft, FileText, Download, ChevronRight, RefreshCw } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { formatDateTime } from '@/lib/date-utils'
|
||||||
|
|
||||||
|
interface LogFile {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
modifiedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<LogFile[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||||
|
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||||
|
const [fileLoading, setFileLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchFiles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/logs`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setFiles(data.data)
|
||||||
|
} else {
|
||||||
|
showToast('Failed to load log files', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load log files', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiles()
|
||||||
|
}, [fetchFiles])
|
||||||
|
|
||||||
|
const openFile = async (fileName: string) => {
|
||||||
|
setSelectedFile(fileName)
|
||||||
|
setFileContent(null)
|
||||||
|
setFileLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/logs?file=${encodeURIComponent(fileName)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setFileContent(data.data.content)
|
||||||
|
} else {
|
||||||
|
showToast('Failed to load log file', 'error')
|
||||||
|
setSelectedFile(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load log file', 'error')
|
||||||
|
setSelectedFile(null)
|
||||||
|
} finally {
|
||||||
|
setFileLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = () => {
|
||||||
|
if (!fileContent || !selectedFile) return
|
||||||
|
const blob = new Blob([fileContent], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = selectedFile.replace('.gz', '')
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Logs"
|
||||||
|
description="View server log files"
|
||||||
|
icon={FileText}
|
||||||
|
actions={
|
||||||
|
selectedFile ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button icon={Download} size="sm" variant="secondary" onClick={downloadFile}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => { setSelectedFile(null); setFileContent(null) }}>
|
||||||
|
Back to Files
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button icon={RefreshCw} size="sm" variant="secondary" onClick={fetchFiles}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedFile ? (
|
||||||
|
/* Log File Viewer */
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-700/50 bg-gray-900/50">
|
||||||
|
<FileText size={16} className="text-cyan-400" />
|
||||||
|
<span className="text-sm font-medium text-gray-200">{selectedFile}</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[calc(100vh-300px)] p-4">
|
||||||
|
{fileLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap break-all">
|
||||||
|
{fileContent || 'Empty log file'}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* File List */
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText size={40} className="mx-auto text-gray-600 mb-3" />
|
||||||
|
<p className="text-gray-400">No log files found</p>
|
||||||
|
<p className="text-gray-600 text-sm mt-1">Logs will appear after the server has started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-700/50">
|
||||||
|
{files.map((file) => (
|
||||||
|
<button
|
||||||
|
key={file.name}
|
||||||
|
onClick={() => openFile(file.name)}
|
||||||
|
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-gray-800/60 transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<FileText size={18} className="text-gray-500 group-hover:text-cyan-400 transition-colors flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-200 truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{formatFileSize(file.size)} · {formatDateTime(file.modifiedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-gray-600 group-hover:text-gray-400 transition-colors flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
src/app/(app)/servers/[id]/mods/page.tsx
Normal file
221
src/app/(app)/servers/[id]/mods/page.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ArrowLeft, Package, Upload, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
import { formatFileSize } from '@/lib/date-utils'
|
||||||
|
|
||||||
|
interface ModInfo {
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
size: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const [mods, setMods] = useState<ModInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchMods = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/mods`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setMods(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load mods', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMods()
|
||||||
|
}, [fetchMods])
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (!file.name.endsWith('.jar')) {
|
||||||
|
showToast('Only .jar files are allowed', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/mods`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Mod uploaded successfully', 'success')
|
||||||
|
fetchMods()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to upload mod', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to upload mod', 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (mod: ModInfo) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/mods/${encodeURIComponent(mod.filename)}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Mod ${mod.enabled ? 'disabled' : 'enabled'}`, 'success')
|
||||||
|
fetchMods()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to toggle mod', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to toggle mod', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = async (mod: ModInfo) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Remove Mod',
|
||||||
|
message: 'Are you sure you want to permanently remove',
|
||||||
|
itemName: mod.name,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Remove',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/mods/${encodeURIComponent(mod.filename)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Mod removed', 'success')
|
||||||
|
fetchMods()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to remove mod', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to remove mod', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Mods"
|
||||||
|
description="Manage server mods (Forge/Fabric)"
|
||||||
|
icon={Package}
|
||||||
|
actions={
|
||||||
|
hasPermission('mods:install') ? (
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<Button icon={Upload} as="span">
|
||||||
|
Upload Mod
|
||||||
|
</Button>
|
||||||
|
<input type="file" accept=".jar" className="hidden" onChange={handleUpload} />
|
||||||
|
</label>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{mods.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<Package size={40} className="mx-auto mb-3 text-gray-600" />
|
||||||
|
<p className="font-medium">No mods installed</p>
|
||||||
|
<p className="text-sm mt-1">Upload a .jar file to install a mod.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-800/50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Mod</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Size</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{mods.map(mod => (
|
||||||
|
<tr key={mod.filename} className="hover:bg-gray-800">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm font-medium text-gray-200">{mod.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">{mod.filename}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-400">{formatFileSize(mod.size)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Badge variant={mod.enabled ? 'success' : 'neutral'}>
|
||||||
|
{mod.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{hasPermission('mods:toggle') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={mod.enabled ? ToggleLeft : ToggleRight}
|
||||||
|
onClick={() => handleToggle(mod)}
|
||||||
|
>
|
||||||
|
{mod.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('mods:remove') && (
|
||||||
|
<Button size="sm" variant="danger" icon={Trash2} onClick={() => handleRemove(mod)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
292
src/app/(app)/servers/[id]/page.tsx
Normal file
292
src/app/(app)/servers/[id]/page.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
import {
|
||||||
|
Server, Play, Square, RotateCcw, Terminal, Settings, HardDrive,
|
||||||
|
Puzzle, Package, Users, Trash2, ArrowLeft, FileText, FolderOpen
|
||||||
|
} from 'lucide-react'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import ServerStatusBadge from '@/components/ServerStatusBadge'
|
||||||
|
import StatsChart from '@/components/StatsChart'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import { supportsPlugins, supportsMods } from '@/types/server'
|
||||||
|
import type { Server as ServerData } from '@/types/server'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const MAX_STATS_POINTS = 60
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerDetailPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const [server, setServer] = useState<ServerData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
|
const [cpuHistory, setCpuHistory] = useState<number[]>([])
|
||||||
|
const [memHistory, setMemHistory] = useState<number[]>([])
|
||||||
|
const [currentStats, setCurrentStats] = useState<{ cpu: number; memUsed: number; memLimit: number; memPercent: number } | null>(null)
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
|
||||||
|
const fetchServer = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setServer(data.data)
|
||||||
|
} else {
|
||||||
|
showToast('Server not found', 'error')
|
||||||
|
router.push('/servers')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to fetch server', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, router, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServer()
|
||||||
|
const interval = setInterval(fetchServer, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchServer])
|
||||||
|
|
||||||
|
// SSE connection for live stats
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server || server.status !== 'online') {
|
||||||
|
// Close any existing connection and clear data when offline
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
setCpuHistory([])
|
||||||
|
setMemHistory([])
|
||||||
|
setCurrentStats(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(`/api/servers/${serverId}/stats`)
|
||||||
|
eventSourceRef.current = es
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
setCurrentStats(data)
|
||||||
|
setCpuHistory(prev => {
|
||||||
|
const next = [...prev, data.cpu]
|
||||||
|
return next.length > MAX_STATS_POINTS ? next.slice(-MAX_STATS_POINTS) : next
|
||||||
|
})
|
||||||
|
setMemHistory(prev => {
|
||||||
|
const next = [...prev, data.memUsed]
|
||||||
|
return next.length > MAX_STATS_POINTS ? next.slice(-MAX_STATS_POINTS) : next
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// skip malformed events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
}, [server?.status, serverId])
|
||||||
|
|
||||||
|
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
|
||||||
|
setActionLoading(action)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Server ${action} initiated`, 'success')
|
||||||
|
fetchServer()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || `Failed to ${action} server`, 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast(`Failed to ${action} server`, 'error')
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!server) return
|
||||||
|
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Delete Server',
|
||||||
|
message: 'Are you sure you want to permanently delete',
|
||||||
|
itemName: server.name,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Delete Server',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Server deleted', 'success')
|
||||||
|
router.push('/servers')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to delete server', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete server', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !server) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ label: 'Console', href: `/servers/${serverId}/console`, icon: Terminal, permission: 'servers:console' },
|
||||||
|
{ label: 'Logs', href: `/servers/${serverId}/logs`, icon: FileText, permission: 'servers:console' },
|
||||||
|
{ label: 'Files', href: `/servers/${serverId}/files`, icon: FolderOpen, permission: 'servers:edit' },
|
||||||
|
{ label: 'Configuration', href: `/servers/${serverId}/configuration`, icon: Settings, permission: 'servers:edit' },
|
||||||
|
{ label: 'Backups', href: `/servers/${serverId}/backups`, icon: HardDrive, permission: 'backups:view' },
|
||||||
|
...(supportsPlugins(server.type) ? [{ label: 'Plugins', href: `/servers/${serverId}/plugins`, icon: Puzzle, permission: 'plugins:view' }] : []),
|
||||||
|
...(supportsMods(server.type) ? [{ label: 'Mods', href: `/servers/${serverId}/mods`, icon: Package, permission: 'mods:view' }] : []),
|
||||||
|
{ label: 'Players', href: `/servers/${serverId}/players`, icon: Users, permission: 'players:view' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back Button */}
|
||||||
|
<Link href="/servers" className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">
|
||||||
|
<ArrowLeft size={16} /> Back to Servers
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<PageHeader
|
||||||
|
title={server.name}
|
||||||
|
description={`${server.type} · ${server.version} · Port ${server.port}`}
|
||||||
|
icon={Server}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ServerStatusBadge status={server.status} size="md" />
|
||||||
|
|
||||||
|
{hasPermission('servers:start') && server.status === 'offline' && (
|
||||||
|
<Button icon={Play} size="sm" onClick={() => handleAction('start')} loading={actionLoading === 'start'}>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('servers:stop') && server.status === 'online' && (
|
||||||
|
<Button icon={Square} size="sm" variant="secondary" onClick={() => handleAction('stop')} loading={actionLoading === 'stop'}>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('servers:restart') && server.status === 'online' && (
|
||||||
|
<Button icon={RotateCcw} size="sm" variant="secondary" onClick={() => handleAction('restart')} loading={actionLoading === 'restart'}>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('servers:delete') && (
|
||||||
|
<Button icon={Trash2} size="sm" variant="danger" onClick={handleDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Server Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<InfoCard label="Type" value={server.type} />
|
||||||
|
<InfoCard label="Version" value={server.version} />
|
||||||
|
<InfoCard label="Memory" value={`${server.memory.min}–${server.memory.max} MB`} />
|
||||||
|
<InfoCard label="Max Players" value={String(server.maxPlayers)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resource Graphs */}
|
||||||
|
{server.status === 'online' && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<StatsChart
|
||||||
|
data={cpuHistory}
|
||||||
|
maxDataPoints={MAX_STATS_POINTS}
|
||||||
|
label="CPU Usage"
|
||||||
|
value={currentStats ? `${currentStats.cpu.toFixed(1)}%` : '—'}
|
||||||
|
color="cyan"
|
||||||
|
maxY={100}
|
||||||
|
unit="%"
|
||||||
|
/>
|
||||||
|
<StatsChart
|
||||||
|
data={memHistory}
|
||||||
|
maxDataPoints={MAX_STATS_POINTS}
|
||||||
|
label="Memory Usage"
|
||||||
|
value={currentStats ? formatBytes(currentStats.memUsed) : '—'}
|
||||||
|
subValue={currentStats ? `/ ${formatBytes(currentStats.memLimit)}` : undefined}
|
||||||
|
color="emerald"
|
||||||
|
maxY={currentStats?.memLimit || server.memory.max * 1024 * 1024}
|
||||||
|
unit=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{navLinks
|
||||||
|
.filter(link => hasPermission(link.permission))
|
||||||
|
.map(link => {
|
||||||
|
const Icon = link.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-5 hover:border-cyan-500/50 hover:bg-gray-800/60 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 rounded-lg bg-gray-800 group-hover:bg-cyan-500/20 transition-colors">
|
||||||
|
<Icon size={20} className="text-gray-400 group-hover:text-cyan-400 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-200">{link.label}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-100 mt-1 capitalize">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
src/app/(app)/servers/[id]/players/page.tsx
Normal file
284
src/app/(app)/servers/[id]/players/page.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Users, UserPlus, UserMinus, Shield, ShieldOff,
|
||||||
|
Ban, CheckCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
|
||||||
|
interface PlayerInfo {
|
||||||
|
name: string
|
||||||
|
uuid?: string
|
||||||
|
isOp: boolean
|
||||||
|
isWhitelisted: boolean
|
||||||
|
isBanned: boolean
|
||||||
|
isOnline: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayersPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const [players, setPlayers] = useState<PlayerInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showAddPlayer, setShowAddPlayer] = useState(false)
|
||||||
|
const [newPlayerName, setNewPlayerName] = useState('')
|
||||||
|
const [addAction, setAddAction] = useState<'whitelist' | 'op'>('whitelist')
|
||||||
|
|
||||||
|
const fetchPlayers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/players`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setPlayers(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load players', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlayers()
|
||||||
|
const interval = setInterval(fetchPlayers, 15000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchPlayers])
|
||||||
|
|
||||||
|
const handlePlayerAction = async (
|
||||||
|
playerName: string,
|
||||||
|
action: 'whitelist' | 'unwhitelist' | 'op' | 'deop' | 'ban' | 'unban'
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/players/${encodeURIComponent(playerName)}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Player ${action}${action.endsWith('e') ? 'd' : 'ed'} successfully`, 'success')
|
||||||
|
fetchPlayers()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || `Failed to ${action} player`, 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast(`Failed to ${action} player`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPlayer = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newPlayerName.trim()) return
|
||||||
|
|
||||||
|
await handlePlayerAction(newPlayerName.trim(), addAction)
|
||||||
|
setNewPlayerName('')
|
||||||
|
setShowAddPlayer(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBan = async (playerName: string) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Ban Player',
|
||||||
|
message: 'Are you sure you want to ban',
|
||||||
|
itemName: playerName,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Ban Player',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
await handlePlayerAction(playerName, 'ban')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlinePlayers = players.filter(p => p.isOnline)
|
||||||
|
const offlinePlayers = players.filter(p => !p.isOnline)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Players"
|
||||||
|
description="Manage whitelist, ops, and bans"
|
||||||
|
icon={Users}
|
||||||
|
actions={
|
||||||
|
<Button icon={UserPlus} onClick={() => setShowAddPlayer(true)}>
|
||||||
|
Add Player
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Online Players */}
|
||||||
|
{onlinePlayers.length > 0 && (
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200 flex items-center gap-2">
|
||||||
|
<div className="w-2 h-2 bg-emerald-400 rounded-full" />
|
||||||
|
Online ({onlinePlayers.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-700">
|
||||||
|
{onlinePlayers.map(player => (
|
||||||
|
<PlayerRow
|
||||||
|
key={player.name}
|
||||||
|
player={player}
|
||||||
|
hasPermission={hasPermission}
|
||||||
|
onAction={handlePlayerAction}
|
||||||
|
onBan={handleBan}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Known Players */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-700/50">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-200">
|
||||||
|
All Players ({players.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{players.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<Users size={40} className="mx-auto mb-3 text-gray-600" />
|
||||||
|
<p className="font-medium">No players found</p>
|
||||||
|
<p className="text-sm mt-1">Add players to the whitelist or start the server to see players.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-700">
|
||||||
|
{[...onlinePlayers, ...offlinePlayers].map(player => (
|
||||||
|
<PlayerRow
|
||||||
|
key={player.name}
|
||||||
|
player={player}
|
||||||
|
hasPermission={hasPermission}
|
||||||
|
onAction={handlePlayerAction}
|
||||||
|
onBan={handleBan}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Player Modal */}
|
||||||
|
<Modal isOpen={showAddPlayer} onClose={() => setShowAddPlayer(false)} title="Add Player">
|
||||||
|
<form onSubmit={handleAddPlayer} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Player Name"
|
||||||
|
value={newPlayerName}
|
||||||
|
onChange={e => setNewPlayerName(e.target.value)}
|
||||||
|
placeholder="Steve"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddAction('whitelist')}
|
||||||
|
className={`flex-1 p-3 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
addAction === 'whitelist'
|
||||||
|
? 'bg-cyan-500/20 border-cyan-500/50 text-cyan-400'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Add to Whitelist
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddAction('op')}
|
||||||
|
className={`flex-1 p-3 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
addAction === 'op'
|
||||||
|
? 'bg-amber-500/20 border-amber-500/50 text-amber-400'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-400 hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Make Operator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={() => setShowAddPlayer(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Add Player</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayerRow({
|
||||||
|
player,
|
||||||
|
hasPermission,
|
||||||
|
onAction,
|
||||||
|
onBan,
|
||||||
|
}: {
|
||||||
|
player: PlayerInfo
|
||||||
|
hasPermission: (p: string) => boolean
|
||||||
|
onAction: (name: string, action: 'whitelist' | 'unwhitelist' | 'op' | 'deop' | 'ban' | 'unban') => void
|
||||||
|
onBan: (name: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${player.isOnline ? 'bg-emerald-400' : 'bg-gray-600'}`} />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-200">{player.name}</span>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
{player.isOp && <Badge variant="warning">OP</Badge>}
|
||||||
|
{player.isWhitelisted && <Badge variant="info">Whitelisted</Badge>}
|
||||||
|
{player.isBanned && <Badge variant="error">Banned</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasPermission('players:whitelist') && (
|
||||||
|
player.isWhitelisted ? (
|
||||||
|
<Button size="sm" variant="ghost" icon={UserMinus} onClick={() => onAction(player.name, 'unwhitelist')} />
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="ghost" icon={UserPlus} onClick={() => onAction(player.name, 'whitelist')} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{hasPermission('players:op') && (
|
||||||
|
player.isOp ? (
|
||||||
|
<Button size="sm" variant="ghost" icon={ShieldOff} onClick={() => onAction(player.name, 'deop')} />
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="ghost" icon={Shield} onClick={() => onAction(player.name, 'op')} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{hasPermission('players:ban') && (
|
||||||
|
player.isBanned ? (
|
||||||
|
<Button size="sm" variant="ghost" icon={CheckCircle} onClick={() => onAction(player.name, 'unban')} />
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="ghost" icon={Ban} onClick={() => onBan(player.name)} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
src/app/(app)/servers/[id]/plugins/page.tsx
Normal file
221
src/app/(app)/servers/[id]/plugins/page.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { ArrowLeft, Puzzle, Upload, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
import { formatFileSize } from '@/lib/date-utils'
|
||||||
|
|
||||||
|
interface PluginInfo {
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
size: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const serverId = params.id as string
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const [plugins, setPlugins] = useState<PluginInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchPlugins = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/plugins`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setPlugins(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to load plugins', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId, showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlugins()
|
||||||
|
}, [fetchPlugins])
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (!file.name.endsWith('.jar')) {
|
||||||
|
showToast('Only .jar files are allowed', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/plugins`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Plugin uploaded successfully', 'success')
|
||||||
|
fetchPlugins()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to upload plugin', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to upload plugin', 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (plugin: PluginInfo) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/plugins/${encodeURIComponent(plugin.filename)}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`Plugin ${plugin.enabled ? 'disabled' : 'enabled'}`, 'success')
|
||||||
|
fetchPlugins()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to toggle plugin', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to toggle plugin', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = async (plugin: PluginInfo) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Remove Plugin',
|
||||||
|
message: 'Are you sure you want to permanently remove',
|
||||||
|
itemName: plugin.name,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Remove',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/plugins/${encodeURIComponent(plugin.filename)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Plugin removed', 'success')
|
||||||
|
fetchPlugins()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to remove plugin', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to remove plugin', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href={`/servers/${serverId}`}
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Server
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title="Plugins"
|
||||||
|
description="Manage server plugins (Bukkit/Spigot/Paper)"
|
||||||
|
icon={Puzzle}
|
||||||
|
actions={
|
||||||
|
hasPermission('plugins:install') ? (
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
<Button icon={Upload} as="span">
|
||||||
|
Upload Plugin
|
||||||
|
</Button>
|
||||||
|
<input type="file" accept=".jar" className="hidden" onChange={handleUpload} />
|
||||||
|
</label>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<Puzzle size={40} className="mx-auto mb-3 text-gray-600" />
|
||||||
|
<p className="font-medium">No plugins installed</p>
|
||||||
|
<p className="text-sm mt-1">Upload a .jar file to install a plugin.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-800/50">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Plugin</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Size</th>
|
||||||
|
<th className="text-left text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Status</th>
|
||||||
|
<th className="text-right text-xs font-medium text-gray-400 uppercase tracking-wider px-6 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{plugins.map(plugin => (
|
||||||
|
<tr key={plugin.filename} className="hover:bg-gray-800">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<p className="text-sm font-medium text-gray-200">{plugin.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">{plugin.filename}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-400">{formatFileSize(plugin.size)}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Badge variant={plugin.enabled ? 'success' : 'neutral'}>
|
||||||
|
{plugin.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{hasPermission('plugins:toggle') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={plugin.enabled ? ToggleLeft : ToggleRight}
|
||||||
|
onClick={() => handleToggle(plugin)}
|
||||||
|
>
|
||||||
|
{plugin.enabled ? 'Disable' : 'Enable'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('plugins:remove') && (
|
||||||
|
<Button size="sm" variant="danger" icon={Trash2} onClick={() => handleRemove(plugin)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
src/app/(app)/servers/page.tsx
Normal file
171
src/app/(app)/servers/page.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { Server } from 'lucide-react'
|
||||||
|
import DataManagementTemplate, { Column } from '@/components/templates/DataManagementTemplate'
|
||||||
|
import ServerStatusBadge from '@/components/ServerStatusBadge'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
|
import type { Server as ServerData, ServerFormData } from '@/types/server'
|
||||||
|
|
||||||
|
const serverTypeOptions = [
|
||||||
|
{ label: 'Vanilla', value: 'vanilla' },
|
||||||
|
{ label: 'Bukkit (Paper/Spigot)', value: 'bukkit' },
|
||||||
|
{ label: 'Forge', value: 'forge' },
|
||||||
|
{ label: 'Fabric', value: 'fabric' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ServersPage() {
|
||||||
|
const [servers, setServers] = useState<ServerData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const fetchServers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/servers', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setServers(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to fetch servers', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchServers()
|
||||||
|
}, [fetchServers])
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreating(true)
|
||||||
|
|
||||||
|
const form = new FormData(e.currentTarget)
|
||||||
|
const body: ServerFormData = {
|
||||||
|
name: form.get('name') as string,
|
||||||
|
type: form.get('type') as ServerData['type'],
|
||||||
|
version: form.get('version') as string,
|
||||||
|
port: Number(form.get('port')),
|
||||||
|
maxPlayers: Number(form.get('maxPlayers')) || 20,
|
||||||
|
memory: {
|
||||||
|
min: Number(form.get('memoryMin')) || 512,
|
||||||
|
max: Number(form.get('memoryMax')) || 1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/servers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Server created successfully', 'success')
|
||||||
|
setShowCreate(false)
|
||||||
|
fetchServers()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to create server', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to create server', 'error')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Column<ServerData>[] = [
|
||||||
|
{ key: 'name', label: 'Name', sortable: true },
|
||||||
|
{ key: 'type', label: 'Type', sortable: true, render: (s) => <span className="capitalize">{s.type}</span> },
|
||||||
|
{ key: 'version', label: 'Version', sortable: true },
|
||||||
|
{ key: 'port', label: 'Port' },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
sortable: true,
|
||||||
|
render: (s) => <ServerStatusBadge status={s.status} />,
|
||||||
|
},
|
||||||
|
{ key: 'maxPlayers', label: 'Max Players' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataManagementTemplate<ServerData>
|
||||||
|
title="Servers"
|
||||||
|
description="Manage your Minecraft server instances"
|
||||||
|
icon={Server}
|
||||||
|
items={servers}
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(s) => s._id}
|
||||||
|
searchPlaceholder="Search servers..."
|
||||||
|
searchFields={['name', 'type', 'version']}
|
||||||
|
filters={[
|
||||||
|
{
|
||||||
|
label: 'All Types',
|
||||||
|
filterKey: 'type',
|
||||||
|
options: serverTypeOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'All Statuses',
|
||||||
|
filterKey: 'status',
|
||||||
|
options: [
|
||||||
|
{ label: 'Online', value: 'online' },
|
||||||
|
{ label: 'Offline', value: 'offline' },
|
||||||
|
{ label: 'Starting', value: 'starting' },
|
||||||
|
{ label: 'Stopping', value: 'stopping' },
|
||||||
|
{ label: 'Crashed', value: 'crashed' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onRowClick={(s) => router.push(`/servers/${s._id}`)}
|
||||||
|
onAdd={() => setShowCreate(true)}
|
||||||
|
addLabel="Create Server"
|
||||||
|
canAdd={hasPermission('servers:create')}
|
||||||
|
emptyIcon={Server}
|
||||||
|
emptyTitle="No servers yet"
|
||||||
|
emptyDescription="Create your first Minecraft server to get started."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Server Modal */}
|
||||||
|
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Create Server" maxWidth="max-w-xl">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<Input name="name" label="Server Name" placeholder="My Minecraft Server" required />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select name="type" label="Server Type" options={serverTypeOptions} required />
|
||||||
|
<Input name="version" label="MC Version" placeholder="1.21.4" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Input name="port" label="Port" type="number" placeholder="25565" required />
|
||||||
|
<Input name="memoryMin" label="Min RAM (MB)" type="number" placeholder="512" />
|
||||||
|
<Input name="memoryMax" label="Max RAM (MB)" type="number" placeholder="1024" />
|
||||||
|
</div>
|
||||||
|
<Input name="maxPlayers" label="Max Players" type="number" placeholder="20" />
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={creating}>
|
||||||
|
Create Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
280
src/app/(app)/users/page.tsx
Normal file
280
src/app/(app)/users/page.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { useToast } from '@/contexts/ToastContext'
|
||||||
|
import { useConfirmation } from '@/contexts/ConfirmationContext'
|
||||||
|
import { Users, Edit, Trash2 } from 'lucide-react'
|
||||||
|
import DataManagementTemplate, { Column } from '@/components/templates/DataManagementTemplate'
|
||||||
|
import Drawer from '@/components/Drawer'
|
||||||
|
import Modal from '@/components/Modal'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
import Select from '@/components/ui/Select'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
import { formatDateTime } from '@/lib/date-utils'
|
||||||
|
import type { User } from '@/types/user'
|
||||||
|
import type { Role } from '@/types/user'
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showEdit, setShowEdit] = useState(false)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const { showConfirmation } = useConfirmation()
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [usersRes, rolesRes] = await Promise.all([
|
||||||
|
fetch('/api/users', { credentials: 'include' }),
|
||||||
|
fetch('/api/roles', { credentials: 'include' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (usersRes.ok) {
|
||||||
|
const data = await usersRes.json()
|
||||||
|
setUsers(data.data || [])
|
||||||
|
}
|
||||||
|
if (rolesRes.ok) {
|
||||||
|
const data = await rolesRes.json()
|
||||||
|
setRoles(data.data || [])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to fetch data', 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
const form = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: form.get('username'),
|
||||||
|
email: form.get('email'),
|
||||||
|
password: form.get('password'),
|
||||||
|
roles: [form.get('role')].filter(Boolean),
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('User created successfully', 'success')
|
||||||
|
setShowCreate(false)
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to create user', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to create user', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selectedUser) return
|
||||||
|
setSaving(true)
|
||||||
|
const form = new FormData(e.currentTarget)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
username: form.get('username'),
|
||||||
|
email: form.get('email'),
|
||||||
|
roles: [form.get('role')].filter(Boolean),
|
||||||
|
status: form.get('status'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = form.get('password') as string
|
||||||
|
if (password) body.password = password
|
||||||
|
|
||||||
|
const res = await fetch(`/api/users/${selectedUser._id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('User updated successfully', 'success')
|
||||||
|
setShowEdit(false)
|
||||||
|
setSelectedUser(null)
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to update user', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to update user', 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (user: User) => {
|
||||||
|
const confirmed = await showConfirmation({
|
||||||
|
title: 'Delete User',
|
||||||
|
message: 'Are you sure you want to permanently delete',
|
||||||
|
itemName: user.username,
|
||||||
|
type: 'danger',
|
||||||
|
confirmText: 'Delete User',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/${user._id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('User deleted', 'success')
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
showToast(data.error || 'Failed to delete user', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to delete user', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Inactive', value: 'inactive' },
|
||||||
|
{ label: 'Locked', value: 'locked' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns: Column<User>[] = [
|
||||||
|
{ key: 'username', label: 'Username', sortable: true },
|
||||||
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
sortable: true,
|
||||||
|
render: (u) => (
|
||||||
|
<Badge variant={u.status === 'active' ? 'success' : u.status === 'locked' ? 'error' : 'neutral'}>
|
||||||
|
{u.status}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastLogin',
|
||||||
|
label: 'Last Login',
|
||||||
|
sortable: true,
|
||||||
|
render: (u) => <span className="text-gray-400">{u.lastLogin ? formatDateTime(u.lastLogin) : 'Never'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_id',
|
||||||
|
label: 'Actions',
|
||||||
|
render: (u) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasPermission('users:edit') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={Edit}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedUser(u)
|
||||||
|
setShowEdit(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasPermission('users:delete') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(u)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const roleOptions = roles.map(r => ({ label: r.name, value: r._id }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataManagementTemplate<User>
|
||||||
|
title="Users"
|
||||||
|
description="Manage user accounts and permissions"
|
||||||
|
icon={Users}
|
||||||
|
items={users}
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
getRowKey={(u) => u._id}
|
||||||
|
searchPlaceholder="Search users..."
|
||||||
|
searchFields={['username', 'email']}
|
||||||
|
filters={[
|
||||||
|
{ label: 'All Statuses', filterKey: 'status', options: statusOptions },
|
||||||
|
]}
|
||||||
|
onRowClick={(u) => {
|
||||||
|
setSelectedUser(u)
|
||||||
|
setShowEdit(true)
|
||||||
|
}}
|
||||||
|
onAdd={() => setShowCreate(true)}
|
||||||
|
addLabel="Create User"
|
||||||
|
canAdd={hasPermission('users:create')}
|
||||||
|
emptyIcon={Users}
|
||||||
|
emptyTitle="No users yet"
|
||||||
|
emptyDescription="Create the first user account."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Create User" maxWidth="max-w-lg">
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<Input name="username" label="Username" placeholder="johndoe" required />
|
||||||
|
<Input name="email" label="Email" type="email" placeholder="john@example.com" required />
|
||||||
|
<Input name="password" label="Password" type="password" placeholder="Minimum 8 characters" required />
|
||||||
|
<Select name="role" label="Role" options={roleOptions} placeholder="Select a role" />
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" loading={saving}>Create User</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit User Drawer */}
|
||||||
|
<Drawer isOpen={showEdit} onClose={() => { setShowEdit(false); setSelectedUser(null) }} title={`Edit User — ${selectedUser?.username}`}>
|
||||||
|
{selectedUser && (
|
||||||
|
<form onSubmit={handleUpdate} className="space-y-4">
|
||||||
|
<Input name="username" label="Username" defaultValue={selectedUser.username} required />
|
||||||
|
<Input name="email" label="Email" type="email" defaultValue={selectedUser.email} required />
|
||||||
|
<Input name="password" label="New Password" type="password" placeholder="Leave blank to keep current" />
|
||||||
|
<Select name="role" label="Role" options={roleOptions} defaultValue={(selectedUser.roles as string[])?.[0] || ''} placeholder="Select a role" />
|
||||||
|
<Select name="status" label="Status" options={statusOptions} defaultValue={selectedUser.status} />
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<Button variant="secondary" type="button" onClick={() => { setShowEdit(false); setSelectedUser(null) }}>Cancel</Button>
|
||||||
|
<Button type="submit" loading={saving}>Save Changes</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/app/api/audit/route.ts
Normal file
61
src/app/api/audit/route.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { AuditLog } from '@/lib/models'
|
||||||
|
|
||||||
|
// GET /api/audit — List audit logs (paginated)
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'audit:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const page = Math.max(1, Number(searchParams.get('page')) || 1)
|
||||||
|
const limit = Math.min(100, Math.max(1, Number(searchParams.get('limit')) || 50))
|
||||||
|
const search = searchParams.get('search') || ''
|
||||||
|
const entityType = searchParams.get('entityType') || ''
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const filter: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
filter.$or = [
|
||||||
|
{ action: { $regex: search, $options: 'i' } },
|
||||||
|
{ entityName: { $regex: search, $options: 'i' } },
|
||||||
|
{ userName: { $regex: search, $options: 'i' } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType) {
|
||||||
|
filter.entityType = entityType
|
||||||
|
}
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
AuditLog.find(filter)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.limit(limit)
|
||||||
|
.lean(),
|
||||||
|
AuditLog.countDocuments(filter),
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: logs,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch audit logs error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/app/api/auth/login/route.ts
Normal file
123
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { User } from '@/lib/models'
|
||||||
|
import { verifyPassword, generateAccessToken, generate2FACode, hash2FACode } from '@/lib/auth'
|
||||||
|
import { sanitizeObject } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { send2FAEmail } from '@/lib/email'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await request.json()
|
||||||
|
const { username, password } = sanitizeObject(raw)
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
$or: [{ username }, { email: username }],
|
||||||
|
}).select('+passwordHash +loginAttempts +lockUntil +twoFactorCode +twoFactorExpiry')
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'login_failed',
|
||||||
|
entityType: 'user',
|
||||||
|
entityName: username,
|
||||||
|
clientIP,
|
||||||
|
status: 'failure',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check account lockout
|
||||||
|
if (user.lockUntil && user.lockUntil > new Date()) {
|
||||||
|
const minutesLeft = Math.ceil((user.lockUntil.getTime() - Date.now()) / 60000)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Account locked. Try again in ${minutesLeft} minutes.` },
|
||||||
|
{ status: 423 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
return NextResponse.json({ error: 'Account is not active' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const valid = await verifyPassword(password, user.passwordHash)
|
||||||
|
if (!valid) {
|
||||||
|
user.loginAttempts = (user.loginAttempts || 0) + 1
|
||||||
|
|
||||||
|
if (user.loginAttempts >= 5) {
|
||||||
|
user.lockUntil = new Date(Date.now() + 30 * 60 * 1000) // 30 min lock
|
||||||
|
user.loginAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'login_failed',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user._id.toString(),
|
||||||
|
entityName: user.username,
|
||||||
|
userId: user._id.toString(),
|
||||||
|
userName: user.username,
|
||||||
|
userEmail: user.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'failure',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate and send 2FA code
|
||||||
|
const code = generate2FACode()
|
||||||
|
const hashedCode = hash2FACode(code)
|
||||||
|
|
||||||
|
user.twoFactorCode = hashedCode
|
||||||
|
user.twoFactorExpiry = new Date(Date.now() + 10 * 60 * 1000) // 10 min expiry
|
||||||
|
user.loginAttempts = 0
|
||||||
|
user.lockUntil = null
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
// Send 2FA email
|
||||||
|
try {
|
||||||
|
await send2FAEmail(user.email, code, user.username)
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send 2FA email:', emailError)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to send verification email. Please try again.' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a temporary token for the 2FA step
|
||||||
|
const tempToken = generateAccessToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
requiresTwoFactor: true,
|
||||||
|
message: 'Verification code sent to your email',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set a temporary cookie for the 2FA step
|
||||||
|
response.cookies.set('pending_2fa', tempToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600, // 10 minutes
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/api/auth/logout/route.ts
Normal file
8
src/app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { clearAuthCookies } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ success: true })
|
||||||
|
clearAuthCookies(response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
25
src/app/api/auth/me/route.ts
Normal file
25
src/app/api/auth/me/route.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
_id: session._id,
|
||||||
|
username: session.username,
|
||||||
|
email: session.email,
|
||||||
|
permissions: session.permissions,
|
||||||
|
roles: session.roles,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Session check error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/auth/refresh/route.ts
Normal file
46
src/app/api/auth/refresh/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { User } from '@/lib/models'
|
||||||
|
import { verifyRefreshToken, generateAccessToken, generateRefreshToken, setAuthCookies } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const refreshToken = request.cookies.get('refresh_token')?.value
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: { userId: string }
|
||||||
|
try {
|
||||||
|
payload = verifyRefreshToken(refreshToken) as { userId: string }
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid refresh token' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const user = await User.findById(payload.userId)
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
return NextResponse.json({ error: 'User not found or inactive' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAccessToken = generateAccessToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||||
|
const newRefreshToken = generateRefreshToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
_id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setAuthCookies(response, newAccessToken, newRefreshToken)
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/api/auth/verify-2fa/route.ts
Normal file
110
src/app/api/auth/verify-2fa/route.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { User } from '@/lib/models'
|
||||||
|
import {
|
||||||
|
verifyAccessToken, verify2FACode, generateAccessToken,
|
||||||
|
generateRefreshToken, setAuthCookies
|
||||||
|
} from '@/lib/auth'
|
||||||
|
import { sanitizeObject } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await request.json()
|
||||||
|
const { code } = sanitizeObject(raw)
|
||||||
|
|
||||||
|
if (!code || code.length !== 6) {
|
||||||
|
return NextResponse.json({ error: 'A valid 6-digit code is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify pending 2FA token
|
||||||
|
const pendingToken = request.cookies.get('pending_2fa')?.value
|
||||||
|
if (!pendingToken) {
|
||||||
|
return NextResponse.json({ error: 'No pending verification. Please log in again.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: { userId: string }
|
||||||
|
try {
|
||||||
|
payload = verifyAccessToken(pendingToken) as { userId: string }
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Verification expired. Please log in again.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const user = await User.findById(payload.userId)
|
||||||
|
.select('+twoFactorCode +twoFactorExpiry')
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2FA expiry
|
||||||
|
if (!user.twoFactorExpiry || user.twoFactorExpiry < new Date()) {
|
||||||
|
return NextResponse.json({ error: 'Verification code expired. Please log in again.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify code
|
||||||
|
if (!user.twoFactorCode || !verify2FACode(code, user.twoFactorCode)) {
|
||||||
|
await createAuditLog({
|
||||||
|
action: '2fa_failed',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user._id.toString(),
|
||||||
|
entityName: user.username,
|
||||||
|
userId: user._id.toString(),
|
||||||
|
userName: user.username,
|
||||||
|
userEmail: user.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'failure',
|
||||||
|
statusCode: 401,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid verification code' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear 2FA code
|
||||||
|
user.twoFactorCode = null
|
||||||
|
user.twoFactorExpiry = null
|
||||||
|
user.lastLogin = new Date()
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
// Generate session tokens
|
||||||
|
const accessToken = generateAccessToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||||
|
const refreshToken = generateRefreshToken({ userId: user._id.toString(), username: user.username, email: user.email })
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'login_success',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user._id.toString(),
|
||||||
|
entityName: user.username,
|
||||||
|
userId: user._id.toString(),
|
||||||
|
userName: user.username,
|
||||||
|
userEmail: user.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
_id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setAuthCookies(response, accessToken, refreshToken)
|
||||||
|
|
||||||
|
// Clear pending token
|
||||||
|
response.cookies.delete('pending_2fa')
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('2FA verify error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/api/health/route.ts
Normal file
19
src/app/api/health/route.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
|
||||||
|
// GET /api/health — Public health check endpoint
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await connectToDatabase()
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status: 'error', message: 'Database connection failed' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/app/api/roles/[id]/route.ts
Normal file
161
src/app/api/roles/[id]/route.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Role, User } from '@/lib/models'
|
||||||
|
import { sanitizeObject, isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// GET /api/roles/[id]
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'roles:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid role ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const role = await Role.findById(id).lean()
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return NextResponse.json({ error: 'Role not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: role })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch role error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/roles/[id]
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'roles:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid role ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const body = sanitizeObject(raw)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const role = await Role.findById(id)
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return NextResponse.json({ error: 'Role not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousValues = role.toObject()
|
||||||
|
|
||||||
|
if (body.name) role.name = body.name
|
||||||
|
if (body.description !== undefined) role.description = body.description
|
||||||
|
if (body.permissions) role.permissions = body.permissions
|
||||||
|
|
||||||
|
await role.save()
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'role_updated',
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: role._id.toString(),
|
||||||
|
entityName: role.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues,
|
||||||
|
newValues: body,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: role })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update role error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/roles/[id]
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'roles:delete')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid role ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const role = await Role.findById(id)
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return NextResponse.json({ error: 'Role not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isDefault) {
|
||||||
|
return NextResponse.json({ error: 'Cannot delete the default role' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove role from all users that have it
|
||||||
|
await User.updateMany({ roles: id }, { $pull: { roles: id } })
|
||||||
|
|
||||||
|
await Role.findByIdAndDelete(id)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'role_deleted',
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: role._id.toString(),
|
||||||
|
entityName: role.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: role.toObject(),
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete role error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/api/roles/route.ts
Normal file
86
src/app/api/roles/route.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Role } from '@/lib/models'
|
||||||
|
import { sanitizeObject } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// GET /api/roles — List all roles
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'roles:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const roles = await Role.find().sort({ name: 1 }).lean()
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: roles })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch roles error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/roles — Create a new role
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'roles:create')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const { name, description, permissions } = sanitizeObject(raw)
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'Role name is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const existing = await Role.findOne({ name })
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: 'Role name already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = new Role({
|
||||||
|
name,
|
||||||
|
description: description || '',
|
||||||
|
permissions: permissions || [],
|
||||||
|
isDefault: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await role.save()
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'role_created',
|
||||||
|
entityType: 'role',
|
||||||
|
entityId: role._id.toString(),
|
||||||
|
entityName: role.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
newValues: { name, description, permissions },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: role }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create role error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/app/api/servers/[id]/backups/[backupId]/restore/route.ts
Normal file
82
src/app/api/servers/[id]/backups/[backupId]/restore/route.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Backup, Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName, getServerPath } from '@/lib/docker'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/backups/[backupId]/restore — Restore a backup
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; backupId: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'backups:restore')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, backupId } = await params
|
||||||
|
if (!isValidObjectId(id) || !isValidObjectId(backupId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = await Backup.findOne({ _id: backupId, serverId: id })
|
||||||
|
if (!backup || backup.status !== 'completed') {
|
||||||
|
return NextResponse.json({ error: 'Backup not found or incomplete' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDir = getServerPath(server._id.toString())
|
||||||
|
|
||||||
|
// Stop server if running
|
||||||
|
if (server.status === 'online') {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
await container.stop({ t: 30 })
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'offline' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace world with backup
|
||||||
|
try {
|
||||||
|
execSync(`rm -rf "${path.join(serverDir, 'world')}"`, { timeout: 60000 })
|
||||||
|
execSync(`tar -xzf "${backup.filePath}" -C "${serverDir}"`, { timeout: 300000 })
|
||||||
|
} catch (restoreError) {
|
||||||
|
console.error('Restore failed:', restoreError)
|
||||||
|
return NextResponse.json({ error: 'Failed to restore backup' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'backup_restored',
|
||||||
|
entityType: 'backup',
|
||||||
|
entityId: backup._id.toString(),
|
||||||
|
entityName: backup.filename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Backup restored. Start the server to use it.' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Restore backup error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/api/servers/[id]/backups/[backupId]/route.ts
Normal file
63
src/app/api/servers/[id]/backups/[backupId]/route.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Backup } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { unlink } from 'fs/promises'
|
||||||
|
|
||||||
|
// DELETE /api/servers/[id]/backups/[backupId] — Delete a backup
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; backupId: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'backups:delete')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, backupId } = await params
|
||||||
|
if (!isValidObjectId(id) || !isValidObjectId(backupId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const backup = await Backup.findOne({ _id: backupId, serverId: id })
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json({ error: 'Backup not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(backup.filePath)
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
|
||||||
|
await Backup.findByIdAndDelete(backupId)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'backup_deleted',
|
||||||
|
entityType: 'backup',
|
||||||
|
entityId: backup._id.toString(),
|
||||||
|
entityName: backup.filename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete backup error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/app/api/servers/[id]/backups/route.ts
Normal file
153
src/app/api/servers/[id]/backups/route.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server, Backup } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName, getServerPath } from '@/lib/docker'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import path from 'path'
|
||||||
|
import { stat } from 'fs/promises'
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/backups — List backups
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'backups:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const backups = await Backup.find({ serverId: id }).sort({ createdAt: -1 }).lean()
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: backups })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch backups error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/backups — Create a manual backup
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'backups:create')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDir = getServerPath(server._id.toString())
|
||||||
|
const backupsDir = path.join(serverDir, 'backups')
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const filename = `backup-${timestamp}.tar.gz`
|
||||||
|
const filePath = path.join(backupsDir, filename)
|
||||||
|
|
||||||
|
// Create backup record
|
||||||
|
const backup = new Backup({
|
||||||
|
serverId: server._id,
|
||||||
|
filename,
|
||||||
|
filePath,
|
||||||
|
fileSize: 0,
|
||||||
|
type: 'manual',
|
||||||
|
status: 'in_progress',
|
||||||
|
createdBy: session._id,
|
||||||
|
})
|
||||||
|
await backup.save()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If server is online, pause saving
|
||||||
|
if (server.status === 'online') {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
const saveOff = await container.exec({ Cmd: ['rcon-cli', 'save-off'], AttachStdout: true })
|
||||||
|
await saveOff.start({ Detach: false })
|
||||||
|
const saveAll = await container.exec({ Cmd: ['rcon-cli', 'save-all', 'flush'], AttachStdout: true })
|
||||||
|
await saveAll.start({ Detach: false })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000)) // Wait for save
|
||||||
|
} catch {
|
||||||
|
// RCON may not be available — continue anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup directory if needed
|
||||||
|
execSync(`mkdir -p "${backupsDir}"`)
|
||||||
|
|
||||||
|
// Create tar.gz of world directory
|
||||||
|
execSync(`tar -czf "${filePath}" -C "${serverDir}" world`, { timeout: 300000 })
|
||||||
|
|
||||||
|
// Resume saving if online
|
||||||
|
if (server.status === 'online') {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
const saveOn = await container.exec({ Cmd: ['rcon-cli', 'save-on'], AttachStdout: true })
|
||||||
|
await saveOn.start({ Detach: false })
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
const stats = await stat(filePath)
|
||||||
|
backup.fileSize = stats.size
|
||||||
|
backup.status = 'completed'
|
||||||
|
await backup.save()
|
||||||
|
} catch (backupError) {
|
||||||
|
console.error('Backup creation failed:', backupError)
|
||||||
|
backup.status = 'failed'
|
||||||
|
await backup.save()
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Backup creation failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'backup_created',
|
||||||
|
entityType: 'backup',
|
||||||
|
entityId: backup._id.toString(),
|
||||||
|
entityName: filename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: backup }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create backup error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/api/servers/[id]/configuration/route.ts
Normal file
134
src/app/api/servers/[id]/configuration/route.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { sanitizeObject, isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { readFile, writeFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/configuration — Read server.properties + JVM args
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDir = getServerPath(server._id.toString())
|
||||||
|
const propsPath = path.join(serverDir, 'server.properties')
|
||||||
|
|
||||||
|
const properties: Record<string, string> = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await readFile(propsPath, 'utf-8')
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
const eqIndex = trimmed.indexOf('=')
|
||||||
|
if (eqIndex > 0) {
|
||||||
|
properties[trimmed.substring(0, eqIndex)] = trimmed.substring(eqIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist yet — that's OK
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
properties,
|
||||||
|
jvmArgs: server.jvmArgs || [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch config error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/servers/[id]/configuration — Save server.properties + JVM args
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const { properties, jvmArgs } = sanitizeObject(raw)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDir = getServerPath(server._id.toString())
|
||||||
|
const propsPath = path.join(serverDir, 'server.properties')
|
||||||
|
|
||||||
|
// Write server.properties
|
||||||
|
if (properties && typeof properties === 'object') {
|
||||||
|
const lines = Object.entries(properties).map(([k, v]) => `${k}=${v}`)
|
||||||
|
await writeFile(propsPath, lines.join('\n') + '\n', 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update JVM args
|
||||||
|
if (Array.isArray(jvmArgs)) {
|
||||||
|
server.jvmArgs = jvmArgs
|
||||||
|
await server.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_config_updated',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save config error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/app/api/servers/[id]/console/route.ts
Normal file
148
src/app/api/servers/[id]/console/route.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/console — SSE log stream
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:console')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (!container) {
|
||||||
|
return NextResponse.json({ error: 'Container not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logStream = await container.logs({
|
||||||
|
follow: true,
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
tail: 100,
|
||||||
|
timestamps: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
logStream.on('data', (chunk: Buffer) => {
|
||||||
|
// Docker log lines have 8-byte header, strip it
|
||||||
|
const line = chunk.slice(8).toString('utf-8').trim()
|
||||||
|
if (line) {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ line })}\n\n`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logStream.on('end', () => {
|
||||||
|
controller.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
logStream.on('error', () => {
|
||||||
|
controller.close()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
(logStream as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Console stream error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/console — Send command
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:console')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { command } = await request.json()
|
||||||
|
if (!command || typeof command !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Command is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status !== 'online') {
|
||||||
|
return NextResponse.json({ error: 'Server is not running' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (!container) {
|
||||||
|
return NextResponse.json({ error: 'Container not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send command to server stdin via mc-send-to-console
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: ['mc-send-to-console', command.trim()],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
User: '1000',
|
||||||
|
})
|
||||||
|
|
||||||
|
const execStream = await exec.start({ Detach: false })
|
||||||
|
|
||||||
|
// Wait for exec to finish
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
execStream.on('end', resolve)
|
||||||
|
execStream.on('error', () => resolve())
|
||||||
|
setTimeout(resolve, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Output will appear in server stdout → SSE stream picks it up
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Console command error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
669
src/app/api/servers/[id]/files/route.ts
Normal file
669
src/app/api/servers/[id]/files/route.ts
Normal file
@ -0,0 +1,669 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { readdir, stat, readFile, writeFile, rm, cp, mkdir, rename } from 'fs/promises'
|
||||||
|
import { join, resolve, relative, extname, dirname, basename } from 'path'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
'.txt', '.log', '.properties', '.json', '.yml', '.yaml', '.toml', '.cfg',
|
||||||
|
'.conf', '.ini', '.xml', '.html', '.css', '.js', '.ts', '.md', '.csv',
|
||||||
|
'.sh', '.bat', '.cmd', '.env', '.mcmeta', '.lang', '.nbt',
|
||||||
|
])
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB read limit
|
||||||
|
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024 // 500 MB upload limit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a path and validates it stays within the server root.
|
||||||
|
* Returns { serverRoot, targetPath, relativePath } or null if invalid.
|
||||||
|
*/
|
||||||
|
function resolveSafePath(serverId: string, requestedPath: string) {
|
||||||
|
const serverRoot = resolve(getServerPath(serverId))
|
||||||
|
const targetPath = resolve(serverRoot, requestedPath.replace(/^\/+/, ''))
|
||||||
|
const relativePath = relative(serverRoot, targetPath)
|
||||||
|
|
||||||
|
if (relativePath.startsWith('..') || resolve(serverRoot, relativePath) !== targetPath) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverRoot, targetPath, relativePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/files — Browse directory or read file
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPath = request.nextUrl.searchParams.get('path') || '/'
|
||||||
|
const readContent = request.nextUrl.searchParams.get('read') === 'true'
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), requestedPath)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied — path outside server directory' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { targetPath, relativePath } = safe
|
||||||
|
|
||||||
|
const targetStat = await stat(targetPath).catch(() => null)
|
||||||
|
if (!targetStat) {
|
||||||
|
return NextResponse.json({ error: 'Path not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file contents
|
||||||
|
if (readContent && targetStat.isFile()) {
|
||||||
|
if (targetStat.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large to view (max 5 MB)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(targetPath).toLowerCase()
|
||||||
|
const isText = TEXT_EXTENSIONS.has(ext) || ext === ''
|
||||||
|
|
||||||
|
if (!isText) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
type: 'binary',
|
||||||
|
name: relativePath.split('/').pop(),
|
||||||
|
size: targetStat.size,
|
||||||
|
message: 'Binary file — cannot be displayed',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readFile(targetPath, 'utf-8')
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
type: 'text',
|
||||||
|
name: relativePath.split('/').pop(),
|
||||||
|
content,
|
||||||
|
size: targetStat.size,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List directory
|
||||||
|
if (targetStat.isDirectory()) {
|
||||||
|
const entries = await readdir(targetPath)
|
||||||
|
const items = await Promise.all(
|
||||||
|
entries.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const entryStat = await stat(join(targetPath, name))
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: relativePath ? `${relativePath}/${name}` : name,
|
||||||
|
isDirectory: entryStat.isDirectory(),
|
||||||
|
size: entryStat.isFile() ? entryStat.size : null,
|
||||||
|
modifiedAt: entryStat.mtime.toISOString(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const validItems = items
|
||||||
|
.filter(Boolean)
|
||||||
|
// Directories first, then alphabetical
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a!.isDirectory !== b!.isDirectory) return a!.isDirectory ? -1 : 1
|
||||||
|
return a!.name.localeCompare(b!.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: relativePath || '/',
|
||||||
|
items: validItems,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single file stat (no read)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
type: 'file',
|
||||||
|
name: relativePath.split('/').pop(),
|
||||||
|
path: relativePath,
|
||||||
|
size: targetStat.size,
|
||||||
|
modifiedAt: targetStat.mtime.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File browser error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PUT — Edit file contents ─────────────────────────────────────
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { path: filePath, content } = body
|
||||||
|
|
||||||
|
if (!filePath || typeof content !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Path and content are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), filePath)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||||
|
if (!targetStat || !targetStat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(safe.targetPath, content, 'utf-8')
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'File saved' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File edit error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE — Delete file or folder ───────────────────────────────
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path: filePath } = await request.json()
|
||||||
|
if (!filePath) {
|
||||||
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), filePath)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the root directory
|
||||||
|
if (safe.relativePath === '' || safe.relativePath === '.') {
|
||||||
|
return NextResponse.json({ error: 'Cannot delete the server root directory' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||||
|
if (!targetStat) {
|
||||||
|
return NextResponse.json({ error: 'Path not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(safe.targetPath, { recursive: true, force: true })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Deleted successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File delete error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST — Upload, copy, zip, unzip, create folder ──────────────
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverRoot = resolve(getServerPath(server._id.toString()))
|
||||||
|
const contentType = request.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
// ── Upload (multipart/form-data) ──────────────────────────────
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const targetDir = (formData.get('path') as string) || '/'
|
||||||
|
const files = formData.getAll('files') as File[]
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return NextResponse.json({ error: 'No files provided' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), targetDir)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(safe.targetPath, { recursive: true })
|
||||||
|
|
||||||
|
const results: string[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > MAX_UPLOAD_SIZE) {
|
||||||
|
results.push(`${file.name}: too large (max 500 MB)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const destPath = join(safe.targetPath, file.name)
|
||||||
|
if (!destPath.startsWith(serverRoot)) {
|
||||||
|
results.push(`${file.name}: invalid path`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
await writeFile(destPath, buffer)
|
||||||
|
results.push(`${file.name}: uploaded`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: results }, { status: 201 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON actions (copy, zip, unzip, create-folder) ────────────
|
||||||
|
const body = await request.json()
|
||||||
|
const { action } = body
|
||||||
|
|
||||||
|
// ── Copy ──────────────────────────────────────────────────────
|
||||||
|
if (action === 'copy') {
|
||||||
|
const { source, destination } = body
|
||||||
|
if (!source || !destination) {
|
||||||
|
return NextResponse.json({ error: 'Source and destination are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeSrc = resolveSafePath(server._id.toString(), source)
|
||||||
|
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||||
|
if (!safeSrc || !safeDest) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||||
|
if (!srcStat) {
|
||||||
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(safeDest.targetPath)) {
|
||||||
|
return NextResponse.json({ error: 'Destination already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await cp(safeSrc.targetPath, safeDest.targetPath, { recursive: true })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Copied successfully' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Move ──────────────────────────────────────────────────────
|
||||||
|
if (action === 'move') {
|
||||||
|
const { source, destination } = body
|
||||||
|
if (!source || !destination) {
|
||||||
|
return NextResponse.json({ error: 'Source and destination are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeSrc = resolveSafePath(server._id.toString(), source)
|
||||||
|
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||||
|
if (!safeSrc || !safeDest) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeSrc.relativePath === '' || safeSrc.relativePath === '.') {
|
||||||
|
return NextResponse.json({ error: 'Cannot move the server root directory' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||||
|
if (!srcStat) {
|
||||||
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(safeDest.targetPath)) {
|
||||||
|
return NextResponse.json({ error: 'Destination already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await mkdir(dirname(safeDest.targetPath), { recursive: true })
|
||||||
|
await rename(safeSrc.targetPath, safeDest.targetPath)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Moved successfully' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Move Multiple ─────────────────────────────────────────────
|
||||||
|
if (action === 'move-multiple') {
|
||||||
|
const { paths, destination } = body
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0 || !destination) {
|
||||||
|
return NextResponse.json({ error: 'Paths array and destination are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||||
|
if (!safeDest) {
|
||||||
|
return NextResponse.json({ error: 'Destination access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(safeDest.targetPath, { recursive: true })
|
||||||
|
|
||||||
|
const results: { path: string; success: boolean; error?: string }[] = []
|
||||||
|
for (const p of paths) {
|
||||||
|
const safeSrc = resolveSafePath(server._id.toString(), p)
|
||||||
|
if (!safeSrc || safeSrc.relativePath === '' || safeSrc.relativePath === '.') {
|
||||||
|
results.push({ path: p, success: false, error: 'Access denied' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||||
|
if (!srcStat) {
|
||||||
|
results.push({ path: p, success: false, error: 'Not found' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const destItemPath = join(safeDest.targetPath, basename(safeSrc.targetPath))
|
||||||
|
if (existsSync(destItemPath)) {
|
||||||
|
results.push({ path: p, success: false, error: 'Destination already exists' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await rename(safeSrc.targetPath, destItemPath)
|
||||||
|
results.push({ path: p, success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved = results.filter((r) => r.success).length
|
||||||
|
return NextResponse.json({ success: true, message: `Moved ${moved} of ${paths.length} items`, data: results })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Folder ─────────────────────────────────────────────
|
||||||
|
if (action === 'create-folder') {
|
||||||
|
const { path: folderPath } = body
|
||||||
|
if (!folderPath) {
|
||||||
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), folderPath)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(safe.targetPath)) {
|
||||||
|
return NextResponse.json({ error: 'Folder already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(safe.targetPath, { recursive: true })
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Folder created' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zip ───────────────────────────────────────────────────────
|
||||||
|
if (action === 'zip') {
|
||||||
|
const { path: targetPath } = body
|
||||||
|
if (!targetPath) {
|
||||||
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), targetPath)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||||
|
if (!targetStat) {
|
||||||
|
return NextResponse.json({ error: 'Path not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipName = `${basename(safe.targetPath)}.zip`
|
||||||
|
const zipPath = join(dirname(safe.targetPath), zipName)
|
||||||
|
|
||||||
|
if (existsSync(zipPath)) {
|
||||||
|
return NextResponse.json({ error: `${zipName} already exists` }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDir = targetStat.isDirectory()
|
||||||
|
const cmd = isDir
|
||||||
|
? `cd "${dirname(safe.targetPath)}" && zip -r "${zipPath}" "${basename(safe.targetPath)}"`
|
||||||
|
: `cd "${dirname(safe.targetPath)}" && zip "${zipPath}" "${basename(safe.targetPath)}"`
|
||||||
|
|
||||||
|
await execAsync(cmd)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: `Created ${zipName}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unzip ─────────────────────────────────────────────────────
|
||||||
|
if (action === 'unzip') {
|
||||||
|
const { path: targetPath } = body
|
||||||
|
if (!targetPath) {
|
||||||
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = resolveSafePath(server._id.toString(), targetPath)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||||
|
if (!targetStat || !targetStat.isFile()) {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(safe.targetPath).toLowerCase()
|
||||||
|
const destDir = dirname(safe.targetPath)
|
||||||
|
|
||||||
|
if (ext === '.zip') {
|
||||||
|
await execAsync(`unzip -o "${safe.targetPath}" -d "${destDir}"`)
|
||||||
|
} else if (ext === '.gz' && safe.targetPath.endsWith('.tar.gz')) {
|
||||||
|
await execAsync(`tar -xzf "${safe.targetPath}" -C "${destDir}"`)
|
||||||
|
} else if (ext === '.tgz') {
|
||||||
|
await execAsync(`tar -xzf "${safe.targetPath}" -C "${destDir}"`)
|
||||||
|
} else if (ext === '.tar') {
|
||||||
|
await execAsync(`tar -xf "${safe.targetPath}" -C "${destDir}"`)
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Unsupported archive format. Supported: .zip, .tar.gz, .tgz, .tar' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Extracted successfully' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete Multiple ────────────────────────────────────────────
|
||||||
|
if (action === 'delete-multiple') {
|
||||||
|
const { paths } = body
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Paths array is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: { path: string; success: boolean; error?: string }[] = []
|
||||||
|
for (const p of paths) {
|
||||||
|
const safe = resolveSafePath(server._id.toString(), p)
|
||||||
|
if (!safe || safe.relativePath === '' || safe.relativePath === '.') {
|
||||||
|
results.push({ path: p, success: false, error: 'Access denied' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||||
|
if (!targetStat) {
|
||||||
|
results.push({ path: p, success: false, error: 'Not found' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await rm(safe.targetPath, { recursive: true, force: true })
|
||||||
|
results.push({ path: p, success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = results.filter((r) => r.success).length
|
||||||
|
return NextResponse.json({ success: true, message: `Deleted ${deleted} of ${paths.length} items`, data: results })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zip Multiple ──────────────────────────────────────────────
|
||||||
|
if (action === 'zip-multiple') {
|
||||||
|
const { paths, zipName } = body
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Paths array is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all paths first
|
||||||
|
const safePaths: { safe: NonNullable<ReturnType<typeof resolveSafePath>>; original: string }[] = []
|
||||||
|
for (const p of paths) {
|
||||||
|
const safe = resolveSafePath(server._id.toString(), p)
|
||||||
|
if (!safe) {
|
||||||
|
return NextResponse.json({ error: `Access denied for path: ${p}` }, { status: 403 })
|
||||||
|
}
|
||||||
|
const targetStat = await stat(safe.targetPath).catch(() => null)
|
||||||
|
if (!targetStat) {
|
||||||
|
return NextResponse.json({ error: `Not found: ${p}` }, { status: 404 })
|
||||||
|
}
|
||||||
|
safePaths.push({ safe, original: p })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output zip path (in the common parent directory)
|
||||||
|
const firstDir = dirname(safePaths[0].safe.targetPath)
|
||||||
|
const outputName = zipName || `archive-${Date.now()}.zip`
|
||||||
|
const zipPath = join(firstDir, outputName)
|
||||||
|
|
||||||
|
if (existsSync(zipPath)) {
|
||||||
|
return NextResponse.json({ error: `${outputName} already exists` }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build zip command with all items relative to the parent dir
|
||||||
|
const items = safePaths.map((sp) => `"${basename(sp.safe.targetPath)}"`).join(' ')
|
||||||
|
const dirItems = safePaths.filter((sp) => {
|
||||||
|
const s = require('fs').statSync(sp.safe.targetPath)
|
||||||
|
return s.isDirectory()
|
||||||
|
})
|
||||||
|
const fileItems = safePaths.filter((sp) => {
|
||||||
|
const s = require('fs').statSync(sp.safe.targetPath)
|
||||||
|
return s.isFile()
|
||||||
|
})
|
||||||
|
|
||||||
|
let cmd = `cd "${firstDir}"`
|
||||||
|
// Add directories with -r flag
|
||||||
|
if (dirItems.length > 0) {
|
||||||
|
const dirNames = dirItems.map((sp) => `"${basename(sp.safe.targetPath)}"`).join(' ')
|
||||||
|
cmd += ` && zip -r "${zipPath}" ${dirNames}`
|
||||||
|
}
|
||||||
|
// Add files (append if zip already started)
|
||||||
|
if (fileItems.length > 0) {
|
||||||
|
const fileNames = fileItems.map((sp) => `"${basename(sp.safe.targetPath)}"`).join(' ')
|
||||||
|
if (dirItems.length > 0) {
|
||||||
|
cmd += ` && zip "${zipPath}" ${fileNames}`
|
||||||
|
} else {
|
||||||
|
cmd += ` && zip "${zipPath}" ${fileNames}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync(cmd)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: `Created ${outputName}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Copy Multiple ─────────────────────────────────────────────
|
||||||
|
if (action === 'copy-multiple') {
|
||||||
|
const { paths, destination } = body
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0 || !destination) {
|
||||||
|
return NextResponse.json({ error: 'Paths array and destination are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeDest = resolveSafePath(server._id.toString(), destination)
|
||||||
|
if (!safeDest) {
|
||||||
|
return NextResponse.json({ error: 'Destination access denied' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure destination exists and is a directory
|
||||||
|
await mkdir(safeDest.targetPath, { recursive: true })
|
||||||
|
|
||||||
|
const results: { path: string; success: boolean; error?: string }[] = []
|
||||||
|
for (const p of paths) {
|
||||||
|
const safeSrc = resolveSafePath(server._id.toString(), p)
|
||||||
|
if (!safeSrc) {
|
||||||
|
results.push({ path: p, success: false, error: 'Access denied' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const srcStat = await stat(safeSrc.targetPath).catch(() => null)
|
||||||
|
if (!srcStat) {
|
||||||
|
results.push({ path: p, success: false, error: 'Not found' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const destItemPath = join(safeDest.targetPath, basename(safeSrc.targetPath))
|
||||||
|
if (existsSync(destItemPath)) {
|
||||||
|
results.push({ path: p, success: false, error: 'Destination already exists' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await cp(safeSrc.targetPath, destItemPath, { recursive: true })
|
||||||
|
results.push({ path: p, success: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = results.filter((r) => r.success).length
|
||||||
|
return NextResponse.json({ success: true, message: `Copied ${copied} of ${paths.length} items`, data: results })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File action error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/app/api/servers/[id]/logs/route.ts
Normal file
111
src/app/api/servers/[id]/logs/route.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { readdir, stat, readFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/logs — List log files or read a specific log
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:console')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logsDir = join(getServerPath(server._id.toString()), 'logs')
|
||||||
|
const fileName = request.nextUrl.searchParams.get('file')
|
||||||
|
|
||||||
|
// If a file is requested, return its contents
|
||||||
|
if (fileName) {
|
||||||
|
// Prevent path traversal
|
||||||
|
const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '')
|
||||||
|
if (safeName !== fileName) {
|
||||||
|
return NextResponse.json({ error: 'Invalid file name' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = join(logsDir, safeName)
|
||||||
|
|
||||||
|
// Ensure we're still within the logs directory
|
||||||
|
if (!filePath.startsWith(logsDir)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Support .gz files
|
||||||
|
if (safeName.endsWith('.gz')) {
|
||||||
|
const { createGunzip } = await import('zlib')
|
||||||
|
const { pipeline } = await import('stream/promises')
|
||||||
|
const { createReadStream } = await import('fs')
|
||||||
|
const { PassThrough } = await import('stream')
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
const gunzip = createGunzip()
|
||||||
|
const collector = new PassThrough()
|
||||||
|
collector.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
|
||||||
|
await pipeline(createReadStream(filePath), gunzip, collector)
|
||||||
|
const content = Buffer.concat(chunks).toString('utf-8')
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: { fileName: safeName, content } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readFile(filePath, 'utf-8')
|
||||||
|
return NextResponse.json({ success: true, data: { fileName: safeName, content } })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Log file not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all log files
|
||||||
|
try {
|
||||||
|
const entries = await readdir(logsDir)
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(join(logsDir, name))
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
size: fileStat.size,
|
||||||
|
modifiedAt: fileStat.mtime.toISOString(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const validFiles = files
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => new Date(b!.modifiedAt).getTime() - new Date(a!.modifiedAt).getTime())
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: validFiles })
|
||||||
|
} catch {
|
||||||
|
// Logs directory doesn't exist yet
|
||||||
|
return NextResponse.json({ success: true, data: [] })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logs error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/servers/[id]/mods/[filename]/route.ts
Normal file
67
src/app/api/servers/[id]/mods/[filename]/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { unlink } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// DELETE /api/servers/[id]/mods/[filename] — Remove a mod
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'mods:remove')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, filename } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedFilename = decodeURIComponent(filename)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server || (server.type !== 'forge' && server.type !== 'fabric')) {
|
||||||
|
return NextResponse.json({ error: 'Server not found or not a Forge/Fabric server' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(getServerPath(server._id.toString()), 'mods', decodedFilename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(filePath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Mod file not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'mod_removed',
|
||||||
|
entityType: 'mod',
|
||||||
|
entityName: decodedFilename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: { serverId: server._id.toString(), filename: decodedFilename },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remove mod error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/servers/[id]/mods/[filename]/toggle/route.ts
Normal file
75
src/app/api/servers/[id]/mods/[filename]/toggle/route.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { rename } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/mods/[filename]/toggle — Enable/disable a mod
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'mods:toggle')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, filename } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedFilename = decodeURIComponent(filename)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server || (server.type !== 'forge' && server.type !== 'fabric')) {
|
||||||
|
return NextResponse.json({ error: 'Server not found or not a Forge/Fabric server' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const modsDir = path.join(getServerPath(server._id.toString()), 'mods')
|
||||||
|
const currentPath = path.join(modsDir, decodedFilename)
|
||||||
|
|
||||||
|
const isEnabled = decodedFilename.endsWith('.jar') && !decodedFilename.endsWith('.jar.disabled')
|
||||||
|
const newFilename = isEnabled
|
||||||
|
? `${decodedFilename}.disabled`
|
||||||
|
: decodedFilename.replace('.jar.disabled', '.jar')
|
||||||
|
const newPath = path.join(modsDir, newFilename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rename(currentPath, newPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Mod file not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: isEnabled ? 'mod_disabled' : 'mod_enabled',
|
||||||
|
entityType: 'mod',
|
||||||
|
entityName: decodedFilename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: { filename: decodedFilename },
|
||||||
|
newValues: { filename: newFilename },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: `Mod ${isEnabled ? 'disabled' : 'enabled'}. Restart to apply.` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Toggle mod error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/api/servers/[id]/mods/route.ts
Normal file
134
src/app/api/servers/[id]/mods/route.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { readdir, stat, writeFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/mods — List mods
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'mods:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type !== 'forge' && server.type !== 'fabric') {
|
||||||
|
return NextResponse.json({ error: 'Mods are only available for Forge/Fabric servers' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const modsDir = path.join(getServerPath(server._id.toString()), 'mods')
|
||||||
|
const mods = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await readdir(modsDir)
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.jar') || file.endsWith('.jar.disabled')) {
|
||||||
|
const filePath = path.join(modsDir, file)
|
||||||
|
const stats = await stat(filePath)
|
||||||
|
const enabled = file.endsWith('.jar') && !file.endsWith('.jar.disabled')
|
||||||
|
const name = file.replace(/\.jar(\.disabled)?$/, '')
|
||||||
|
mods.push({
|
||||||
|
name,
|
||||||
|
filename: file,
|
||||||
|
size: stats.size,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: mods })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch mods error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/mods — Upload a mod
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'mods:install')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type !== 'forge' && server.type !== 'fabric') {
|
||||||
|
return NextResponse.json({ error: 'Mods are only available for Forge/Fabric servers' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
if (!file || !file.name.endsWith('.jar')) {
|
||||||
|
return NextResponse.json({ error: 'A .jar file is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const modsDir = path.join(getServerPath(server._id.toString()), 'mods')
|
||||||
|
const { execSync } = await import('child_process')
|
||||||
|
execSync(`mkdir -p "${modsDir}"`)
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
await writeFile(path.join(modsDir, file.name), buffer)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'mod_installed',
|
||||||
|
entityType: 'mod',
|
||||||
|
entityName: file.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
newValues: { serverId: server._id.toString(), filename: file.name },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Mod uploaded. Restart the server to load it.' }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload mod error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/app/api/servers/[id]/players/[name]/[action]/route.ts
Normal file
104
src/app/api/servers/[id]/players/[name]/[action]/route.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/players/[name]/[action]
|
||||||
|
// Actions: whitelist, unwhitelist, op, deop, ban, unban
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; name: string; action: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, name: playerName, action } = await params
|
||||||
|
const decodedName = decodeURIComponent(playerName)
|
||||||
|
|
||||||
|
// Map action to permission
|
||||||
|
const permissionMap: Record<string, string> = {
|
||||||
|
whitelist: 'players:whitelist',
|
||||||
|
unwhitelist: 'players:whitelist',
|
||||||
|
op: 'players:op',
|
||||||
|
deop: 'players:op',
|
||||||
|
ban: 'players:ban',
|
||||||
|
unban: 'players:ban',
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredPermission = permissionMap[action]
|
||||||
|
if (!requiredPermission) {
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, requiredPermission)) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map action to MC command
|
||||||
|
const commandMap: Record<string, string> = {
|
||||||
|
whitelist: `whitelist add ${decodedName}`,
|
||||||
|
unwhitelist: `whitelist remove ${decodedName}`,
|
||||||
|
op: `op ${decodedName}`,
|
||||||
|
deop: `deop ${decodedName}`,
|
||||||
|
ban: `ban ${decodedName}`,
|
||||||
|
unban: `pardon ${decodedName}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcCommand = commandMap[action]
|
||||||
|
|
||||||
|
if (server.status === 'online') {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: ['rcon-cli', mcCommand],
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
})
|
||||||
|
await exec.start({ Detach: false })
|
||||||
|
} catch (execError) {
|
||||||
|
console.error('RCON command failed:', execError)
|
||||||
|
return NextResponse.json({ error: 'Failed to execute command' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'Server must be online to manage players' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: `player_${action}`,
|
||||||
|
entityType: 'player',
|
||||||
|
entityName: decodedName,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
newValues: { player: decodedName, action, serverId: server._id.toString() },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: `Player ${action} successful` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Player action error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/app/api/servers/[id]/players/route.ts
Normal file
158
src/app/api/servers/[id]/players/route.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { getContainerByName, getServerPath } from '@/lib/docker'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
interface PlayerInfo {
|
||||||
|
name: string
|
||||||
|
uuid?: string
|
||||||
|
isOp: boolean
|
||||||
|
isWhitelisted: boolean
|
||||||
|
isBanned: boolean
|
||||||
|
isOnline: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/players — List all known players
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'players:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverDir = getServerPath(server._id.toString())
|
||||||
|
|
||||||
|
// Parse player lists from JSON files
|
||||||
|
const whitelist = await readJsonFile(path.join(serverDir, 'whitelist.json'))
|
||||||
|
const ops = await readJsonFile(path.join(serverDir, 'ops.json'))
|
||||||
|
const bannedPlayers = await readJsonFile(path.join(serverDir, 'banned-players.json'))
|
||||||
|
|
||||||
|
// Get online players via RCON
|
||||||
|
let onlineNames: string[] = []
|
||||||
|
if (server.status === 'online') {
|
||||||
|
try {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
const exec = await container.exec({
|
||||||
|
Cmd: ['rcon-cli', 'list'],
|
||||||
|
AttachStdout: true,
|
||||||
|
})
|
||||||
|
const stream = await exec.start({ Detach: false })
|
||||||
|
let output = ''
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
stream.on('data', (chunk: Buffer) => { output += chunk.slice(8).toString('utf-8') })
|
||||||
|
stream.on('end', resolve)
|
||||||
|
setTimeout(resolve, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse "There are X of a max of Y players online: player1, player2"
|
||||||
|
const match = output.match(/players online:(.*)/)
|
||||||
|
if (match) {
|
||||||
|
onlineNames = match[1].split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// RCON may not be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all sources
|
||||||
|
const playerMap = new Map<string, PlayerInfo>()
|
||||||
|
|
||||||
|
for (const entry of whitelist) {
|
||||||
|
const name = entry.name
|
||||||
|
playerMap.set(name, {
|
||||||
|
name,
|
||||||
|
uuid: entry.uuid,
|
||||||
|
isOp: false,
|
||||||
|
isWhitelisted: true,
|
||||||
|
isBanned: false,
|
||||||
|
isOnline: onlineNames.includes(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of ops) {
|
||||||
|
const name = entry.name
|
||||||
|
const existing = playerMap.get(name)
|
||||||
|
if (existing) {
|
||||||
|
existing.isOp = true
|
||||||
|
} else {
|
||||||
|
playerMap.set(name, {
|
||||||
|
name,
|
||||||
|
uuid: entry.uuid,
|
||||||
|
isOp: true,
|
||||||
|
isWhitelisted: false,
|
||||||
|
isBanned: false,
|
||||||
|
isOnline: onlineNames.includes(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of bannedPlayers) {
|
||||||
|
const name = entry.name
|
||||||
|
const existing = playerMap.get(name)
|
||||||
|
if (existing) {
|
||||||
|
existing.isBanned = true
|
||||||
|
} else {
|
||||||
|
playerMap.set(name, {
|
||||||
|
name,
|
||||||
|
uuid: entry.uuid,
|
||||||
|
isOp: false,
|
||||||
|
isWhitelisted: false,
|
||||||
|
isBanned: true,
|
||||||
|
isOnline: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add online-only players not in any list
|
||||||
|
for (const name of onlineNames) {
|
||||||
|
if (!playerMap.has(name)) {
|
||||||
|
playerMap.set(name, {
|
||||||
|
name,
|
||||||
|
isOp: false,
|
||||||
|
isWhitelisted: false,
|
||||||
|
isBanned: false,
|
||||||
|
isOnline: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = Array.from(playerMap.values())
|
||||||
|
return NextResponse.json({ success: true, data: players })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch players error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(filePath: string): Promise<Array<{ name: string; uuid?: string }>> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(filePath, 'utf-8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/servers/[id]/plugins/[filename]/route.ts
Normal file
67
src/app/api/servers/[id]/plugins/[filename]/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { unlink } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// DELETE /api/servers/[id]/plugins/[filename] — Remove a plugin
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'plugins:remove')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, filename } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedFilename = decodeURIComponent(filename)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server || server.type !== 'bukkit') {
|
||||||
|
return NextResponse.json({ error: 'Server not found or not a Bukkit server' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(getServerPath(server._id.toString()), 'plugins', decodedFilename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(filePath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Plugin file not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'plugin_removed',
|
||||||
|
entityType: 'plugin',
|
||||||
|
entityName: decodedFilename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: { serverId: server._id.toString(), filename: decodedFilename },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remove plugin error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts
Normal file
75
src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { rename } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/plugins/[filename]/toggle — Enable/disable a plugin
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; filename: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'plugins:toggle')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, filename } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedFilename = decodeURIComponent(filename)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server || server.type !== 'bukkit') {
|
||||||
|
return NextResponse.json({ error: 'Server not found or not a Bukkit server' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins')
|
||||||
|
const currentPath = path.join(pluginsDir, decodedFilename)
|
||||||
|
|
||||||
|
const isEnabled = decodedFilename.endsWith('.jar') && !decodedFilename.endsWith('.jar.disabled')
|
||||||
|
const newFilename = isEnabled
|
||||||
|
? `${decodedFilename}.disabled`
|
||||||
|
: decodedFilename.replace('.jar.disabled', '.jar')
|
||||||
|
const newPath = path.join(pluginsDir, newFilename)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rename(currentPath, newPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Plugin file not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: isEnabled ? 'plugin_disabled' : 'plugin_enabled',
|
||||||
|
entityType: 'plugin',
|
||||||
|
entityName: decodedFilename,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: { filename: decodedFilename },
|
||||||
|
newValues: { filename: newFilename },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: `Plugin ${isEnabled ? 'disabled' : 'enabled'}. Restart to apply.` })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Toggle plugin error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/api/servers/[id]/plugins/route.ts
Normal file
134
src/app/api/servers/[id]/plugins/route.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getServerPath } from '@/lib/docker'
|
||||||
|
import { readdir, stat, writeFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/plugins — List plugins
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'plugins:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type !== 'bukkit') {
|
||||||
|
return NextResponse.json({ error: 'Plugins are only available for Bukkit-type servers' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins')
|
||||||
|
const plugins = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await readdir(pluginsDir)
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.jar') || file.endsWith('.jar.disabled')) {
|
||||||
|
const filePath = path.join(pluginsDir, file)
|
||||||
|
const stats = await stat(filePath)
|
||||||
|
const enabled = file.endsWith('.jar')
|
||||||
|
const name = file.replace(/\.jar(\.disabled)?$/, '')
|
||||||
|
plugins.push({
|
||||||
|
name,
|
||||||
|
filename: file,
|
||||||
|
size: stats.size,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: plugins })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch plugins error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/servers/[id]/plugins — Upload a plugin
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'plugins:install')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type !== 'bukkit') {
|
||||||
|
return NextResponse.json({ error: 'Plugins are only available for Bukkit-type servers' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
if (!file || !file.name.endsWith('.jar')) {
|
||||||
|
return NextResponse.json({ error: 'A .jar file is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins')
|
||||||
|
const { execSync } = await import('child_process')
|
||||||
|
execSync(`mkdir -p "${pluginsDir}"`)
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
await writeFile(path.join(pluginsDir, file.name), buffer)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'plugin_installed',
|
||||||
|
entityType: 'plugin',
|
||||||
|
entityName: file.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
newValues: { serverId: server._id.toString(), filename: file.name },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Plugin uploaded. Restart the server to load it.' }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload plugin error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/api/servers/[id]/restart/route.ts
Normal file
90
src/app/api/servers/[id]/restart/route.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:restart')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the server is currently offline, check for port conflicts before restarting
|
||||||
|
if (server.status !== 'online' && server.status !== 'starting') {
|
||||||
|
const portConflict = await Server.findOne({
|
||||||
|
_id: { $ne: server._id },
|
||||||
|
port: server.port,
|
||||||
|
status: { $in: ['online', 'starting'] },
|
||||||
|
})
|
||||||
|
if (portConflict) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Port ${server.port} is already in use by "${portConflict.name}"` },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (!container) {
|
||||||
|
return NextResponse.json({ error: 'Docker container not found' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'stopping' })
|
||||||
|
await container.restart({ t: 30 })
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'starting' })
|
||||||
|
|
||||||
|
// Poll for running state
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const inspect = await container.inspect()
|
||||||
|
const status = inspect.State?.Running ? 'online' : 'offline'
|
||||||
|
await Server.findByIdAndUpdate(id, { status })
|
||||||
|
} catch {
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'crashed' })
|
||||||
|
}
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_restarted',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Server restarting' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Restart server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/app/api/servers/[id]/route.ts
Normal file
196
src/app/api/servers/[id]/route.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { sanitizeObject, isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
// GET /api/servers/[id] — Get server details
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id).lean()
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile status with Docker
|
||||||
|
try {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
const inspect = await container.inspect()
|
||||||
|
const running = inspect.State?.Running
|
||||||
|
const actualStatus = running ? 'online' : 'offline'
|
||||||
|
if (server.status !== actualStatus && server.status !== 'starting' && server.status !== 'stopping') {
|
||||||
|
await Server.findByIdAndUpdate(id, { status: actualStatus })
|
||||||
|
server.status = actualStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Container may not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: server })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/servers/[id] — Update server
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const body = sanitizeObject(raw)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousValues = server.toObject()
|
||||||
|
|
||||||
|
// Allowlisted update fields
|
||||||
|
const allowed = ['name', 'maxPlayers', 'memory', 'jvmArgs', 'autoStart', 'autoRestart', 'backupSchedule', 'backupRetention']
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(server as any)[key] = body[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await server.save()
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_updated',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues,
|
||||||
|
newValues: body,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: server })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/servers/[id] — Delete server
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:delete')) {
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_delete_denied',
|
||||||
|
entityType: 'server',
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'failure',
|
||||||
|
statusCode: 403,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Docker container
|
||||||
|
try {
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (container) {
|
||||||
|
await container.remove({ force: true })
|
||||||
|
}
|
||||||
|
} catch (dockerError) {
|
||||||
|
console.error('Failed to remove container:', dockerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Server.findByIdAndDelete(id)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_deleted',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: server.toObject(),
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/api/servers/[id]/start/route.ts
Normal file
91
src/app/api/servers/[id]/start/route.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:start')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status === 'online' || server.status === 'starting') {
|
||||||
|
return NextResponse.json({ error: 'Server is already running or starting' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if another server is already using this port
|
||||||
|
const portConflict = await Server.findOne({
|
||||||
|
_id: { $ne: server._id },
|
||||||
|
port: server.port,
|
||||||
|
status: { $in: ['online', 'starting'] },
|
||||||
|
})
|
||||||
|
if (portConflict) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Port ${server.port} is already in use by "${portConflict.name}"` },
|
||||||
|
{ status: 409 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (!container) {
|
||||||
|
return NextResponse.json({ error: 'Docker container not found. Try recreating the server.' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'starting' })
|
||||||
|
await container.start()
|
||||||
|
|
||||||
|
// Poll for running state
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const inspect = await container.inspect()
|
||||||
|
const status = inspect.State?.Running ? 'online' : 'offline'
|
||||||
|
await Server.findByIdAndUpdate(id, { status })
|
||||||
|
} catch {
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'crashed' })
|
||||||
|
}
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_started',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Server starting' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Start server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/app/api/servers/[id]/stats/route.ts
Normal file
160
src/app/api/servers/[id]/stats/route.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
interface DockerCpuStats {
|
||||||
|
cpu_stats: {
|
||||||
|
cpu_usage: { total_usage: number }
|
||||||
|
system_cpu_usage: number
|
||||||
|
online_cpus: number
|
||||||
|
}
|
||||||
|
precpu_stats: {
|
||||||
|
cpu_usage: { total_usage: number }
|
||||||
|
system_cpu_usage: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockerMemoryStats {
|
||||||
|
memory_stats: {
|
||||||
|
usage: number
|
||||||
|
limit: number
|
||||||
|
stats?: { cache?: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DockerStats = DockerCpuStats & DockerMemoryStats
|
||||||
|
|
||||||
|
function calculateCpuPercent(stats: DockerStats): number {
|
||||||
|
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage
|
||||||
|
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage
|
||||||
|
const cpuCount = stats.cpu_stats.online_cpus || 1
|
||||||
|
|
||||||
|
if (systemDelta <= 0 || cpuDelta < 0) return 0
|
||||||
|
return Math.min(100, (cpuDelta / systemDelta) * cpuCount * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/servers/[id]/stats — SSE stream of container CPU & memory stats
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:view')) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Invalid server ID' }), { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id).lean()
|
||||||
|
if (!server) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Server not found' }), { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`).catch(() => null)
|
||||||
|
if (!container) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Container not found' }), { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if container is running
|
||||||
|
try {
|
||||||
|
const inspect = await container.inspect()
|
||||||
|
if (!inspect.State?.Running) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Container is not running' }), { status: 400 })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to inspect container' }), { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
const statsStream = await container.stats({ stream: true }) as NodeJS.ReadableStream
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
buffer += chunk.toString()
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const stats: DockerStats = JSON.parse(line)
|
||||||
|
|
||||||
|
const cpuPercent = calculateCpuPercent(stats)
|
||||||
|
const memUsage = stats.memory_stats.usage || 0
|
||||||
|
const memLimit = stats.memory_stats.limit || 0
|
||||||
|
const memCache = stats.memory_stats.stats?.cache || 0
|
||||||
|
const memActual = memUsage - memCache
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
cpu: Math.round(cpuPercent * 100) / 100,
|
||||||
|
memUsed: memActual,
|
||||||
|
memLimit: memLimit,
|
||||||
|
memPercent: memLimit > 0 ? Math.round((memActual / memLimit) * 10000) / 100 : 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`))
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
if (!cancelled) {
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
if (!cancelled) {
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statsStream.on('data', onData)
|
||||||
|
statsStream.on('end', onEnd)
|
||||||
|
statsStream.on('error', onError)
|
||||||
|
|
||||||
|
// Cleanup on cancel
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
cancelled = true
|
||||||
|
statsStream.removeListener('data', onData)
|
||||||
|
statsStream.removeListener('end', onEnd)
|
||||||
|
statsStream.removeListener('error', onError)
|
||||||
|
try { (statsStream as any).destroy?.() } catch {}
|
||||||
|
try { controller.close() } catch {}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
controller.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
69
src/app/api/servers/[id]/stop/route.ts
Normal file
69
src/app/api/servers/[id]/stop/route.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { isValidObjectId } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getContainerByName } from '@/lib/docker'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:stop')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const server = await Server.findById(id)
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status === 'offline' || server.status === 'stopping') {
|
||||||
|
return NextResponse.json({ error: 'Server is already stopped or stopping' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await getContainerByName(`mc-${server._id}`)
|
||||||
|
if (!container) {
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'offline' })
|
||||||
|
return NextResponse.json({ success: true, message: 'Server marked as offline' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'stopping' })
|
||||||
|
await container.stop({ t: 30 }) // 30 second graceful shutdown
|
||||||
|
await Server.findByIdAndUpdate(id, { status: 'offline' })
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_stopped',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Server stopped' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stop server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/app/api/servers/route.ts
Normal file
150
src/app/api/servers/route.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { Server } from '@/lib/models'
|
||||||
|
import { sanitizeObject, isValidPort } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { createServerContainer, getServerPath, ensureServerDirectory } from '@/lib/docker'
|
||||||
|
|
||||||
|
// GET /api/servers — List all servers
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const servers = await Server.find().sort({ createdAt: -1 }).lean()
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: servers })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch servers error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/servers — Create a new server
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'servers:create')) {
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_create_denied',
|
||||||
|
entityType: 'server',
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'failure',
|
||||||
|
statusCode: 403,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const body = sanitizeObject(raw)
|
||||||
|
|
||||||
|
const { name, type, version, port, maxPlayers, memory, dockerImage, jvmArgs } = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !type || !version || !port) {
|
||||||
|
return NextResponse.json({ error: 'Name, type, version, and port are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ['vanilla', 'bukkit', 'forge', 'fabric']
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid server type' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPort(port)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid port number' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
// Check for name conflict
|
||||||
|
const existingName = await Server.findOne({ name })
|
||||||
|
if (existingName) {
|
||||||
|
return NextResponse.json({ error: `Server name "${name}" already exists` }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new Server({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
version,
|
||||||
|
port: Number(port),
|
||||||
|
maxPlayers: Number(maxPlayers) || 20,
|
||||||
|
memory: {
|
||||||
|
min: Number(memory?.min) || 512,
|
||||||
|
max: Number(memory?.max) || 1024,
|
||||||
|
},
|
||||||
|
dockerImage: dockerImage || 'itzg/minecraft-server',
|
||||||
|
jvmArgs: jvmArgs || [],
|
||||||
|
status: 'offline',
|
||||||
|
autoStart: false,
|
||||||
|
autoRestart: true,
|
||||||
|
createdBy: session._id,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set containerName before save — schema requires it
|
||||||
|
server.containerName = `mc-${server._id}`
|
||||||
|
|
||||||
|
await server.save()
|
||||||
|
|
||||||
|
// Create server data directory with correct permissions and Docker container
|
||||||
|
const serverPath = getServerPath(server._id.toString())
|
||||||
|
await ensureServerDirectory(serverPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = await createServerContainer({
|
||||||
|
containerName: `mc-${server._id}`,
|
||||||
|
dockerImage: server.dockerImage || 'itzg/minecraft-server',
|
||||||
|
serverType: server.type,
|
||||||
|
version: server.version,
|
||||||
|
port: server.port,
|
||||||
|
rconPort: server.rconPort || null,
|
||||||
|
memoryMax: server.memory?.max || 1024,
|
||||||
|
memoryMin: server.memory?.min || 512,
|
||||||
|
jvmArgs: server.jvmArgs || [],
|
||||||
|
autoRestart: server.autoRestart || false,
|
||||||
|
serverPath,
|
||||||
|
maxPlayers: server.maxPlayers || 20,
|
||||||
|
})
|
||||||
|
server.containerId = container.id
|
||||||
|
await server.save()
|
||||||
|
} catch (dockerError) {
|
||||||
|
console.error('Docker container creation failed:', dockerError)
|
||||||
|
// Server record is saved but container failed — user can retry
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'server_created',
|
||||||
|
entityType: 'server',
|
||||||
|
entityId: server._id.toString(),
|
||||||
|
entityName: server.name,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
newValues: { name, type, version, port },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: server }, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create server error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/app/api/users/[id]/route.ts
Normal file
187
src/app/api/users/[id]/route.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission, hashPassword } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { User } from '@/lib/models'
|
||||||
|
import { sanitizeObject, isValidObjectId, isValidEmail } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// GET /api/users/[id]
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'users:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const user = await User.findById(id)
|
||||||
|
.select('-passwordHash -twoFactorCode -twoFactorExpiry -loginAttempts -lockUntil')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: user })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch user error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/users/[id]
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'users:edit')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const body = sanitizeObject(raw)
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const user = await User.findById(id)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousValues = {
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status,
|
||||||
|
roles: user.roles,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.username) user.username = body.username
|
||||||
|
if (body.email) {
|
||||||
|
if (!isValidEmail(body.email)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
|
||||||
|
}
|
||||||
|
user.email = body.email
|
||||||
|
}
|
||||||
|
if (body.password) {
|
||||||
|
if (body.password.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 })
|
||||||
|
}
|
||||||
|
user.passwordHash = await hashPassword(body.password)
|
||||||
|
}
|
||||||
|
if (body.roles !== undefined) user.roles = body.roles
|
||||||
|
if (body.status) user.status = body.status
|
||||||
|
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'user_updated',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user._id.toString(),
|
||||||
|
entityName: user.username,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues,
|
||||||
|
newValues: { username: body.username, email: body.email, status: body.status, roles: body.roles },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
_id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status,
|
||||||
|
roles: user.roles,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update user error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/users/[id]
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'users:delete')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
if (!isValidObjectId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-deletion
|
||||||
|
if (id === session._id) {
|
||||||
|
return NextResponse.json({ error: 'You cannot delete your own account' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const user = await User.findById(id)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await User.findByIdAndDelete(id)
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'user_deleted',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user._id.toString(),
|
||||||
|
entityName: user.username,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
previousValues: { username: user.username, email: user.email },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete user error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/app/api/users/route.ts
Normal file
119
src/app/api/users/route.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { validateSession, hasPermission, hashPassword } from '@/lib/auth'
|
||||||
|
import connectToDatabase from '@/lib/mongodb'
|
||||||
|
import { User } from '@/lib/models'
|
||||||
|
import { sanitizeObject, isValidEmail } from '@/lib/input-validation'
|
||||||
|
import { createAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// GET /api/users — List all users
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'users:view')) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
const users = await User.find()
|
||||||
|
.select('-passwordHash -twoFactorCode -twoFactorExpiry -loginAttempts -lockUntil')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, data: users })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch users error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/users — Create a new user
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await validateSession(request)
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(session, 'users:create')) {
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'user_create_denied',
|
||||||
|
entityType: 'user',
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
clientIP,
|
||||||
|
status: 'failure',
|
||||||
|
statusCode: 403,
|
||||||
|
})
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await request.json()
|
||||||
|
const { username, email, password, roles, status } = sanitizeObject(raw)
|
||||||
|
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
return NextResponse.json({ error: 'Username, email, and password are required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const existingUser = await User.findOne({ $or: [{ username }, { email }] })
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json({ error: 'Username or email already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password)
|
||||||
|
|
||||||
|
const user = new User({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
roles: roles || [],
|
||||||
|
status: status || 'active',
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
await createAuditLog({
|
||||||
|
action: 'user_created',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user._id.toString(),
|
||||||
|
entityName: user.username,
|
||||||
|
userId: session._id,
|
||||||
|
userName: session.username,
|
||||||
|
userEmail: session.email,
|
||||||
|
newValues: { username, email, roles, status },
|
||||||
|
clientIP,
|
||||||
|
status: 'success',
|
||||||
|
statusCode: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
_id: user._id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status,
|
||||||
|
roles: user.roles,
|
||||||
|
},
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create user error:', error)
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,26 +1,75 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
font-family: var(--font-sans);
|
||||||
color: var(--foreground);
|
background-color: #030712; /* gray-950 */
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
color: #f3f4f6; /* gray-100 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Animations ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scale-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar (Dark Theme) ──────────────────────────────────── */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #111827; /* gray-900 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #374151; /* gray-700 */
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4b5563; /* gray-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Console Font ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.font-mono {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,30 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next'
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import './globals.css'
|
||||||
import "./globals.css";
|
import { AuthProvider } from '@/contexts/AuthContext'
|
||||||
|
import { ToastProvider } from '@/contexts/ToastContext'
|
||||||
const geistSans = Geist({
|
import { ConfirmationProvider } from '@/contexts/ConfirmationContext'
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'MC-Manager',
|
||||||
description: "Generated by create next app",
|
description: 'Minecraft Server Manager',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className="min-h-screen bg-gray-950 text-gray-100 antialiased">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<AuthProvider>
|
||||||
>
|
<ToastProvider>
|
||||||
|
<ConfirmationProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</ConfirmationProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/app/login/page.tsx
Normal file
146
src/app/login/page.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import { Gamepad2, ArrowRight } from 'lucide-react'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Input from '@/components/ui/Input'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [step, setStep] = useState<'credentials' | '2fa'>('credentials')
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const { login, verify2FA } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const redirect = searchParams.get('redirect') || '/dashboard'
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const result = await login(username, password)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error)
|
||||||
|
} else if (result.requiresTwoFactor) {
|
||||||
|
setStep('2fa')
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerify = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const result = await verify2FA(code)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error)
|
||||||
|
} else if (result.success) {
|
||||||
|
router.push(redirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-cyan-500/20 rounded-2xl mb-4">
|
||||||
|
<Gamepad2 size={32} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-100">MC-Manager</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Minecraft Server Manager</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 shadow-xl p-8">
|
||||||
|
{step === 'credentials' ? (
|
||||||
|
<form onSubmit={handleLogin} className="space-y-5">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-100 text-center">Sign In</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
placeholder="Enter username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" loading={loading} icon={ArrowRight} className="w-full">
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleVerify} className="space-y-5">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-100 text-center">Verification Code</h2>
|
||||||
|
<p className="text-sm text-gray-400 text-center">
|
||||||
|
A 6-digit code has been sent to your email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Code"
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
placeholder="000000"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" loading={loading} className="w-full">
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep('credentials'); setError('') }}
|
||||||
|
className="w-full text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Back to login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,65 +1,27 @@
|
|||||||
import Image from "next/image";
|
'use client'
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex items-center justify-center min-h-screen bg-gray-950">
|
||||||
<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">
|
<Spinner size="lg" />
|
||||||
<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>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
136
src/components/ConsoleViewer.tsx
Normal file
136
src/components/ConsoleViewer.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Send, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ConsoleViewerProps {
|
||||||
|
serverId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConsoleViewer({ serverId, readOnly = false }: ConsoleViewerProps) {
|
||||||
|
const [lines, setLines] = useState<string[]>([])
|
||||||
|
const [command, setCommand] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new lines
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [lines])
|
||||||
|
|
||||||
|
// SSE log streaming
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(`/api/servers/${serverId}/console`)
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
const line = data.line || event.data
|
||||||
|
setLines(prev => [...prev.slice(-500), line])
|
||||||
|
} catch {
|
||||||
|
setLines(prev => [...prev.slice(-500), event.data])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
}, [serverId])
|
||||||
|
|
||||||
|
const handleSendCommand = async () => {
|
||||||
|
if (!command.trim() || sending) return
|
||||||
|
|
||||||
|
const cmd = command.trim()
|
||||||
|
// Show the command in the console
|
||||||
|
setLines(prev => [...prev.slice(-500), `> ${cmd}`])
|
||||||
|
setCommand('')
|
||||||
|
inputRef.current?.focus()
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/console`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ command: cmd }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setLines(prev => [...prev.slice(-500), `§c Error: ${data.error || 'Command failed'}`])
|
||||||
|
}
|
||||||
|
// Output arrives via SSE log stream — no need to parse response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send command:', error)
|
||||||
|
setLines(prev => [...prev.slice(-500), '§c Error: Failed to send command'])
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSendCommand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-950 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{/* Console Output */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 overflow-y-auto p-4 font-mono text-sm text-gray-300 space-y-0.5 min-h-[400px]"
|
||||||
|
>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<p className="text-gray-600 italic">Waiting for server output...</p>
|
||||||
|
) : (
|
||||||
|
lines.map((line, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap break-all hover:bg-gray-900/50">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command Input */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex items-center gap-2 p-3 border-t border-gray-700/50 bg-gray-900/50">
|
||||||
|
<span className="text-cyan-500 font-mono text-sm">></span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={command}
|
||||||
|
onChange={e => setCommand(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a command..."
|
||||||
|
className="flex-1 bg-transparent text-gray-100 font-mono text-sm placeholder-gray-600 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setLines([])}
|
||||||
|
className="p-1.5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label="Clear console"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSendCommand}
|
||||||
|
disabled={!command.trim() || sending}
|
||||||
|
className="p-1.5 rounded text-cyan-400 hover:text-cyan-300 hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Send command"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
src/components/Drawer.tsx
Normal file
61
src/components/Drawer.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DrawerProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Drawer({ isOpen, onClose, title, children }: DrawerProps) {
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEsc)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEsc)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Drawer Panel */}
|
||||||
|
<div className="absolute inset-y-0 right-0 w-full max-w-3xl animate-slide-in-right">
|
||||||
|
<div className="h-full flex flex-col bg-gray-900/95 backdrop-blur-md border-l border-gray-700/50 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-700/50 bg-gray-900/60 backdrop-blur-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/components/Modal.tsx
Normal file
57
src/components/Modal.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
maxWidth?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-lg' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEsc)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEsc)
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
{/* Modal Panel */}
|
||||||
|
<div className={`relative ${maxWidth} w-full mx-4 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl animate-scale-in`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-700/50">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/components/PageHeader.tsx
Normal file
25
src/components/PageHeader.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
icon?: LucideIcon
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ title, description, icon: Icon, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg shadow-lg border border-gray-700/50 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{Icon && <Icon size={28} className="text-gray-400" />}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-100">{title}</h1>
|
||||||
|
{description && <p className="text-sm text-gray-400 mt-1">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/ProtectedComponent.tsx
Normal file
23
src/components/ProtectedComponent.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
|
interface ProtectedComponentProps {
|
||||||
|
permission: string
|
||||||
|
children: React.ReactNode
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditionally renders children based on the user's permissions.
|
||||||
|
* NOTE: This is a UI hint only — server-side checks are authoritative.
|
||||||
|
*/
|
||||||
|
export default function ProtectedComponent({ permission, children, fallback = null }: ProtectedComponentProps) {
|
||||||
|
const { hasPermission } = useAuth()
|
||||||
|
|
||||||
|
if (!hasPermission(permission)) {
|
||||||
|
return <>{fallback}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
46
src/components/ServerStatusBadge.tsx
Normal file
46
src/components/ServerStatusBadge.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { ServerStatus } from '@/types/server'
|
||||||
|
|
||||||
|
interface ServerStatusBadgeProps {
|
||||||
|
status: ServerStatus
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<ServerStatus, { label: string; className: string; dot: string }> = {
|
||||||
|
online: {
|
||||||
|
label: 'Online',
|
||||||
|
className: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
dot: 'bg-emerald-400',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
label: 'Offline',
|
||||||
|
className: 'bg-gray-500/20 text-gray-400',
|
||||||
|
dot: 'bg-gray-400',
|
||||||
|
},
|
||||||
|
starting: {
|
||||||
|
label: 'Starting',
|
||||||
|
className: 'bg-amber-500/20 text-amber-400',
|
||||||
|
dot: 'bg-amber-400 animate-pulse',
|
||||||
|
},
|
||||||
|
stopping: {
|
||||||
|
label: 'Stopping',
|
||||||
|
className: 'bg-amber-500/20 text-amber-400',
|
||||||
|
dot: 'bg-amber-400 animate-pulse',
|
||||||
|
},
|
||||||
|
crashed: {
|
||||||
|
label: 'Crashed',
|
||||||
|
className: 'bg-red-500/20 text-red-400',
|
||||||
|
dot: 'bg-red-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerStatusBadge({ status, size = 'sm' }: ServerStatusBadgeProps) {
|
||||||
|
const config = statusConfig[status]
|
||||||
|
const sizeClasses = size === 'sm' ? 'px-2.5 py-0.5 text-xs' : 'px-3 py-1 text-sm'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full font-semibold ${sizeClasses} ${config.className}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${config.dot}`} />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/components/Sidebar.tsx
Normal file
110
src/components/Sidebar.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Server,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
ScrollText,
|
||||||
|
LogOut,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Gamepad2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
icon: typeof LayoutDashboard
|
||||||
|
permission?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Servers', href: '/servers', icon: Server, permission: 'servers:view' },
|
||||||
|
{ label: 'Users', href: '/users', icon: Users, permission: 'users:view' },
|
||||||
|
{ label: 'Roles', href: '/roles', icon: Shield, permission: 'roles:view' },
|
||||||
|
{ label: 'Audit Log', href: '/audit', icon: ScrollText, permission: 'audit:view' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const pathname = usePathname()
|
||||||
|
const { user, logout, hasPermission } = useAuth()
|
||||||
|
|
||||||
|
const filteredItems = navItems.filter(
|
||||||
|
item => !item.permission || hasPermission(item.permission)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`flex flex-col bg-gray-900 border-r border-gray-700/50 transition-all duration-300 ${
|
||||||
|
collapsed ? 'w-16' : 'w-64'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-4 h-16 border-b border-gray-700/50">
|
||||||
|
<Gamepad2 size={28} className="text-cyan-500 flex-shrink-0" />
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="text-lg font-bold text-gray-100 truncate">MC-Manager</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||||
|
{filteredItems.map(item => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = pathname === item.href || pathname.startsWith(item.href + '/')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-sm font-medium ${
|
||||||
|
isActive
|
||||||
|
? 'bg-cyan-500/20 text-cyan-400'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} className="flex-shrink-0" />
|
||||||
|
{!collapsed && <span>{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-700/50 p-2 space-y-1">
|
||||||
|
{/* User Info */}
|
||||||
|
{user && !collapsed && (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-sm font-medium text-gray-200 truncate">{user.username}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-400 hover:bg-red-500/10 hover:text-red-400 transition-colors text-sm font-medium w-full"
|
||||||
|
>
|
||||||
|
<LogOut size={20} className="flex-shrink-0" />
|
||||||
|
{!collapsed && <span>Logout</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapse Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(prev => !prev)}
|
||||||
|
className="flex items-center justify-center gap-3 px-3 py-2.5 rounded-lg text-gray-500 hover:bg-gray-800 hover:text-gray-300 transition-colors w-full"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight size={20} /> : <ChevronLeft size={20} />}
|
||||||
|
{!collapsed && <span className="text-sm">Collapse</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/components/StatsChart.tsx
Normal file
127
src/components/StatsChart.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
interface StatsChartProps {
|
||||||
|
data: number[]
|
||||||
|
maxDataPoints?: number
|
||||||
|
height?: number
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
subValue?: string
|
||||||
|
color: 'cyan' | 'emerald' | 'amber' | 'red'
|
||||||
|
maxY?: number
|
||||||
|
unit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
cyan: { stroke: '#06b6d4', fill: 'rgba(6, 182, 212, 0.15)', text: 'text-cyan-400', bg: 'bg-cyan-500/20' },
|
||||||
|
emerald: { stroke: '#10b981', fill: 'rgba(16, 185, 129, 0.15)', text: 'text-emerald-400', bg: 'bg-emerald-500/20' },
|
||||||
|
amber: { stroke: '#f59e0b', fill: 'rgba(245, 158, 11, 0.15)', text: 'text-amber-400', bg: 'bg-amber-500/20' },
|
||||||
|
red: { stroke: '#ef4444', fill: 'rgba(239, 68, 68, 0.15)', text: 'text-red-400', bg: 'bg-red-500/20' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsChart({
|
||||||
|
data,
|
||||||
|
maxDataPoints = 60,
|
||||||
|
height = 120,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subValue,
|
||||||
|
color,
|
||||||
|
maxY = 100,
|
||||||
|
unit = '%',
|
||||||
|
}: StatsChartProps) {
|
||||||
|
const theme = COLORS[color]
|
||||||
|
|
||||||
|
const { linePath, areaPath } = useMemo(() => {
|
||||||
|
if (data.length < 2) return { linePath: '', areaPath: '' }
|
||||||
|
|
||||||
|
const w = 400
|
||||||
|
const h = height
|
||||||
|
const padding = 2
|
||||||
|
const effectiveH = h - padding * 2
|
||||||
|
const clampedMax = Math.max(maxY, 1)
|
||||||
|
|
||||||
|
const points = data.map((val, i) => {
|
||||||
|
const x = (i / (maxDataPoints - 1)) * w
|
||||||
|
const y = padding + effectiveH - (Math.min(val, clampedMax) / clampedMax) * effectiveH
|
||||||
|
return { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Smooth the line with quadratic bezier curves
|
||||||
|
let line = `M ${points[0].x},${points[0].y}`
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const prev = points[i - 1]
|
||||||
|
const curr = points[i]
|
||||||
|
const cpx = (prev.x + curr.x) / 2
|
||||||
|
line += ` Q ${cpx},${prev.y} ${curr.x},${curr.y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastPoint = points[points.length - 1]
|
||||||
|
const firstPoint = points[0]
|
||||||
|
const area = `${line} L ${lastPoint.x},${h} L ${firstPoint.x},${h} Z`
|
||||||
|
|
||||||
|
return { linePath: line, areaPath: area }
|
||||||
|
}, [data, maxDataPoints, height, maxY])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-4 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${theme.bg}`}>
|
||||||
|
<div className={`w-2 h-2 rounded-full animate-pulse`} style={{ backgroundColor: theme.stroke }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`text-lg font-semibold ${theme.text}`}>{value}</span>
|
||||||
|
{subValue && (
|
||||||
|
<span className="text-xs text-gray-500 ml-1">{subValue}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
{data.length < 2 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-xs text-gray-600">Collecting data…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 400 ${height}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
{/* Grid lines */}
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((pct) => (
|
||||||
|
<line
|
||||||
|
key={pct}
|
||||||
|
x1={0}
|
||||||
|
y1={2 + (height - 4) * pct}
|
||||||
|
x2={400}
|
||||||
|
y2={2 + (height - 4) * pct}
|
||||||
|
stroke="rgba(55, 65, 81, 0.3)"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Area fill */}
|
||||||
|
<path d={areaPath} fill={theme.fill} />
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
<path d={linePath} fill="none" stroke={theme.stroke} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
<div className="absolute top-0 left-0 h-full flex flex-col justify-between pointer-events-none py-0.5">
|
||||||
|
<span className="text-[10px] text-gray-600">{maxY}{unit}</span>
|
||||||
|
<span className="text-[10px] text-gray-600">0{unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
src/components/templates/DataManagementTemplate.tsx
Normal file
239
src/components/templates/DataManagementTemplate.tsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Search, Plus, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
sortable?: boolean
|
||||||
|
render?: (item: T) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
label: string
|
||||||
|
filterKey: string
|
||||||
|
options: { label: string; value: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
interface DataManagementTemplateProps<T extends Record<string, any>> {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
icon?: LucideIcon
|
||||||
|
|
||||||
|
items: T[]
|
||||||
|
loading: boolean
|
||||||
|
|
||||||
|
columns: Column<T>[]
|
||||||
|
getRowKey: (item: T) => string
|
||||||
|
|
||||||
|
searchPlaceholder?: string
|
||||||
|
searchFields?: string[]
|
||||||
|
filters?: Filter[]
|
||||||
|
|
||||||
|
onRowClick?: (item: T) => void
|
||||||
|
onAdd?: () => void
|
||||||
|
addLabel?: string
|
||||||
|
canAdd?: boolean
|
||||||
|
|
||||||
|
emptyIcon?: LucideIcon
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export default function DataManagementTemplate<T extends Record<string, any>>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
loading,
|
||||||
|
columns,
|
||||||
|
getRowKey,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
searchFields = [],
|
||||||
|
filters = [],
|
||||||
|
onRowClick,
|
||||||
|
onAdd,
|
||||||
|
addLabel = 'Create New',
|
||||||
|
canAdd = true,
|
||||||
|
emptyIcon: EmptyIcon,
|
||||||
|
emptyTitle = 'No items found',
|
||||||
|
emptyDescription = 'Get started by creating one.',
|
||||||
|
}: DataManagementTemplateProps<T>) {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
|
||||||
|
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
// Filter + search
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
let result = [...items]
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (search && searchFields.length > 0) {
|
||||||
|
const lowerSearch = search.toLowerCase()
|
||||||
|
result = result.filter(item =>
|
||||||
|
searchFields.some(field => {
|
||||||
|
const val = item[field]
|
||||||
|
return typeof val === 'string' && val.toLowerCase().includes(lowerSearch)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
for (const [key, value] of Object.entries(activeFilters)) {
|
||||||
|
if (value) {
|
||||||
|
result = result.filter(item => String(item[key]) === value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort
|
||||||
|
if (sortKey) {
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aVal = String(a[sortKey] ?? '')
|
||||||
|
const bVal = String(b[sortKey] ?? '')
|
||||||
|
const cmp = aVal.localeCompare(bVal)
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [items, search, searchFields, activeFilters, sortKey, sortDir])
|
||||||
|
|
||||||
|
const handleSort = (key: string) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDir('asc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<PageHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
icon={icon}
|
||||||
|
actions={
|
||||||
|
canAdd && onAdd ? (
|
||||||
|
<Button icon={Plus} onClick={onAdd}>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search & Filters Bar */}
|
||||||
|
<div className="bg-gray-900/80 backdrop-blur-lg rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg
|
||||||
|
placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
{filters.map(filter => (
|
||||||
|
<select
|
||||||
|
key={filter.filterKey}
|
||||||
|
value={activeFilters[filter.filterKey] || ''}
|
||||||
|
onChange={e => setActiveFilters(prev => ({ ...prev, [filter.filterKey]: e.target.value }))}
|
||||||
|
className="px-3 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg text-sm
|
||||||
|
focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">{filter.label}</option>
|
||||||
|
{filter.options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-gray-900 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
{EmptyIcon && (
|
||||||
|
<div className="bg-gray-800 rounded-full p-4 mb-4">
|
||||||
|
<EmptyIcon size={32} className="text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-medium text-gray-200 mb-2">{emptyTitle}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">{emptyDescription}</p>
|
||||||
|
{canAdd && onAdd && (
|
||||||
|
<Button icon={Plus} onClick={onAdd}>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-700">
|
||||||
|
<thead className="bg-gray-800/50">
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={`px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider ${
|
||||||
|
col.sortable ? 'cursor-pointer select-none hover:text-gray-200' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => col.sortable && handleSort(col.key)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{col.label}
|
||||||
|
{col.sortable && sortKey === col.key && (
|
||||||
|
sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
{filteredItems.map(item => (
|
||||||
|
<tr
|
||||||
|
key={getRowKey(item)}
|
||||||
|
onClick={() => onRowClick?.(item)}
|
||||||
|
className={`hover:bg-gray-800 transition-colors ${
|
||||||
|
onRowClick ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
|
{col.render
|
||||||
|
? col.render(item)
|
||||||
|
: String(item[col.key] ?? '—')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/components/ui/Badge.tsx
Normal file
23
src/components/ui/Badge.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
interface BadgeProps {
|
||||||
|
variant?: 'info' | 'success' | 'warning' | 'error' | 'neutral'
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeVariants = {
|
||||||
|
info: 'bg-cyan-500/20 text-cyan-400',
|
||||||
|
success: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
warning: 'bg-amber-500/20 text-amber-400',
|
||||||
|
error: 'bg-red-500/20 text-red-400',
|
||||||
|
neutral: 'bg-gray-500/20 text-gray-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({ variant = 'neutral', children, className = '' }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${badgeVariants[variant]} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
src/components/ui/Button.tsx
Normal file
53
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
import Spinner from './Spinner'
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
icon?: LucideIcon
|
||||||
|
loading?: boolean
|
||||||
|
as?: React.ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-cyan-500 hover:bg-cyan-600 text-white shadow-sm hover:shadow-md',
|
||||||
|
secondary: 'bg-gray-700 hover:bg-gray-600 text-gray-200 border border-gray-600',
|
||||||
|
danger: 'bg-red-500 hover:bg-red-600 text-white',
|
||||||
|
ghost: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
icon: Icon,
|
||||||
|
loading = false,
|
||||||
|
as: Component = 'button',
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg
|
||||||
|
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-gray-900
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16} />
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/ui/Input.tsx
Normal file
35
src/components/ui/Input.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, required, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-400 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`w-full px-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg
|
||||||
|
placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||||
|
error ? 'border-red-500 focus:ring-red-500' : ''
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
export default Input
|
||||||
48
src/components/ui/Select.tsx
Normal file
48
src/components/ui/Select.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
required?: boolean
|
||||||
|
options: { label: string; value: string }[]
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ label, error, required, options, placeholder, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-400 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={`w-full px-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 rounded-lg
|
||||||
|
focus:ring-2 focus:ring-cyan-500 focus:border-transparent
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||||
|
error ? 'border-red-500 focus:ring-red-500' : ''
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder && (
|
||||||
|
<option value="" className="text-gray-500">
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Select.displayName = 'Select'
|
||||||
|
export default Select
|
||||||
18
src/components/ui/Spinner.tsx
Normal file
18
src/components/ui/Spinner.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
interface SpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${className}`}>
|
||||||
|
<div className={`animate-spin rounded-full border-b-2 border-cyan-500 ${sizes[size]}`} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/contexts/AuthContext.tsx
Normal file
118
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import type { AuthUser } from '@/types/user'
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null
|
||||||
|
loading: boolean
|
||||||
|
login: (username: string, password: string) => Promise<{ requiresTwoFactor: boolean; error?: string }>
|
||||||
|
verify2FA: (code: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refreshUser: () => Promise<void>
|
||||||
|
hasPermission: (permission: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUser(data.data)
|
||||||
|
} else {
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshUser()
|
||||||
|
}, [refreshUser])
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { requiresTwoFactor: false, error: data.error || 'Login failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requiresTwoFactor: true }
|
||||||
|
} catch {
|
||||||
|
return { requiresTwoFactor: false, error: 'Network error' }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const verify2FA = useCallback(async (code: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/verify-2fa', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { success: false, error: data.error || 'Verification failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshUser()
|
||||||
|
return { success: true }
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: 'Network error' }
|
||||||
|
}
|
||||||
|
}, [refreshUser])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const hasPermission = useCallback((permission: string) => {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.permissions.includes('*:*')) return true
|
||||||
|
return user.permissions.includes(permission)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, verify2FA, logout, refreshUser, hasPermission }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
121
src/contexts/ConfirmationContext.tsx
Normal file
121
src/contexts/ConfirmationContext.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ConfirmationOptions {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
itemName?: string
|
||||||
|
type?: 'danger' | 'warning' | 'info'
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmationContextType {
|
||||||
|
showConfirmation: (options: ConfirmationOptions) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmationContext = createContext<ConfirmationContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
const typeStyles = {
|
||||||
|
danger: {
|
||||||
|
icon: 'bg-red-500/20 text-red-400',
|
||||||
|
button: 'bg-red-500 hover:bg-red-600 text-white',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: 'bg-amber-500/20 text-amber-400',
|
||||||
|
button: 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: 'bg-cyan-500/20 text-cyan-400',
|
||||||
|
button: 'bg-cyan-500 hover:bg-cyan-600 text-white',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [options, setOptions] = useState<ConfirmationOptions | null>(null)
|
||||||
|
const [resolver, setResolver] = useState<((value: boolean) => void) | null>(null)
|
||||||
|
|
||||||
|
const showConfirmation = useCallback((opts: ConfirmationOptions): Promise<boolean> => {
|
||||||
|
setOptions(opts)
|
||||||
|
setIsOpen(true)
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
setResolver(() => resolve)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
resolver?.(true)
|
||||||
|
setResolver(null)
|
||||||
|
}, [resolver])
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setIsOpen(false)
|
||||||
|
resolver?.(false)
|
||||||
|
setResolver(null)
|
||||||
|
}, [resolver])
|
||||||
|
|
||||||
|
const type = options?.type || 'danger'
|
||||||
|
const styles = typeStyles[type]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationContext.Provider value={{ showConfirmation }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
{isOpen && options && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={handleCancel} />
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="relative bg-gray-900 border border-gray-700 rounded-lg shadow-2xl p-6 max-w-md w-full mx-4 animate-scale-in">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className={`p-3 rounded-full ${styles.icon}`}>
|
||||||
|
<AlertTriangle size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-100">{options.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
|
{options.message}
|
||||||
|
{options.itemName && (
|
||||||
|
<span className="font-semibold text-gray-200"> {options.itemName}</span>
|
||||||
|
)}
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-lg transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
{options.cancelText || 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors text-sm font-medium ${styles.button}`}
|
||||||
|
>
|
||||||
|
{options.confirmText || 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfirmationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfirmation() {
|
||||||
|
const context = useContext(ConfirmationContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useConfirmation must be used within a ConfirmationProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
85
src/contexts/ToastContext.tsx
Normal file
85
src/contexts/ToastContext.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||||
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
type: ToastType
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
showToast: (message: string, type: ToastType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
const TOAST_DURATION = 5000
|
||||||
|
|
||||||
|
const toastStyles: Record<ToastType, string> = {
|
||||||
|
success: 'bg-emerald-500/20 border-emerald-500/50 text-emerald-400',
|
||||||
|
error: 'bg-red-500/20 border-red-500/50 text-red-400',
|
||||||
|
info: 'bg-cyan-500/20 border-cyan-500/50 text-cyan-400',
|
||||||
|
warning: 'bg-amber-500/20 border-amber-500/50 text-amber-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastIcons: Record<ToastType, typeof CheckCircle> = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: AlertCircle,
|
||||||
|
info: Info,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: ToastType) => {
|
||||||
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
setToasts(prev => [...prev, { id, message, type }])
|
||||||
|
|
||||||
|
setTimeout(() => removeToast(id), TOAST_DURATION)
|
||||||
|
}, [removeToast])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Toast Container */}
|
||||||
|
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||||
|
{toasts.map(toast => {
|
||||||
|
const Icon = toastIcons[toast.type]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-lg shadow-xl animate-slide-in-right ${toastStyles[toast.type]}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} className="flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
className="ml-2 opacity-60 hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
71
src/hooks/useConsole.ts
Normal file
71
src/hooks/useConsole.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
interface UseConsoleOptions {
|
||||||
|
maxLines?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for SSE console log streaming and command sending.
|
||||||
|
*/
|
||||||
|
export function useConsole(serverId: string, options: UseConsoleOptions = {}) {
|
||||||
|
const { maxLines = 500 } = options
|
||||||
|
const [lines, setLines] = useState<string[]>([])
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
|
||||||
|
// Connect to SSE stream
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(`/api/servers/${serverId}/console`)
|
||||||
|
eventSourceRef.current = eventSource
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setConnected(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
setLines(prev => [...prev.slice(-(maxLines - 1)), event.data])
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setConnected(false)
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
|
}, [serverId, maxLines])
|
||||||
|
|
||||||
|
// Send command
|
||||||
|
const sendCommand = useCallback(async (command: string) => {
|
||||||
|
if (!command.trim()) return
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}/console`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ command: command.trim() }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}, [serverId])
|
||||||
|
|
||||||
|
// Clear console
|
||||||
|
const clearConsole = useCallback(() => {
|
||||||
|
setLines([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { lines, connected, sending, sendCommand, clearConsole }
|
||||||
|
}
|
||||||
85
src/hooks/useServerStatus.ts
Normal file
85
src/hooks/useServerStatus.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import type { ServerStatus } from '@/types/server'
|
||||||
|
|
||||||
|
interface UseServerStatusOptions {
|
||||||
|
pollInterval?: number // ms, default 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerStatusData {
|
||||||
|
id: string
|
||||||
|
status: ServerStatus
|
||||||
|
players?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll server status at regular intervals.
|
||||||
|
*/
|
||||||
|
export function useServerStatus(serverId: string | null, options: UseServerStatusOptions = {}) {
|
||||||
|
const { pollInterval = 10000 } = options
|
||||||
|
const [status, setStatus] = useState<ServerStatus>('offline')
|
||||||
|
const [players, setPlayers] = useState<number>(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
if (!serverId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${serverId}`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setStatus(data.data.status)
|
||||||
|
setPlayers(data.data.onlinePlayers || 0)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail — will retry on next poll
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [serverId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus()
|
||||||
|
const interval = setInterval(fetchStatus, pollInterval)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchStatus, pollInterval])
|
||||||
|
|
||||||
|
return { status, players, loading, refresh: fetchStatus }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll all servers status for dashboard overview.
|
||||||
|
*/
|
||||||
|
export function useAllServersStatus(pollInterval = 15000) {
|
||||||
|
const [servers, setServers] = useState<ServerStatusData[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/servers', { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setServers(
|
||||||
|
data.data.map((s: Record<string, unknown>) => ({
|
||||||
|
id: s._id,
|
||||||
|
status: s.status,
|
||||||
|
players: s.onlinePlayers || 0,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll()
|
||||||
|
const interval = setInterval(fetchAll, pollInterval)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchAll, pollInterval])
|
||||||
|
|
||||||
|
return { servers, loading, refresh: fetchAll }
|
||||||
|
}
|
||||||
69
src/lib/audit.ts
Normal file
69
src/lib/audit.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import connectToDatabase from './mongodb'
|
||||||
|
import { AuditLog } from './models'
|
||||||
|
|
||||||
|
interface AuditLogParams {
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId?: string
|
||||||
|
entityName?: string
|
||||||
|
userId?: string
|
||||||
|
userName?: string
|
||||||
|
userEmail?: string
|
||||||
|
previousValues?: unknown
|
||||||
|
newValues?: unknown
|
||||||
|
changes?: Record<string, string> | null
|
||||||
|
clientIP: string
|
||||||
|
status: 'SUCCESS' | 'FAILED' | 'success' | 'failure'
|
||||||
|
statusCode: number
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an audit log entry. Always call after mutations (success or failure).
|
||||||
|
*/
|
||||||
|
export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
||||||
|
try {
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
// Normalize status to uppercase
|
||||||
|
const statusMap: Record<string, string> = { success: 'SUCCESS', failure: 'FAILED' }
|
||||||
|
const normalizedStatus = statusMap[params.status] || params.status.toUpperCase()
|
||||||
|
|
||||||
|
await AuditLog.create({
|
||||||
|
action: params.action,
|
||||||
|
entityType: params.entityType,
|
||||||
|
entityId: params.entityId || undefined,
|
||||||
|
entityName: params.entityName || undefined,
|
||||||
|
userId: params.userId || undefined,
|
||||||
|
userName: params.userName || undefined,
|
||||||
|
userEmail: params.userEmail || undefined,
|
||||||
|
previousValues: params.previousValues ?? null,
|
||||||
|
newValues: params.newValues ?? null,
|
||||||
|
changes: params.changes || null,
|
||||||
|
clientIP: params.clientIP,
|
||||||
|
status: normalizedStatus,
|
||||||
|
statusCode: params.statusCode,
|
||||||
|
errorMessage: params.errorMessage,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Audit] Failed to create audit log:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract client IP from the request headers.
|
||||||
|
*/
|
||||||
|
export function getClientIP(request: NextRequest): string {
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for')
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIP = request.headers.get('x-real-ip')
|
||||||
|
if (realIP) {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
|
||||||
|
return '127.0.0.1'
|
||||||
|
}
|
||||||
151
src/lib/auth.ts
Normal file
151
src/lib/auth.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import connectToDatabase from './mongodb'
|
||||||
|
import { User } from './models'
|
||||||
|
import type { AuthUser, SessionPayload } from '@/types/user'
|
||||||
|
|
||||||
|
// ─── Password Hashing ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hashedPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── JWT Tokens ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function generateAccessToken(payload: SessionPayload): string {
|
||||||
|
return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: '1h' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRefreshToken(payload: SessionPayload): string {
|
||||||
|
return jwt.sign(payload, process.env.JWT_REFRESH_SECRET!, { expiresIn: '7d' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAccessToken(token: string): SessionPayload | null {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, process.env.JWT_SECRET!) as SessionPayload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyRefreshToken(token: string): SessionPayload | null {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, process.env.JWT_REFRESH_SECRET!) as SessionPayload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2FA Code ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function generate2FACode(): string {
|
||||||
|
return crypto.randomInt(100000, 999999).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hash2FACode(code: string): string {
|
||||||
|
const hmac = crypto.createHmac('sha256', process.env.JWT_SECRET!)
|
||||||
|
hmac.update(code)
|
||||||
|
return hmac.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verify2FACode(inputCode: string, hashedCode: string): boolean {
|
||||||
|
const inputHash = hash2FACode(inputCode.trim())
|
||||||
|
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(inputHash, 'hex'),
|
||||||
|
Buffer.from(hashedCode, 'hex')
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session Validation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function validateSession(request: NextRequest): Promise<AuthUser | null> {
|
||||||
|
const token = request.cookies.get('session-token')?.value
|
||||||
|
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
|
const payload = verifyAccessToken(token)
|
||||||
|
if (!payload) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const user = await User.findById(payload.userId)
|
||||||
|
.populate('roles')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
if (!user || user.status !== 'active') return null
|
||||||
|
|
||||||
|
const permissions = new Set<string>()
|
||||||
|
const roleNames: string[] = []
|
||||||
|
|
||||||
|
if (Array.isArray(user.roles)) {
|
||||||
|
for (const role of user.roles) {
|
||||||
|
if (typeof role === 'object' && role !== null) {
|
||||||
|
const r = role as unknown as { name: string; permissions: { resource: string; actions: string[] }[] }
|
||||||
|
roleNames.push(r.name)
|
||||||
|
for (const perm of r.permissions) {
|
||||||
|
for (const action of perm.actions) {
|
||||||
|
permissions.add(`${perm.resource}:${action}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: user._id.toString(),
|
||||||
|
id: user._id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
permissions: Array.from(permissions),
|
||||||
|
roles: roleNames,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cookie Helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function setAuthCookies(response: NextResponse, accessToken: string, refreshToken: string): void {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
response.cookies.set('session-token', accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 3600, // 1 hour
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
response.cookies.set('refresh-token', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 7 * 24 * 3600, // 7 days
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCookies(response: NextResponse): void {
|
||||||
|
response.cookies.set('session-token', '', { maxAge: 0, path: '/' })
|
||||||
|
response.cookies.set('refresh-token', '', { maxAge: 0, path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Permission Check ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function hasPermission(user: AuthUser, permission: string): boolean {
|
||||||
|
if (user.permissions.includes('*:*')) return true
|
||||||
|
return user.permissions.includes(permission)
|
||||||
|
}
|
||||||
205
src/lib/backup-scheduler.ts
Normal file
205
src/lib/backup-scheduler.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import cron, { ScheduledTask } from 'node-cron'
|
||||||
|
import connectToDatabase from './mongodb'
|
||||||
|
import { Server, Backup } from './models'
|
||||||
|
import { getContainerById, getServerPath } from './docker'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
// In-memory map of active cron jobs
|
||||||
|
const scheduledJobs = new Map<string, ScheduledTask>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all backup schedules from the database on app startup.
|
||||||
|
*/
|
||||||
|
export async function initBackupScheduler(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const servers = await Server.find({
|
||||||
|
backupSchedule: { $ne: null },
|
||||||
|
status: { $ne: 'offline' },
|
||||||
|
}).lean()
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
if (server.backupSchedule && cron.validate(server.backupSchedule)) {
|
||||||
|
registerBackupJob(server._id.toString(), server.backupSchedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BackupScheduler] Initialized ${scheduledJobs.size} scheduled backup jobs`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackupScheduler] Failed to initialize:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a cron job for a server's backup schedule.
|
||||||
|
*/
|
||||||
|
export function registerBackupJob(serverId: string, cronExpression: string): void {
|
||||||
|
// Remove existing job if any
|
||||||
|
removeBackupJob(serverId)
|
||||||
|
|
||||||
|
if (!cron.validate(cronExpression)) {
|
||||||
|
console.error(`[BackupScheduler] Invalid cron expression for server ${serverId}: ${cronExpression}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = cron.schedule(cronExpression, async () => {
|
||||||
|
await runScheduledBackup(serverId)
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduledJobs.set(serverId, task)
|
||||||
|
console.log(`[BackupScheduler] Registered job for server ${serverId}: ${cronExpression}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a server's backup cron job.
|
||||||
|
*/
|
||||||
|
export function removeBackupJob(serverId: string): void {
|
||||||
|
const existing = scheduledJobs.get(serverId)
|
||||||
|
if (existing) {
|
||||||
|
existing.stop()
|
||||||
|
scheduledJobs.delete(serverId)
|
||||||
|
console.log(`[BackupScheduler] Removed job for server ${serverId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a server's backup schedule (remove old, register new).
|
||||||
|
*/
|
||||||
|
export function updateBackupJob(serverId: string, cronExpression: string | null): void {
|
||||||
|
if (cronExpression) {
|
||||||
|
registerBackupJob(serverId, cronExpression)
|
||||||
|
} else {
|
||||||
|
removeBackupJob(serverId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a scheduled backup for a server.
|
||||||
|
*/
|
||||||
|
async function runScheduledBackup(serverId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await connectToDatabase()
|
||||||
|
|
||||||
|
const server = await Server.findById(serverId)
|
||||||
|
if (!server || server.status !== 'online') {
|
||||||
|
console.log(`[BackupScheduler] Skipping backup for ${serverId} — server not online`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = getServerPath(serverId)
|
||||||
|
const backupsDir = path.join(serverPath, 'backups')
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const filename = `backup-${timestamp}.tar.gz`
|
||||||
|
const filePath = path.join(backupsDir, filename)
|
||||||
|
|
||||||
|
// Ensure backups directory exists
|
||||||
|
await fs.mkdir(backupsDir, { recursive: true })
|
||||||
|
|
||||||
|
// Create backup record
|
||||||
|
const backup = await Backup.create({
|
||||||
|
serverId: server._id,
|
||||||
|
filename,
|
||||||
|
filePath,
|
||||||
|
type: 'scheduled',
|
||||||
|
status: 'in_progress',
|
||||||
|
createdBy: server.createdBy,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pause world saving if container is running
|
||||||
|
const container = server.containerId ? await getContainerById(server.containerId) : null
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
const saveOffExec = await container.exec({
|
||||||
|
Cmd: ['rcon-cli', 'save-off'],
|
||||||
|
AttachStdout: true,
|
||||||
|
})
|
||||||
|
await saveOffExec.start({})
|
||||||
|
|
||||||
|
const saveAllExec = await container.exec({
|
||||||
|
Cmd: ['rcon-cli', 'save-all', 'flush'],
|
||||||
|
AttachStdout: true,
|
||||||
|
})
|
||||||
|
await saveAllExec.start({})
|
||||||
|
|
||||||
|
// Brief pause for save to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
} catch {
|
||||||
|
console.warn(`[BackupScheduler] Could not pause saves for ${serverId}, proceeding anyway`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tar the world directory
|
||||||
|
await execAsync(`tar -czf "${filePath}" -C "${serverPath}" world`)
|
||||||
|
|
||||||
|
// Resume world saving
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
const saveOnExec = await container.exec({
|
||||||
|
Cmd: ['rcon-cli', 'save-on'],
|
||||||
|
AttachStdout: true,
|
||||||
|
})
|
||||||
|
await saveOnExec.start({})
|
||||||
|
} catch {
|
||||||
|
console.warn(`[BackupScheduler] Could not resume saves for ${serverId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
const stats = await fs.stat(filePath)
|
||||||
|
|
||||||
|
// Update backup record
|
||||||
|
backup.fileSize = stats.size
|
||||||
|
backup.status = 'completed'
|
||||||
|
await backup.save()
|
||||||
|
|
||||||
|
console.log(`[BackupScheduler] Completed backup for ${serverId}: ${filename}`)
|
||||||
|
|
||||||
|
// Enforce retention policy
|
||||||
|
await enforceRetention(serverId, server.backupRetention)
|
||||||
|
} catch (error) {
|
||||||
|
backup.status = 'failed'
|
||||||
|
await backup.save()
|
||||||
|
console.error(`[BackupScheduler] Backup failed for ${serverId}:`, error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[BackupScheduler] Error running backup for ${serverId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete oldest backups that exceed the retention limit.
|
||||||
|
*/
|
||||||
|
async function enforceRetention(serverId: string, retention: number): Promise<void> {
|
||||||
|
const backups = await Backup.find({ serverId, status: 'completed' })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
if (backups.length <= retention) return
|
||||||
|
|
||||||
|
const toDelete = backups.slice(retention)
|
||||||
|
|
||||||
|
for (const backup of toDelete) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(backup.filePath)
|
||||||
|
} catch {
|
||||||
|
// File may already be deleted
|
||||||
|
}
|
||||||
|
await Backup.findByIdAndDelete(backup._id)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BackupScheduler] Enforced retention for ${serverId}: removed ${toDelete.length} old backups`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of active scheduled jobs.
|
||||||
|
*/
|
||||||
|
export function getActiveJobCount(): number {
|
||||||
|
return scheduledJobs.size
|
||||||
|
}
|
||||||
86
src/lib/date-utils.ts
Normal file
86
src/lib/date-utils.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Format date for display: "Jan 15, 2026"
|
||||||
|
*/
|
||||||
|
export function formatDate(date: string | Date | null | undefined): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return '—'
|
||||||
|
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date and time for display: "Jan 15, 2026 at 2:30 PM"
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: string | Date | null | undefined): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return '—'
|
||||||
|
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for HTML input[type="date"]: "2026-01-15"
|
||||||
|
*/
|
||||||
|
export function formatDateForInput(date: string | Date | null | undefined): string {
|
||||||
|
if (!date) return ''
|
||||||
|
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display: "1.5 GB", "256 MB", "12 KB"
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time: "2 hours ago", "just now"
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
||||||
|
if (!date) return '—'
|
||||||
|
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return '—'
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - d.getTime()
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000)
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return 'just now'
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`
|
||||||
|
|
||||||
|
return formatDate(date)
|
||||||
|
}
|
||||||
186
src/lib/docker.ts
Normal file
186
src/lib/docker.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import Docker from 'dockerode'
|
||||||
|
import { mkdir, chown } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
|
||||||
|
let dockerClient: Docker | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cached dockerode instance connected to the Docker Engine socket.
|
||||||
|
*/
|
||||||
|
export function getDockerClient(): Docker {
|
||||||
|
if (!dockerClient) {
|
||||||
|
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'
|
||||||
|
dockerClient = new Docker({ socketPath })
|
||||||
|
}
|
||||||
|
return dockerClient
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Docker container state to our ServerStatus.
|
||||||
|
*/
|
||||||
|
export function mapContainerState(state: string): 'online' | 'offline' | 'starting' | 'stopping' | 'crashed' {
|
||||||
|
switch (state) {
|
||||||
|
case 'running':
|
||||||
|
return 'online'
|
||||||
|
case 'created':
|
||||||
|
case 'restarting':
|
||||||
|
return 'starting'
|
||||||
|
case 'removing':
|
||||||
|
case 'paused':
|
||||||
|
return 'stopping'
|
||||||
|
case 'exited':
|
||||||
|
return 'offline'
|
||||||
|
case 'dead':
|
||||||
|
return 'crashed'
|
||||||
|
default:
|
||||||
|
return 'offline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the itzg/minecraft-server TYPE env var from our server.type.
|
||||||
|
*/
|
||||||
|
export function getItzgServerType(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'vanilla':
|
||||||
|
return 'VANILLA'
|
||||||
|
case 'bukkit':
|
||||||
|
return 'PAPER' // Default bukkit to Paper — override if needed
|
||||||
|
case 'forge':
|
||||||
|
return 'FORGE'
|
||||||
|
case 'fabric':
|
||||||
|
return 'FABRIC'
|
||||||
|
default:
|
||||||
|
return 'VANILLA'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateContainerOptions {
|
||||||
|
containerName: string
|
||||||
|
dockerImage: string
|
||||||
|
serverType: string
|
||||||
|
version: string
|
||||||
|
port: number
|
||||||
|
rconPort: number | null
|
||||||
|
memoryMax: number
|
||||||
|
memoryMin: number
|
||||||
|
jvmArgs: string[]
|
||||||
|
autoRestart: boolean
|
||||||
|
serverPath: string
|
||||||
|
maxPlayers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Docker container for a Minecraft server.
|
||||||
|
*/
|
||||||
|
export async function createServerContainer(options: CreateContainerOptions): Promise<Docker.Container> {
|
||||||
|
const docker = getDockerClient()
|
||||||
|
const isItzg = options.dockerImage === 'itzg/minecraft-server'
|
||||||
|
|
||||||
|
const env: string[] = [
|
||||||
|
`EULA=TRUE`,
|
||||||
|
`MAX_PLAYERS=${options.maxPlayers}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Only set itzg-specific env vars for the default image
|
||||||
|
if (isItzg) {
|
||||||
|
env.push(
|
||||||
|
`TYPE=${getItzgServerType(options.serverType)}`,
|
||||||
|
`VERSION=${options.version}`,
|
||||||
|
`MEMORY=${options.memoryMax}M`,
|
||||||
|
`JVM_XX_OPTS=${options.jvmArgs.join(' ')}`,
|
||||||
|
'CREATE_CONSOLE_IN_PIPE=true',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const portBindings: Docker.PortMap = {
|
||||||
|
'25565/tcp': [{ HostPort: options.port.toString() }],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.rconPort) {
|
||||||
|
portBindings['25575/tcp'] = [{ HostPort: options.rconPort.toString() }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const exposedPorts: Record<string, object> = { '25565/tcp': {} }
|
||||||
|
if (options.rconPort) {
|
||||||
|
exposedPorts['25575/tcp'] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await docker.createContainer({
|
||||||
|
name: options.containerName,
|
||||||
|
Image: options.dockerImage,
|
||||||
|
Env: env,
|
||||||
|
ExposedPorts: exposedPorts,
|
||||||
|
HostConfig: {
|
||||||
|
PortBindings: portBindings,
|
||||||
|
Binds: [`${options.serverPath}:/data:Z`],
|
||||||
|
Memory: options.memoryMax * 1024 * 1024,
|
||||||
|
RestartPolicy: {
|
||||||
|
Name: options.autoRestart ? 'unless-stopped' : 'no',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the server data directory exists with correct ownership (uid=1000)
|
||||||
|
* for the itzg/minecraft-server container.
|
||||||
|
*/
|
||||||
|
export async function ensureServerDirectory(serverPath: string): Promise<void> {
|
||||||
|
if (!existsSync(serverPath)) {
|
||||||
|
await mkdir(serverPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// itzg/minecraft-server runs as uid=1000 gid=1000
|
||||||
|
try {
|
||||||
|
await chown(serverPath, 1000, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Docker] Could not chown ${serverPath} to 1000:1000 — container may have permission issues:`, (err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a container by name. Returns null if not found.
|
||||||
|
*/
|
||||||
|
export async function getContainerByName(name: string): Promise<Docker.Container | null> {
|
||||||
|
const docker = getDockerClient()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(name)
|
||||||
|
await container.inspect() // Verify it exists
|
||||||
|
return container
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a container by ID. Returns null if not found.
|
||||||
|
*/
|
||||||
|
export async function getContainerById(id: string): Promise<Docker.Container | null> {
|
||||||
|
const docker = getDockerClient()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = docker.getContainer(id)
|
||||||
|
await container.inspect()
|
||||||
|
return container
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base server path for volumes.
|
||||||
|
*/
|
||||||
|
export function getServersBasePath(): string {
|
||||||
|
return process.env.MC_SERVERS_PATH || '/opt/mc-servers'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the full path for a specific server's data directory.
|
||||||
|
*/
|
||||||
|
export function getServerPath(serverId: string): string {
|
||||||
|
return `${getServersBasePath()}/${serverId}`
|
||||||
|
}
|
||||||
87
src/lib/email-graph.ts
Normal file
87
src/lib/email-graph.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { ConfidentialClientApplication } from '@azure/msal-node'
|
||||||
|
import { Client } from '@microsoft/microsoft-graph-client'
|
||||||
|
|
||||||
|
let msalClient: ConfidentialClientApplication | null = null
|
||||||
|
let graphClient: Client | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the MSAL confidential client.
|
||||||
|
*/
|
||||||
|
function getMsalClient(): ConfidentialClientApplication {
|
||||||
|
if (!msalClient) {
|
||||||
|
const clientId = process.env.AZURE_CLIENT_ID
|
||||||
|
const clientSecret = process.env.AZURE_CLIENT_SECRET
|
||||||
|
const tenantId = process.env.AZURE_TENANT_ID
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !tenantId) {
|
||||||
|
throw new Error('Azure credentials not configured (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)')
|
||||||
|
}
|
||||||
|
|
||||||
|
msalClient = new ConfidentialClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
authority: `https://login.microsoftonline.com/${tenantId}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return msalClient
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an authenticated Microsoft Graph client.
|
||||||
|
*/
|
||||||
|
async function getGraphClient(): Promise<Client> {
|
||||||
|
const msal = getMsalClient()
|
||||||
|
|
||||||
|
const tokenResponse = await msal.acquireTokenByClientCredential({
|
||||||
|
scopes: ['https://graph.microsoft.com/.default'],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tokenResponse?.accessToken) {
|
||||||
|
throw new Error('Failed to acquire Graph API access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
graphClient = Client.init({
|
||||||
|
authProvider: (done) => {
|
||||||
|
done(null, tokenResponse.accessToken)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return graphClient
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an email via Microsoft Graph API.
|
||||||
|
*/
|
||||||
|
export async function sendGraphEmail(
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
htmlContent: string
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await getGraphClient()
|
||||||
|
const sender = process.env.EMAIL_USER
|
||||||
|
|
||||||
|
if (!sender) {
|
||||||
|
throw new Error('EMAIL_USER not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.api(`/users/${sender}/sendMail`).post({
|
||||||
|
message: {
|
||||||
|
subject,
|
||||||
|
body: {
|
||||||
|
contentType: 'HTML',
|
||||||
|
content: htmlContent,
|
||||||
|
},
|
||||||
|
toRecipients: [
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: to,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
saveToSentItems: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
109
src/lib/email.ts
Normal file
109
src/lib/email.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import { sendGraphEmail } from './email-graph'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the configured email provider.
|
||||||
|
* Returns 'none' if neither Graph nor SMTP credentials are set.
|
||||||
|
*/
|
||||||
|
function getEmailProvider(): 'graph' | 'smtp' | 'none' {
|
||||||
|
if (process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_TENANT_ID) {
|
||||||
|
return 'graph'
|
||||||
|
}
|
||||||
|
if (process.env.EMAIL_USER && process.env.EMAIL_PASS) {
|
||||||
|
return 'smtp'
|
||||||
|
}
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email via SMTP (nodemailer).
|
||||||
|
*/
|
||||||
|
async function sendSmtpEmail(to: string, subject: string, html: string): Promise<void> {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.EMAIL_HOST || 'smtp.office365.com',
|
||||||
|
port: Number(process.env.EMAIL_PORT) || 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.EMAIL_USER,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email with automatic fallback: Graph API → SMTP.
|
||||||
|
* Always use this function for sending emails.
|
||||||
|
*/
|
||||||
|
export async function sendEmail(to: string, subject: string, html: string): Promise<void> {
|
||||||
|
const provider = getEmailProvider()
|
||||||
|
|
||||||
|
if (provider === 'none') {
|
||||||
|
console.warn('[Email] No email provider configured (Graph API or SMTP). Email not sent.')
|
||||||
|
console.warn(`[Email] To: ${to} | Subject: ${subject}`)
|
||||||
|
throw new Error('No email provider configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'graph') {
|
||||||
|
try {
|
||||||
|
await sendGraphEmail(to, subject, html)
|
||||||
|
console.log(`[Email] Sent via Graph API to ${to}`)
|
||||||
|
return
|
||||||
|
} catch (graphError) {
|
||||||
|
console.error('[Email] Graph API failed, trying SMTP fallback:', (graphError as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendSmtpEmail(to, subject, html)
|
||||||
|
console.log(`[Email] Sent via SMTP to ${to}`)
|
||||||
|
} catch (smtpError) {
|
||||||
|
console.error('[Email] All providers failed:', (smtpError as Error).message)
|
||||||
|
throw new Error('Failed to send email via all providers')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a 2FA verification code email.
|
||||||
|
*/
|
||||||
|
export async function send2FAEmail(to: string, code: string, username: string): Promise<void> {
|
||||||
|
const provider = getEmailProvider()
|
||||||
|
|
||||||
|
if (provider === 'none') {
|
||||||
|
console.log('')
|
||||||
|
console.log('╔══════════════════════════════════════════════════════╗')
|
||||||
|
console.log('║ MC-Manager — 2FA Verification Code ║')
|
||||||
|
console.log('╠══════════════════════════════════════════════════════╣')
|
||||||
|
console.log(`║ User: ${username.padEnd(44)}║`)
|
||||||
|
console.log(`║ Email: ${to.padEnd(44)}║`)
|
||||||
|
console.log(`║ Code: ${code.padEnd(44)}║`)
|
||||||
|
console.log('╠══════════════════════════════════════════════════════╣')
|
||||||
|
console.log('║ ⚠ No email provider configured (Graph/SMTP) ║')
|
||||||
|
console.log('║ Configure AZURE_* or EMAIL_* env vars for email. ║')
|
||||||
|
console.log('╚══════════════════════════════════════════════════════╝')
|
||||||
|
console.log('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = 'MC-Manager - Verification Code'
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 24px;">
|
||||||
|
<h2 style="color: #06b6d4; margin-bottom: 16px;">MC-Manager</h2>
|
||||||
|
<p style="color: #374151; font-size: 16px;">Hi ${username},</p>
|
||||||
|
<p style="color: #374151; font-size: 16px;">Your verification code is:</p>
|
||||||
|
<div style="background: #f3f4f6; border-radius: 8px; padding: 20px; text-align: center; margin: 24px 0;">
|
||||||
|
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #111827;">${code}</span>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">This code expires in 5 minutes.</p>
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">If you didn't request this, please ignore this email.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
await sendEmail(to, subject, html)
|
||||||
|
}
|
||||||
69
src/lib/input-validation.ts
Normal file
69
src/lib/input-validation.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Sanitize a string: remove null bytes, control characters, trim, limit length.
|
||||||
|
*/
|
||||||
|
export function sanitizeString(input: string, maxLength = 10000): string {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '')
|
||||||
|
.substring(0, maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggressively sanitize HTML content.
|
||||||
|
*/
|
||||||
|
export function sanitizeHtml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/data:text\/html/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sanitize all string values in an object.
|
||||||
|
*/
|
||||||
|
export function sanitizeObject<T>(obj: T): T {
|
||||||
|
if (typeof obj === 'string') return sanitizeString(obj) as T
|
||||||
|
if (Array.isArray(obj)) return obj.map(sanitizeObject) as T
|
||||||
|
if (obj !== null && typeof obj === 'object') {
|
||||||
|
const sanitized: Record<string, unknown> = {}
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
sanitized[key] = sanitizeObject((obj as Record<string, unknown>)[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitized as T
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a MongoDB ObjectId format.
|
||||||
|
*/
|
||||||
|
export function isValidObjectId(id: string): boolean {
|
||||||
|
return /^[0-9a-fA-F]{24}$/.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an email address format.
|
||||||
|
*/
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
return emailRegex.test(email) && email.length <= 254
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a cron expression (basic check — node-cron validates further).
|
||||||
|
*/
|
||||||
|
export function isValidCron(expression: string): boolean {
|
||||||
|
const parts = expression.trim().split(/\s+/)
|
||||||
|
return parts.length === 5 || parts.length === 6
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a port number.
|
||||||
|
*/
|
||||||
|
export function isValidPort(port: number): boolean {
|
||||||
|
return Number.isInteger(port) && port >= 1024 && port <= 65535
|
||||||
|
}
|
||||||
197
src/lib/models.ts
Normal file
197
src/lib/models.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import mongoose, { Schema, Document, Model } from 'mongoose'
|
||||||
|
|
||||||
|
// ─── User ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IUser extends Document {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
passwordHash: string
|
||||||
|
roles: mongoose.Types.ObjectId[]
|
||||||
|
twoFactorCode: string | null
|
||||||
|
twoFactorExpiry: Date | null
|
||||||
|
loginAttempts: number
|
||||||
|
lockUntil: Date | null
|
||||||
|
status: 'active' | 'inactive' | 'locked'
|
||||||
|
lastLogin: Date | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSchema = new Schema<IUser>(
|
||||||
|
{
|
||||||
|
username: { type: String, required: true, unique: true, trim: true },
|
||||||
|
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||||
|
passwordHash: { type: String, required: true },
|
||||||
|
roles: [{ type: Schema.Types.ObjectId, ref: 'Role' }],
|
||||||
|
twoFactorCode: { type: String, default: null },
|
||||||
|
twoFactorExpiry: { type: Date, default: null },
|
||||||
|
loginAttempts: { type: Number, default: 0 },
|
||||||
|
lockUntil: { type: Date, default: null },
|
||||||
|
status: { type: String, enum: ['active', 'inactive', 'locked'], default: 'active' },
|
||||||
|
lastLogin: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Role ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IPermission {
|
||||||
|
resource: string
|
||||||
|
actions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRole extends Document {
|
||||||
|
name: string
|
||||||
|
permissions: IPermission[]
|
||||||
|
description: string
|
||||||
|
isDefault: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleSchema = new Schema<IRole>(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, unique: true, trim: true },
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
resource: { type: String, required: true },
|
||||||
|
actions: [{ type: String, required: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
isDefault: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Server ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IServer extends Document {
|
||||||
|
name: string
|
||||||
|
type: 'vanilla' | 'bukkit' | 'forge' | 'fabric'
|
||||||
|
version: string
|
||||||
|
dockerImage: string
|
||||||
|
containerId: string | null
|
||||||
|
containerName: string
|
||||||
|
port: number
|
||||||
|
rconPort: number | null
|
||||||
|
status: 'online' | 'offline' | 'starting' | 'stopping' | 'crashed'
|
||||||
|
maxPlayers: number
|
||||||
|
memory: { min: number; max: number }
|
||||||
|
jvmArgs: string[]
|
||||||
|
autoStart: boolean
|
||||||
|
autoRestart: boolean
|
||||||
|
backupSchedule: string | null
|
||||||
|
backupRetention: number
|
||||||
|
createdBy: mongoose.Types.ObjectId
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverSchema = new Schema<IServer>(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true, trim: true },
|
||||||
|
type: { type: String, required: true, enum: ['vanilla', 'bukkit', 'forge', 'fabric'] },
|
||||||
|
version: { type: String, required: true },
|
||||||
|
dockerImage: { type: String, default: 'itzg/minecraft-server' },
|
||||||
|
containerId: { type: String, default: null },
|
||||||
|
containerName: { type: String, required: true, unique: true },
|
||||||
|
port: { type: Number, required: true },
|
||||||
|
rconPort: { type: Number, default: null },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['online', 'offline', 'starting', 'stopping', 'crashed'],
|
||||||
|
default: 'offline',
|
||||||
|
},
|
||||||
|
maxPlayers: { type: Number, default: 20 },
|
||||||
|
memory: {
|
||||||
|
min: { type: Number, default: 512 },
|
||||||
|
max: { type: Number, default: 1024 },
|
||||||
|
},
|
||||||
|
jvmArgs: [{ type: String }],
|
||||||
|
autoStart: { type: Boolean, default: false },
|
||||||
|
autoRestart: { type: Boolean, default: true },
|
||||||
|
backupSchedule: { type: String, default: null },
|
||||||
|
backupRetention: { type: Number, default: 5 },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Backup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IBackup extends Document {
|
||||||
|
serverId: mongoose.Types.ObjectId
|
||||||
|
filename: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
type: 'manual' | 'scheduled'
|
||||||
|
status: 'completed' | 'in_progress' | 'failed'
|
||||||
|
createdBy: mongoose.Types.ObjectId
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupSchema = new Schema<IBackup>(
|
||||||
|
{
|
||||||
|
serverId: { type: Schema.Types.ObjectId, ref: 'Server', required: true, index: true },
|
||||||
|
filename: { type: String, required: true },
|
||||||
|
filePath: { type: String, required: true },
|
||||||
|
fileSize: { type: Number, default: 0 },
|
||||||
|
type: { type: String, enum: ['manual', 'scheduled'], required: true },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['completed', 'in_progress', 'failed'],
|
||||||
|
default: 'in_progress',
|
||||||
|
},
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Audit Log ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IAuditLog extends Document {
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId?: string
|
||||||
|
entityName?: string
|
||||||
|
userId?: string
|
||||||
|
userName?: string
|
||||||
|
userEmail?: string
|
||||||
|
previousValues: Record<string, unknown> | null
|
||||||
|
newValues: Record<string, unknown> | null
|
||||||
|
changes: Record<string, string> | null
|
||||||
|
clientIP: string
|
||||||
|
status: 'SUCCESS' | 'FAILED'
|
||||||
|
statusCode: number
|
||||||
|
errorMessage?: string
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLogSchema = new Schema<IAuditLog>(
|
||||||
|
{
|
||||||
|
action: { type: String, required: true, index: true },
|
||||||
|
entityType: { type: String, required: true, index: true },
|
||||||
|
entityId: { type: String, default: '' },
|
||||||
|
entityName: { type: String, default: '' },
|
||||||
|
userId: { type: String, default: '', index: true },
|
||||||
|
userName: { type: String, default: '' },
|
||||||
|
userEmail: { type: String, default: '' },
|
||||||
|
previousValues: { type: Schema.Types.Mixed, default: null },
|
||||||
|
newValues: { type: Schema.Types.Mixed, default: null },
|
||||||
|
changes: { type: Schema.Types.Mixed, default: null },
|
||||||
|
clientIP: { type: String, default: '' },
|
||||||
|
status: { type: String, enum: ['SUCCESS', 'FAILED'], required: true },
|
||||||
|
statusCode: { type: Number, required: true },
|
||||||
|
errorMessage: { type: String },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Model Exports (prevent re-compilation in dev) ───────────────
|
||||||
|
|
||||||
|
export const User: Model<IUser> = mongoose.models.User || mongoose.model<IUser>('User', userSchema)
|
||||||
|
export const Role: Model<IRole> = mongoose.models.Role || mongoose.model<IRole>('Role', roleSchema)
|
||||||
|
export const Server: Model<IServer> = mongoose.models.Server || mongoose.model<IServer>('Server', serverSchema)
|
||||||
|
export const Backup: Model<IBackup> = mongoose.models.Backup || mongoose.model<IBackup>('Backup', backupSchema)
|
||||||
|
export const AuditLog: Model<IAuditLog> = mongoose.models.AuditLog || mongoose.model<IAuditLog>('AuditLog', auditLogSchema)
|
||||||
43
src/lib/mongodb.ts
Normal file
43
src/lib/mongodb.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
interface MongooseCache {
|
||||||
|
conn: typeof mongoose | null
|
||||||
|
promise: Promise<typeof mongoose> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var mongoose: MongooseCache | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached: MongooseCache = global.mongoose || { conn: null, promise: null }
|
||||||
|
|
||||||
|
if (!global.mongoose) {
|
||||||
|
global.mongoose = cached
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function connectToDatabase(): Promise<typeof mongoose> {
|
||||||
|
if (cached.conn) {
|
||||||
|
return cached.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached.promise) {
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI
|
||||||
|
|
||||||
|
if (!MONGODB_URI) {
|
||||||
|
throw new Error('Please define the MONGODB_URI environment variable')
|
||||||
|
}
|
||||||
|
|
||||||
|
cached.promise = mongoose.connect(MONGODB_URI, {
|
||||||
|
bufferCommands: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cached.conn = await cached.promise
|
||||||
|
} catch (e) {
|
||||||
|
cached.promise = null
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.conn
|
||||||
|
}
|
||||||
66
src/middleware.ts
Normal file
66
src/middleware.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const publicRoutes = ['/login', '/api/auth/login', '/api/auth/verify-2fa', '/api/health']
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Allow public routes
|
||||||
|
if (publicRoutes.some(route => pathname.startsWith(route))) {
|
||||||
|
return addSecurityHeaders(NextResponse.next())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow static assets
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.startsWith('/favicon') ||
|
||||||
|
pathname.includes('.')
|
||||||
|
) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session token
|
||||||
|
const sessionToken = request.cookies.get('session-token')?.value
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
// API routes return 401
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages redirect to login
|
||||||
|
const loginUrl = new URL('/login', request.url)
|
||||||
|
loginUrl.searchParams.set('redirect', pathname)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS enforcement in production
|
||||||
|
if (process.env.NODE_ENV === 'production' && pathname.startsWith('/api/')) {
|
||||||
|
const origin = request.headers.get('origin')
|
||||||
|
const allowedOrigins = [
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL,
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
if (origin && !allowedOrigins.includes(origin)) {
|
||||||
|
return NextResponse.json({ error: 'CORS: Origin not allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addSecurityHeaders(NextResponse.next())
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSecurityHeaders(response: NextResponse): NextResponse {
|
||||||
|
response.headers.set('X-Frame-Options', 'DENY')
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||||
|
response.headers.set('X-XSS-Protection', '1; mode=block')
|
||||||
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
response.headers.set(
|
||||||
|
'Permissions-Policy',
|
||||||
|
'geolocation=(), microphone=(), camera=()'
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
}
|
||||||
15
src/types/backup.ts
Normal file
15
src/types/backup.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export type BackupType = 'manual' | 'scheduled'
|
||||||
|
|
||||||
|
export type BackupStatus = 'completed' | 'in_progress' | 'failed'
|
||||||
|
|
||||||
|
export interface Backup {
|
||||||
|
_id: string
|
||||||
|
serverId: string
|
||||||
|
filename: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
type: BackupType
|
||||||
|
status: BackupStatus
|
||||||
|
createdBy: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
37
src/types/index.ts
Normal file
37
src/types/index.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export * from './user'
|
||||||
|
export * from './server'
|
||||||
|
export * from './backup'
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
_id: string
|
||||||
|
action: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
userEmail: string
|
||||||
|
previousValues: Record<string, unknown> | null
|
||||||
|
newValues: Record<string, unknown> | null
|
||||||
|
changes: Record<string, string> | null
|
||||||
|
clientIP: string
|
||||||
|
status: 'SUCCESS' | 'FAILED'
|
||||||
|
statusCode: number
|
||||||
|
errorMessage?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
66
src/types/server.ts
Normal file
66
src/types/server.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
export type ServerType = 'vanilla' | 'bukkit' | 'forge' | 'fabric'
|
||||||
|
|
||||||
|
export type ServerStatus = 'online' | 'offline' | 'starting' | 'stopping' | 'crashed'
|
||||||
|
|
||||||
|
export interface ServerMemory {
|
||||||
|
min: number // MB
|
||||||
|
max: number // MB
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Server {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
type: ServerType
|
||||||
|
version: string
|
||||||
|
dockerImage: string
|
||||||
|
containerId: string | null
|
||||||
|
containerName: string
|
||||||
|
port: number
|
||||||
|
rconPort: number | null
|
||||||
|
status: ServerStatus
|
||||||
|
maxPlayers: number
|
||||||
|
memory: ServerMemory
|
||||||
|
jvmArgs: string[]
|
||||||
|
autoStart: boolean
|
||||||
|
autoRestart: boolean
|
||||||
|
backupSchedule: string | null
|
||||||
|
backupRetention: number
|
||||||
|
createdBy: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerFormData {
|
||||||
|
name: string
|
||||||
|
type: ServerType
|
||||||
|
version: string
|
||||||
|
dockerImage?: string
|
||||||
|
port: number
|
||||||
|
rconPort?: number
|
||||||
|
maxPlayers: number
|
||||||
|
memory: ServerMemory
|
||||||
|
jvmArgs?: string[]
|
||||||
|
autoStart?: boolean
|
||||||
|
autoRestart?: boolean
|
||||||
|
backupSchedule?: string | null
|
||||||
|
backupRetention?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlinePlayer {
|
||||||
|
name: string
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the server type supports plugin management (bukkit-based).
|
||||||
|
*/
|
||||||
|
export function supportsPlugins(type: ServerType): boolean {
|
||||||
|
return type === 'bukkit'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the server type supports mod management (forge/fabric).
|
||||||
|
*/
|
||||||
|
export function supportsMods(type: ServerType): boolean {
|
||||||
|
return type === 'forge' || type === 'fabric'
|
||||||
|
}
|
||||||
42
src/types/user.ts
Normal file
42
src/types/user.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
export interface Permission {
|
||||||
|
resource: string
|
||||||
|
actions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
_id: string
|
||||||
|
name: string
|
||||||
|
permissions: Permission[]
|
||||||
|
description: string
|
||||||
|
isDefault: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
_id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
roles: Role[] | string[]
|
||||||
|
status: 'active' | 'inactive' | 'locked'
|
||||||
|
lastLogin: string | null
|
||||||
|
loginAttempts: number
|
||||||
|
lockUntil: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
_id: string
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
permissions: string[]
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionPayload {
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user