From 026a5581950f3357956efa46dfd51b95da465dbc Mon Sep 17 00:00:00 2001 From: rmoren97 Date: Fri, 13 Feb 2026 15:16:54 -0800 Subject: [PATCH] updates --- .claude/CLAUDE.md | 409 ++++++++++++++++++ src/app/(app)/servers/[id]/admins/page.tsx | 212 +++++++++ src/app/(app)/servers/[id]/page.tsx | 17 +- .../api/servers/[id]/admins/[userId]/route.ts | 68 +++ src/app/api/servers/[id]/admins/route.ts | 115 +++++ .../[id]/backups/[backupId]/restore/route.ts | 11 +- .../servers/[id]/backups/[backupId]/route.ts | 18 +- src/app/api/servers/[id]/backups/route.ts | 25 +- .../api/servers/[id]/configuration/route.ts | 20 +- src/app/api/servers/[id]/console/route.ts | 20 +- src/app/api/servers/[id]/files/route.ts | 38 +- src/app/api/servers/[id]/logs/route.ts | 11 +- .../api/servers/[id]/mods/[filename]/route.ts | 11 +- .../[id]/mods/[filename]/toggle/route.ts | 11 +- src/app/api/servers/[id]/mods/route.ts | 20 +- .../[id]/players/[name]/[action]/route.ts | 11 +- src/app/api/servers/[id]/players/route.ts | 11 +- .../servers/[id]/plugins/[filename]/route.ts | 11 +- .../[id]/plugins/[filename]/toggle/route.ts | 11 +- src/app/api/servers/[id]/plugins/route.ts | 20 +- src/app/api/servers/[id]/restart/route.ts | 11 +- src/app/api/servers/[id]/route.ts | 20 +- src/app/api/servers/[id]/start/route.ts | 11 +- src/app/api/servers/[id]/stats/route.ts | 11 +- src/app/api/servers/[id]/stop/route.ts | 11 +- src/app/api/servers/route.ts | 17 +- src/lib/auth.ts | 15 + src/lib/models.ts | 2 + src/types/server.ts | 1 + 29 files changed, 1021 insertions(+), 148 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 src/app/(app)/servers/[id]/admins/page.tsx create mode 100644 src/app/api/servers/[id]/admins/[userId]/route.ts create mode 100644 src/app/api/servers/[id]/admins/route.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..4b30af6 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,409 @@ +# MC-Manager — Claude Code Instructions + +## Project Overview +Minecraft Server Manager — full-stack Next.js 16 (App Router) web app for managing Minecraft server instances. Each server runs in its own Docker container. + +## Commands +```bash +npm run dev # Dev server (port 3000, Turbopack) +npm run build # Production build +npm run lint # ESLint +npm run seed # Seed DB with default roles + admin user (tsx scripts/seed.ts) +``` + +## Actual Tech Stack (exact versions matter) +- **Framework:** Next.js 16.1.6, React 19.2.3, TypeScript 5 (strict) +- **Styling:** Tailwind CSS 4 — uses `@import "tailwindcss"` syntax, NOT `@tailwind base/components/utilities` +- **Database:** MongoDB via Mongoose 9.1.6 — cached connection in `src/lib/mongodb.ts` +- **Auth:** JWT dual-token (1h access + 7d refresh) in HTTP-only cookies; mandatory 2FA via email; bcryptjs (12 rounds) +- **Containers:** dockerode 4.0.9 — Docker Engine API via Unix socket +- **Scheduling:** node-cron 4.2.1 — runs inside Next.js process (no external scheduler) +- **Icons:** Lucide React only — never use any other icon library +- **Email:** Microsoft Graph API primary (`src/lib/email-graph.ts`), SMTP/nodemailer fallback — always dual-provider + +## Project Structure +``` +src/app/ — Pages + API routes (App Router) +src/components/ — Shared UI; templates/DataManagementTemplate for CRUD pages +src/contexts/ — AuthContext, ToastContext, ConfirmationContext +src/hooks/ — useConsole, useServerStatus, useAuth, useToast, useConfirmation +src/lib/ — Server-only utilities (never import in client components): + auth.ts — validateSession, generateAccessToken/RefreshToken, verifyPassword, + hashPassword, hasPermission, setAuthCookies, clearAuthCookies, + generate2FACode, hash2FACode, verify2FACode, send2FAEmail, sendEmail + email-graph.ts — getMsalClient, getGraphClient, sendGraphEmail (Azure MSAL) + mongodb.ts — connectToDatabase() (cached Mongoose connection) + models.ts — User, Role, Server, Backup, AuditLog (Mongoose schemas + TS interfaces) + docker.ts — getDockerClient, createServerContainer, ensureServerDirectory, + getContainerByName, getContainerById, mapContainerState, + getItzgServerType, getServersBasePath, getServerPath + backup-scheduler.ts — initBackupScheduler, registerBackupJob, removeBackupJob, updateBackupJob + audit.ts — createAuditLog, getClientIP + input-validation.ts — sanitizeString, sanitizeHtml, sanitizeObject, isValidObjectId, + isValidEmail, isValidCron, isValidPort + date-utils.ts — formatDate, formatDateTime, formatDateForInput, formatFileSize, + formatRelativeTime +src/types/ — server.ts (Server, ServerType, supportsPlugins, supportsMods), + user.ts (User, AuthUser, Role, Permission, SessionPayload), + backup.ts (Backup, BackupType, BackupStatus) +src/middleware.ts — Next.js middleware +``` + +## Critical Conventions + +### Pages +- Every page MUST start with `'use client'` +- Add `export const dynamic = 'force-dynamic'` only if the page uses `useSearchParams()` +- Never use raw `` — always `next/image` +- Allowed remote image domains: `mc-heads.net`, `crafatar.com` + +### Dates & Sizes — NEVER create custom formatters +Always import from `@/lib/date-utils`: +```ts +formatDate(date) // "Jan 15, 2026" +formatDateTime(date) // "Jan 15, 2026 at 2:30 PM" +formatDateForInput(date) // "2026-01-15" (for ) +formatFileSize(bytes) // "1.5 GB", "256 MB", "12 KB" +formatRelativeTime(date) // "2 hours ago", "just now" +``` + +### CRUD Pages +Use `DataManagementTemplate` from `@/components/templates/` — provides search, filter, sort, table, empty state, and create button. Never build custom data tables from scratch. + +### Drawers over Modals for detail views +Drawers use `max-w-3xl animate-slide-in-right`. Modals (`Modal.tsx`) are for creation/confirmation. + +### Import Order (enforce this order) +1. React hooks +2. Next.js hooks (`useRouter`, `useSearchParams`, etc.) +3. Contexts (`useAuth`, `useToast`, `useConfirmation`) +4. Lib utilities +5. Components + +### Server Type Helpers (from `@/types/server`) +```ts +supportsPlugins(type: ServerType): boolean // true only for 'bukkit' +supportsMods(type: ServerType): boolean // true for 'forge' | 'fabric' +``` +Always gate plugin/mod UI and API logic behind these — never hardcode type checks. + +## API Route Pattern (enforce this exact order) + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { validateSession, hasPermission } from '@/lib/auth' +import { getClientIP, createAuditLog } from '@/lib/audit' +import { sanitizeObject } from '@/lib/input-validation' +import { isValidObjectId } from '@/lib/input-validation' +import { connectToDatabase } from '@/lib/mongodb' + +export async function POST(request: NextRequest) { + const clientIP = getClientIP(request) + + // 1. Auth + const session = await validateSession(request) + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + // 2. Permission check — ALWAYS audit-log denials + if (!hasPermission(session, 'resource:action')) { + await createAuditLog({ action: '...', ..., status: 'FAILED', statusCode: 403, clientIP }) + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // 3. Sanitize input BEFORE any use + const { field1, field2 } = sanitizeObject(await request.json()) + + // 4. Validate params + if (!isValidObjectId(someId)) return NextResponse.json({ error: '...' }, { status: 400 }) + + // 5. DB + business logic + await connectToDatabase() + // ... Mongoose queries ... + + // 6. Audit log every mutation (success AND failure) + await createAuditLog({ + action: 'entity_created', + entityType: 'entity', + entityId: doc._id.toString(), + entityName: doc.name, + userId: session._id, + userName: session.username, + userEmail: session.email, + newValues: { ... }, + clientIP, + status: 'SUCCESS', + statusCode: 201, + }) + + return NextResponse.json({ success: true, data: doc }, { status: 201 }) +} +``` + +**HTTP status codes:** 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 500 Internal Server Error + +## Security — Non-Negotiable Rules +- Server-side permission checks are authoritative; client-side is UI-only +- `sanitizeObject()` on ALL request bodies before any use +- Parameterized Mongoose queries only — never string concatenation in queries +- Audit log ALL mutations (CREATE/UPDATE/DELETE) with previousValues + newValues + clientIP +- Never expose `passwordHash`, `twoFactorCode`, `twoFactorExpiry`, `loginAttempts` in API responses — use `.select('-passwordHash -twoFactorCode ...')` on Mongoose queries +- Permission format: `resource:action` (e.g., `servers:edit`, `backups:delete`) +- Admin wildcard: `*:*` + +## Permissions Reference +``` +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 +``` + +## UI Patterns — Dark Glassmorphism + +### Use these CSS utility classes (defined in `globals.css`): +- `.glass` — standard card/panel (rgba white 6%, blur 24px) +- `.glass-lg` — hero/prominent panels (rgba white 8%, blur 40px) +- `.glass-surface` — subtle surfaces (rgba white 7%, blur 16px) +- `.glass-input` — form inputs with cyan glow on focus +- `.glass-shine` — adds top-edge gradient highlight via `::before` +- `.glow-cyan`, `.glow-emerald`, `.glow-amber`, `.glow-red` — colored box shadows + +### Background layers +- Page background: `#050510` (set on `body` in globals.css — do NOT override with bg-* classes) +- Surfaces: `bg-white/[0.06]` or `.glass` +- Elevated: `bg-white/[0.08]` or `.glass-lg` + +### Color semantics (Tailwind) +| Meaning | Color | +|---------|-------| +| Primary/action | `cyan-400` / `cyan-500` | +| Success / online | `emerald-400` / `emerald-500` | +| Warning / pending | `amber-400` / `amber-500` | +| Error / danger / offline | `red-400` / `red-500` | +| Neutral | `gray-400` through `gray-900` | + +### Text +- Primary: `text-gray-100` +- Secondary: `text-gray-400` +- Muted/placeholder: `text-gray-500` + +### Buttons +``` +Primary: bg-cyan-500 hover:bg-cyan-600 text-white rounded-lg +Danger: bg-red-500 hover:bg-red-600 text-white rounded-lg +Secondary: bg-white/[0.08] hover:bg-white/[0.12] text-gray-200 rounded-lg border border-white/[0.12] +``` + +### Badges +``` +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 +``` + +### Animations (all defined in globals.css) +- `animate-fade-in` (200ms) +- `animate-scale-in` (250ms) — modals +- `animate-slide-in-right` (300ms) — drawers, toasts +- `animate-slide-up` (300ms) +- Only animate `transform` and `opacity`; keep under 300ms + +### Tables +``` +Container: .glass rounded-xl overflow-hidden +Header: bg-white/[0.05] text-gray-400 text-xs uppercase tracking-wider +Row: border-b border-white/[0.06] hover:bg-white/[0.04] +Dividers: divide-y divide-white/[0.06] +``` + +## Docker Architecture + +### Key docker.ts patterns +```ts +// Always use getDockerClient() singleton +import { getDockerClient, getServerPath, createServerContainer, mapContainerState } from '@/lib/docker' + +// Container naming +containerName = `mc-${server._id}` + +// getItzgServerType maps server type to Docker env +// 'bukkit' → 'PAPER' (not 'BUKKIT') +// 'vanilla' → 'VANILLA' +// 'forge' → 'FORGE' +// 'fabric' → 'FABRIC' + +// Volume mount pattern (SELinux-compatible) +// ${serverPath}:/data:Z +``` + +### Volume layout +``` +${MC_SERVERS_PATH}/{serverId}/ +├── server.properties +├── world/ # Backed up +├── plugins/ # bukkit only +├── mods/ # forge/fabric only +├── logs/ +└── backups/ +``` + +### Environment variables +```env +MC_SERVERS_PATH=/opt/mc-servers # Base path for server volumes +DOCKER_SOCKET=/var/run/docker.sock # Docker socket +DOCKER_UID=1000 # Container user ID (chown on dir creation) +DOCKER_GID=1000 # Container group ID +``` + +### Container status mapping +Docker state → app `ServerStatus`: +- `running` → `online` +- `created` / `restarting` → `starting` +- `paused` / `removing` → `stopping` +- `exited` → `offline` +- `dead` → `crashed` + +### Backup process +1. Exec RCON `save-off` + `save-all flush` (pause world saving) +2. `tar -czf "${filePath}" -C "${serverPath}" world` +3. Exec RCON `save-on` (resume) +4. Create/update `Backup` document +5. Delete oldest backups beyond `backupRetention` limit + +### next.config.ts note +`dockerode` and `node-cron` are in `serverExternalPackages` — they can only run server-side. + +## Data Models Quick Reference + +### Server.status values +`online` | `offline` | `starting` | `stopping` | `crashed` + +### Server.type values +`vanilla` | `bukkit` | `forge` | `fabric` + +### Backup.type / Backup.status +- type: `manual` | `scheduled` +- status: `completed` | `in_progress` | `failed` + +### User.status values +`active` | `inactive` | `locked` + +### AuditLog.status values +`SUCCESS` | `FAILED` (always uppercase) + +## Contexts — How to Use + +```ts +// Auth +const { user, loading, login, verify2FA, logout, hasPermission, refreshUser } = useAuth() + +// Toast notifications +const { showToast } = useToast() +showToast('Server started', 'success') // 'success' | 'error' | 'info' | 'warning' + +// Confirmation dialogs (returns Promise) +const { showConfirmation } = useConfirmation() +const ok = await showConfirmation({ + title: 'Delete Server?', + message: 'This will permanently delete', + itemName: server.name, + type: 'danger', // 'danger' | 'warning' | 'info' +}) +``` + +## Hooks Quick Reference + +```ts +// Console streaming + command sending +const { lines, connected, sending, sendCommand, clearConsole } = useConsole(serverId) + +// Single server status polling (default 10s interval) +const { status, players, loading, refresh } = useServerStatus(serverId) + +// All servers status (default 15s interval) +const { servers, loading, refresh } = useAllServersStatus() +``` + +## Real-Time Console (SSE) +- **Stream:** GET `/api/servers/[id]/console` → EventSource / SSE +- **Send command:** POST `/api/servers/[id]/console` with `{ command: string }` +- Client-side: use `useConsole()` hook — never reimplement SSE manually + +## Authentication Flow Summary +1. POST `/api/auth/login` → sends 2FA email → returns `{ requiresTwoFactor: true }` + sets `pending_2fa` cookie (10 min) +2. POST `/api/auth/verify-2fa` → validates code → sets `session-token` (1h) + `refresh-token` (7d) cookies +3. GET `/api/auth/me` → returns `AuthUser` from current session +4. POST `/api/auth/logout` → clears cookies +5. POST `/api/auth/refresh` → rotates access token + +Account lockout: 5 failed login attempts → 30-minute lockout (tracked via `loginAttempts` + `lockUntil`) + +## API Routes Map +``` +/api/auth/ login, logout, me, refresh, verify-2fa +/api/users/ GET list, POST create +/api/users/[id] PATCH update, DELETE +/api/roles/ GET list, POST create +/api/roles/[id] PATCH update, DELETE +/api/servers/ GET list, POST create +/api/servers/[id] GET, PATCH, DELETE +/api/servers/[id]/start POST +/api/servers/[id]/stop POST +/api/servers/[id]/restart POST +/api/servers/[id]/console GET (SSE stream), POST (command) +/api/servers/[id]/stats GET +/api/servers/[id]/configuration GET/PATCH (server.properties) +/api/servers/[id]/backups GET, POST (create manual) +/api/servers/[id]/backups/[backupId] DELETE +/api/servers/[id]/backups/[backupId]/restore POST +/api/servers/[id]/plugins GET, POST (upload JAR) +/api/servers/[id]/plugins/[filename] DELETE +/api/servers/[id]/plugins/[filename]/toggle PATCH +/api/servers/[id]/mods GET, POST (upload JAR) +/api/servers/[id]/mods/[filename] DELETE +/api/servers/[id]/mods/[filename]/toggle PATCH +/api/servers/[id]/players GET (online players) +/api/servers/[id]/players/[name]/[action] POST (op, deop, ban, unban, whitelist) +/api/servers/[id]/files GET (file browser) +/api/servers/[id]/logs GET (log files) +/api/audit GET +/api/health GET +``` + +## Pages Map (all `'use client'`) +``` +/ Redirect → /dashboard or /login +/login Login + 2FA flow +/(app)/dashboard Overview: server stats, quick-access server list +/(app)/servers Server list (DataManagementTemplate) +/(app)/servers/[id] Server overview / detail +/(app)/servers/[id]/console +/(app)/servers/[id]/configuration +/(app)/servers/[id]/plugins +/(app)/servers/[id]/mods +/(app)/servers/[id]/backups +/(app)/servers/[id]/logs +/(app)/servers/[id]/files +/(app)/servers/[id]/players +/(app)/users User management (DataManagementTemplate) +/(app)/roles Role management (DataManagementTemplate) +/(app)/audit Audit log viewer (DataManagementTemplate) +``` + +## Common Mistakes to Avoid +- **Do NOT** import `src/lib/` utilities in client components — they're server-only (use API routes instead) +- **Do NOT** use `bg-gray-950` / `bg-gray-900` for backgrounds — use `.glass`, `.glass-surface`, or `bg-white/[0.06]` per the glassmorphism system +- **Do NOT** use `tailwind.config.js` extend syntax for new colors — use Tailwind 4's CSS variable approach in globals.css if needed +- **Do NOT** skip sanitizing request body before using any field +- **Do NOT** build custom date formatters — use `@/lib/date-utils` +- **Do NOT** build custom data tables — use `DataManagementTemplate` +- **Do NOT** use any icon library other than `lucide-react` +- **Do NOT** allow plugin routes on non-bukkit servers or mod routes on non-forge/fabric — return 400 with type mismatch error +- **Do NOT** expose sensitive user fields in API responses (`passwordHash`, `twoFactorCode`, etc.) +- **Do NOT** skip audit logging on mutations — every CREATE/UPDATE/DELETE must log success AND failure diff --git a/src/app/(app)/servers/[id]/admins/page.tsx b/src/app/(app)/servers/[id]/admins/page.tsx new file mode 100644 index 0000000..36b4707 --- /dev/null +++ b/src/app/(app)/servers/[id]/admins/page.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useParams } from 'next/navigation' +import { ArrowLeft, ShieldCheck, UserMinus, UserPlus } 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 { useAuth } from '@/contexts/AuthContext' +import { useConfirmation } from '@/contexts/ConfirmationContext' + +interface AdminUser { + _id: string + username: string + email: string +} + +interface AllUser { + _id: string + username: string + email: string +} + +export default function ServerAdminsPage() { + const params = useParams() + const serverId = params.id as string + const { showToast } = useToast() + const { hasPermission } = useAuth() + const { showConfirmation } = useConfirmation() + + const canEdit = hasPermission('servers:edit') + + const [admins, setAdmins] = useState([]) + const [allUsers, setAllUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedUserId, setSelectedUserId] = useState('') + const [adding, setAdding] = useState(false) + + const fetchAdmins = useCallback(async () => { + try { + const res = await fetch(`/api/servers/${serverId}/admins`, { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setAdmins(data.data || []) + } + } catch { + showToast('Failed to load admins', 'error') + } finally { + setLoading(false) + } + }, [serverId, showToast]) + + const fetchAllUsers = useCallback(async () => { + if (!canEdit) return + try { + const res = await fetch('/api/users', { credentials: 'include' }) + if (res.ok) { + const data = await res.json() + setAllUsers(data.data || []) + } + } catch { + // non-critical + } + }, [canEdit]) + + useEffect(() => { + fetchAdmins() + fetchAllUsers() + }, [fetchAdmins, fetchAllUsers]) + + const handleAdd = async () => { + if (!selectedUserId) return + setAdding(true) + try { + const res = await fetch(`/api/servers/${serverId}/admins`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: selectedUserId }), + }) + const data = await res.json() + if (res.ok) { + showToast('Admin added', 'success') + setSelectedUserId('') + fetchAdmins() + } else { + showToast(data.error || 'Failed to add admin', 'error') + } + } catch { + showToast('Failed to add admin', 'error') + } finally { + setAdding(false) + } + } + + const handleRemove = async (admin: AdminUser) => { + const confirmed = await showConfirmation({ + title: 'Remove Admin', + message: 'Remove admin access from', + itemName: admin.username, + type: 'warning', + confirmText: 'Remove', + }) + if (!confirmed) return + + try { + const res = await fetch(`/api/servers/${serverId}/admins/${admin._id}`, { + method: 'DELETE', + credentials: 'include', + }) + const data = await res.json() + if (res.ok) { + showToast('Admin removed', 'success') + fetchAdmins() + } else { + showToast(data.error || 'Failed to remove admin', 'error') + } + } catch { + showToast('Failed to remove admin', 'error') + } + } + + const availableUsers = allUsers.filter(u => !admins.some(a => a._id === u._id)) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+ + Back to Server + + + + + {/* Add Admin */} + {canEdit && ( +
+

+ + Add Admin +

+
+ + +
+
+ )} + + {/* Admin List */} +
+
+

Current Admins ({admins.length})

+
+ {admins.length === 0 ? ( +
No admins assigned.
+ ) : ( +
+ {admins.map(admin => ( +
+
+

{admin.username}

+

{admin.email}

+
+ {canEdit && ( + + )} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/(app)/servers/[id]/page.tsx b/src/app/(app)/servers/[id]/page.tsx index 55d7781..66ab373 100644 --- a/src/app/(app)/servers/[id]/page.tsx +++ b/src/app/(app)/servers/[id]/page.tsx @@ -7,7 +7,7 @@ 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 + Puzzle, Package, Users, Trash2, ArrowLeft, FileText, FolderOpen, ShieldCheck } from 'lucide-react' import PageHeader from '@/components/PageHeader' import ServerStatusBadge from '@/components/ServerStatusBadge' @@ -31,7 +31,7 @@ export default function ServerDetailPage() { const params = useParams() const router = useRouter() const serverId = params.id as string - const { hasPermission } = useAuth() + const { hasPermission, user } = useAuth() const { showToast } = useToast() const { showConfirmation } = useConfirmation() @@ -172,6 +172,10 @@ export default function ServerDetailPage() { ) } + const isServerAdmin = server.admins?.includes(user?._id ?? '') + const canDo = (permission: string) => + hasPermission(permission) || (permission !== 'servers:delete' && !!isServerAdmin) + const navLinks = [ { label: 'Console', href: `/servers/${serverId}/console`, icon: Terminal, permission: 'servers:console' }, { label: 'Logs', href: `/servers/${serverId}/logs`, icon: FileText, permission: 'servers:console' }, @@ -181,6 +185,7 @@ export default function ServerDetailPage() { ...(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' }, + ...(hasPermission('servers:edit') ? [{ label: 'Admins', href: `/servers/${serverId}/admins`, icon: ShieldCheck, permission: 'servers:edit' }] : []), ] return ( @@ -199,17 +204,17 @@ export default function ServerDetailPage() {
- {hasPermission('servers:start') && server.status === 'offline' && ( + {canDo('servers:start') && server.status === 'offline' && ( )} - {hasPermission('servers:stop') && server.status === 'online' && ( + {canDo('servers:stop') && server.status === 'online' && ( )} - {hasPermission('servers:restart') && server.status === 'online' && ( + {canDo('servers:restart') && server.status === 'online' && ( @@ -259,7 +264,7 @@ export default function ServerDetailPage() { {/* Navigation Cards */}
{navLinks - .filter(link => hasPermission(link.permission)) + .filter(link => canDo(link.permission)) .map(link => { const Icon = link.icon return ( diff --git a/src/app/api/servers/[id]/admins/[userId]/route.ts b/src/app/api/servers/[id]/admins/[userId]/route.ts new file mode 100644 index 0000000..550366b --- /dev/null +++ b/src/app/api/servers/[id]/admins/[userId]/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server' +import { validateSession, hasPermission } from '@/lib/auth' +import connectToDatabase from '@/lib/mongodb' +import { Server, User } from '@/lib/models' +import { isValidObjectId } from '@/lib/input-validation' +import { createAuditLog, getClientIP } from '@/lib/audit' + +// DELETE /api/servers/[id]/admins/[userId] — Remove a server admin +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; userId: 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, userId } = await params + if (!isValidObjectId(id) || !isValidObjectId(userId)) { + 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 isAdmin = server.admins.some((a) => a.toString() === userId) + if (!isAdmin) { + return NextResponse.json({ error: 'User is not an admin of this server' }, { status: 404 }) + } + + if (server.admins.length <= 1) { + return NextResponse.json({ error: 'Cannot remove the last admin from a server' }, { status: 400 }) + } + + server.admins = server.admins.filter((a) => a.toString() !== userId) + await server.save() + + const removedUser = await User.findById(userId, { username: 1 }).lean() + + await createAuditLog({ + action: 'server_admin_removed', + entityType: 'server', + entityName: server.name, + userId: session._id, + userName: session.username, + userEmail: session.email, + previousValues: { serverId: server._id.toString(), removedUserId: userId, removedUserName: removedUser?.username }, + clientIP, + status: 'success', + statusCode: 200, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Remove admin error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/servers/[id]/admins/route.ts b/src/app/api/servers/[id]/admins/route.ts new file mode 100644 index 0000000..0ad5504 --- /dev/null +++ b/src/app/api/servers/[id]/admins/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from 'next/server' +import { validateSession, hasPermission, hasServerPermission } from '@/lib/auth' +import connectToDatabase from '@/lib/mongodb' +import { Server, User } from '@/lib/models' +import { isValidObjectId } from '@/lib/input-validation' +import { createAuditLog, getClientIP } from '@/lib/audit' +import mongoose from 'mongoose' + +// GET /api/servers/[id]/admins — List server admins +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 }) + } + + 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 adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:view', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Populate admin users + const adminUsers = await User.find( + { _id: { $in: server.admins } }, + { _id: 1, username: 1, email: 1 } + ).lean() + + return NextResponse.json({ success: true, data: adminUsers }) + } catch (error) { + console.error('Fetch admins error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// POST /api/servers/[id]/admins — Add a user as server admin +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: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 body = await request.json() + const { userId } = body + + if (!userId || !isValidObjectId(userId)) { + return NextResponse.json({ error: 'Invalid user ID' }, { status: 400 }) + } + + await connectToDatabase() + const server = await Server.findById(id) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const targetUser = await User.findById(userId, { _id: 1, username: 1, email: 1 }).lean() + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + const alreadyAdmin = server.admins.some((a) => a.toString() === userId) + if (alreadyAdmin) { + return NextResponse.json({ error: 'User is already an admin of this server' }, { status: 409 }) + } + + server.admins.push(new mongoose.Types.ObjectId(userId)) + await server.save() + + await createAuditLog({ + action: 'server_admin_added', + entityType: 'server', + entityName: server.name, + userId: session._id, + userName: session.username, + userEmail: session.email, + newValues: { serverId: server._id.toString(), addedUserId: userId, addedUserName: targetUser.username }, + clientIP, + status: 'success', + statusCode: 201, + }) + + return NextResponse.json({ success: true, data: targetUser }, { status: 201 }) + } catch (error) { + console.error('Add admin error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/servers/[id]/backups/[backupId]/restore/route.ts b/src/app/api/servers/[id]/backups/[backupId]/restore/route.ts index 1cebbdc..5e7b3fd 100644 --- a/src/app/api/servers/[id]/backups/[backupId]/restore/route.ts +++ b/src/app/api/servers/[id]/backups/[backupId]/restore/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Backup, Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -21,10 +21,6 @@ export async function POST( 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 }) @@ -36,6 +32,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'backups:restore', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + 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 }) diff --git a/src/app/api/servers/[id]/backups/[backupId]/route.ts b/src/app/api/servers/[id]/backups/[backupId]/route.ts index 072453b..34f04d5 100644 --- a/src/app/api/servers/[id]/backups/[backupId]/route.ts +++ b/src/app/api/servers/[id]/backups/[backupId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' -import { Backup } from '@/lib/models' +import { Backup, Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' import { createAuditLog, getClientIP } from '@/lib/audit' import { unlink } from 'fs/promises' @@ -19,16 +19,22 @@ export async function DELETE( 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 server = await Server.findById(id).lean() + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const adminIds = (server.admins ?? []).map((a) => a.toString()) + if (!hasServerPermission(session, 'backups:delete', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const backup = await Backup.findOne({ _id: backupId, serverId: id }) if (!backup) { return NextResponse.json({ error: 'Backup not found' }, { status: 404 }) diff --git a/src/app/api/servers/[id]/backups/route.ts b/src/app/api/servers/[id]/backups/route.ts index 45019ab..d32b4be 100644 --- a/src/app/api/servers/[id]/backups/route.ts +++ b/src/app/api/servers/[id]/backups/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server, Backup } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -20,16 +20,22 @@ export async function GET( 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 server = await Server.findById(id).lean() + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } + + const adminIds = (server.admins ?? []).map((a) => a.toString()) + if (!hasServerPermission(session, 'backups:view', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const backups = await Backup.find({ serverId: id }).sort({ createdAt: -1 }).lean() return NextResponse.json({ success: true, data: backups }) @@ -52,10 +58,6 @@ export async function POST( 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 }) @@ -67,6 +69,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'backups:create', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const serverDir = getServerPath(server._id.toString()) const backupsDir = path.join(serverDir, 'backups') const timestamp = new Date().toISOString().replace(/[:.]/g, '-') diff --git a/src/app/api/servers/[id]/configuration/route.ts b/src/app/api/servers/[id]/configuration/route.ts index a52806c..e35e09d 100644 --- a/src/app/api/servers/[id]/configuration/route.ts +++ b/src/app/api/servers/[id]/configuration/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { sanitizeObject, isValidObjectId } from '@/lib/input-validation' @@ -19,10 +19,6 @@ export async function GET( 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 }) @@ -34,6 +30,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const serverDir = getServerPath(server._id.toString()) const propsPath = path.join(serverDir, 'server.properties') @@ -80,10 +81,6 @@ export async function PUT( 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 }) @@ -98,6 +95,11 @@ export async function PUT( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const serverDir = getServerPath(server._id.toString()) const propsPath = path.join(serverDir, 'server.properties') diff --git a/src/app/api/servers/[id]/console/route.ts b/src/app/api/servers/[id]/console/route.ts index 5a9e610..9a2f0c6 100644 --- a/src/app/api/servers/[id]/console/route.ts +++ b/src/app/api/servers/[id]/console/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -16,10 +16,6 @@ export async function GET( 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 }) @@ -31,6 +27,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:console', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const container = await getContainerByName(`mc-${server._id}`) if (!container) { return NextResponse.json({ error: 'Container not found' }, { status: 404 }) @@ -93,10 +94,6 @@ export async function POST( 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 }) @@ -113,6 +110,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:console', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.status !== 'online') { return NextResponse.json({ error: 'Server is not running' }, { status: 400 }) } diff --git a/src/app/api/servers/[id]/files/route.ts b/src/app/api/servers/[id]/files/route.ts index 126c85d..5b81ca0 100644 --- a/src/app/api/servers/[id]/files/route.ts +++ b/src/app/api/servers/[id]/files/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -48,10 +48,6 @@ export async function GET( 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 }) @@ -63,6 +59,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const requestedPath = request.nextUrl.searchParams.get('path') || '/' const readContent = request.nextUrl.searchParams.get('read') === 'true' @@ -176,10 +177,6 @@ export async function PUT( 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 }) @@ -191,6 +188,11 @@ export async function PUT( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const body = await request.json() const { path: filePath, content } = body @@ -228,10 +230,6 @@ export async function DELETE( 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 }) @@ -243,6 +241,11 @@ export async function DELETE( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const { path: filePath } = await request.json() if (!filePath) { return NextResponse.json({ error: 'Path is required' }, { status: 400 }) @@ -283,10 +286,6 @@ export async function POST( 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 }) @@ -298,6 +297,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const serverRoot = resolve(getServerPath(server._id.toString())) const contentType = request.headers.get('content-type') || '' diff --git a/src/app/api/servers/[id]/logs/route.ts b/src/app/api/servers/[id]/logs/route.ts index 7057ef1..df97abb 100644 --- a/src/app/api/servers/[id]/logs/route.ts +++ b/src/app/api/servers/[id]/logs/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -18,10 +18,6 @@ export async function GET( 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 }) @@ -33,6 +29,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:console', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const logsDir = join(getServerPath(server._id.toString()), 'logs') const fileName = request.nextUrl.searchParams.get('file') diff --git a/src/app/api/servers/[id]/mods/[filename]/route.ts b/src/app/api/servers/[id]/mods/[filename]/route.ts index eac3995..80cb0ad 100644 --- a/src/app/api/servers/[id]/mods/[filename]/route.ts +++ b/src/app/api/servers/[id]/mods/[filename]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -21,10 +21,6 @@ export async function DELETE( 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 }) @@ -38,6 +34,11 @@ export async function DELETE( return NextResponse.json({ error: 'Server not found or not a Forge/Fabric server' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'mods:remove', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const filePath = path.join(getServerPath(server._id.toString()), 'mods', decodedFilename) try { diff --git a/src/app/api/servers/[id]/mods/[filename]/toggle/route.ts b/src/app/api/servers/[id]/mods/[filename]/toggle/route.ts index d43ffe3..3d695ef 100644 --- a/src/app/api/servers/[id]/mods/[filename]/toggle/route.ts +++ b/src/app/api/servers/[id]/mods/[filename]/toggle/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -21,10 +21,6 @@ export async function POST( 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 }) @@ -38,6 +34,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found or not a Forge/Fabric server' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'mods:toggle', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const modsDir = path.join(getServerPath(server._id.toString()), 'mods') const currentPath = path.join(modsDir, decodedFilename) diff --git a/src/app/api/servers/[id]/mods/route.ts b/src/app/api/servers/[id]/mods/route.ts index ea705a6..9e12239 100644 --- a/src/app/api/servers/[id]/mods/route.ts +++ b/src/app/api/servers/[id]/mods/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -19,10 +19,6 @@ export async function GET( 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 }) @@ -34,6 +30,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'mods:view', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.type !== 'forge' && server.type !== 'fabric') { return NextResponse.json({ error: 'Mods are only available for Forge/Fabric servers' }, { status: 400 }) } @@ -81,10 +82,6 @@ export async function POST( 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 }) @@ -96,6 +93,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'mods:install', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.type !== 'forge' && server.type !== 'fabric') { return NextResponse.json({ error: 'Mods are only available for Forge/Fabric servers' }, { status: 400 }) } diff --git a/src/app/api/servers/[id]/players/[name]/[action]/route.ts b/src/app/api/servers/[id]/players/[name]/[action]/route.ts index b8a5fce..e4dc00b 100644 --- a/src/app/api/servers/[id]/players/[name]/[action]/route.ts +++ b/src/app/api/servers/[id]/players/[name]/[action]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -38,10 +38,6 @@ export async function POST( 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 }) } @@ -52,6 +48,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, requiredPermission, adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + // Map action to MC command const commandMap: Record = { whitelist: `whitelist add ${decodedName}`, diff --git a/src/app/api/servers/[id]/players/route.ts b/src/app/api/servers/[id]/players/route.ts index 753452e..b973314 100644 --- a/src/app/api/servers/[id]/players/route.ts +++ b/src/app/api/servers/[id]/players/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -27,10 +27,6 @@ export async function GET( 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 }) @@ -42,6 +38,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'players:view', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const serverDir = getServerPath(server._id.toString()) // Parse player lists from JSON files diff --git a/src/app/api/servers/[id]/plugins/[filename]/route.ts b/src/app/api/servers/[id]/plugins/[filename]/route.ts index b86745f..5382c45 100644 --- a/src/app/api/servers/[id]/plugins/[filename]/route.ts +++ b/src/app/api/servers/[id]/plugins/[filename]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -21,10 +21,6 @@ export async function DELETE( 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 }) @@ -38,6 +34,11 @@ export async function DELETE( return NextResponse.json({ error: 'Server not found or not a Bukkit server' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'plugins:remove', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const filePath = path.join(getServerPath(server._id.toString()), 'plugins', decodedFilename) try { diff --git a/src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts b/src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts index 6c53fce..1d66073 100644 --- a/src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts +++ b/src/app/api/servers/[id]/plugins/[filename]/toggle/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -21,10 +21,6 @@ export async function POST( 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 }) @@ -38,6 +34,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found or not a Bukkit server' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'plugins:toggle', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const pluginsDir = path.join(getServerPath(server._id.toString()), 'plugins') const currentPath = path.join(pluginsDir, decodedFilename) diff --git a/src/app/api/servers/[id]/plugins/route.ts b/src/app/api/servers/[id]/plugins/route.ts index 425a059..8601636 100644 --- a/src/app/api/servers/[id]/plugins/route.ts +++ b/src/app/api/servers/[id]/plugins/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -19,10 +19,6 @@ export async function GET( 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 }) @@ -34,6 +30,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'plugins:view', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.type !== 'bukkit') { return NextResponse.json({ error: 'Plugins are only available for Bukkit-type servers' }, { status: 400 }) } @@ -81,10 +82,6 @@ export async function POST( 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 }) @@ -96,6 +93,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'plugins:install', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.type !== 'bukkit') { return NextResponse.json({ error: 'Plugins are only available for Bukkit-type servers' }, { status: 400 }) } diff --git a/src/app/api/servers/[id]/restart/route.ts b/src/app/api/servers/[id]/restart/route.ts index d3456df..d9bdd1f 100644 --- a/src/app/api/servers/[id]/restart/route.ts +++ b/src/app/api/servers/[id]/restart/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -18,10 +18,6 @@ export async function POST( 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 }) @@ -34,6 +30,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:restart', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + // If the server is currently offline, check for port conflicts before restarting if (server.status !== 'online' && server.status !== 'starting') { const portConflict = await Server.findOne({ diff --git a/src/app/api/servers/[id]/route.ts b/src/app/api/servers/[id]/route.ts index b7b7c67..0fa8d14 100644 --- a/src/app/api/servers/[id]/route.ts +++ b/src/app/api/servers/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasPermission, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { sanitizeObject, isValidObjectId } from '@/lib/input-validation' @@ -17,10 +17,6 @@ export async function GET( 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 }) @@ -33,6 +29,11 @@ export async function GET( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = (server.admins ?? []).map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:view', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + // Reconcile status with Docker try { const container = await getContainerByName(`mc-${server._id}`) @@ -69,10 +70,6 @@ export async function PUT( 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 }) @@ -88,6 +85,11 @@ export async function PUT( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:edit', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const previousValues = server.toObject() // Allowlisted update fields diff --git a/src/app/api/servers/[id]/start/route.ts b/src/app/api/servers/[id]/start/route.ts index 49fdc29..90d3f33 100644 --- a/src/app/api/servers/[id]/start/route.ts +++ b/src/app/api/servers/[id]/start/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -18,10 +18,6 @@ export async function POST( 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 }) @@ -34,6 +30,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:start', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.status === 'online' || server.status === 'starting') { return NextResponse.json({ error: 'Server is already running or starting' }, { status: 400 }) } diff --git a/src/app/api/servers/[id]/stats/route.ts b/src/app/api/servers/[id]/stats/route.ts index 70ee430..c1596aa 100644 --- a/src/app/api/servers/[id]/stats/route.ts +++ b/src/app/api/servers/[id]/stats/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -46,10 +46,6 @@ export async function GET( 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 }) @@ -61,6 +57,11 @@ export async function GET( return new Response(JSON.stringify({ error: 'Server not found' }), { status: 404 }) } + const adminIds = (server.admins ?? []).map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:view', adminIds)) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) + } + const container = await getContainerByName(`mc-${server._id}`).catch(() => null) if (!container) { return new Response(JSON.stringify({ error: 'Container not found' }), { status: 404 }) diff --git a/src/app/api/servers/[id]/stop/route.ts b/src/app/api/servers/[id]/stop/route.ts index f5e8bf2..2819c8e 100644 --- a/src/app/api/servers/[id]/stop/route.ts +++ b/src/app/api/servers/[id]/stop/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { validateSession, hasPermission } from '@/lib/auth' +import { validateSession, hasServerPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' import { isValidObjectId } from '@/lib/input-validation' @@ -18,10 +18,6 @@ export async function POST( 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 }) @@ -34,6 +30,11 @@ export async function POST( return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } + const adminIds = server.admins.map((a) => a.toString()) + if (!hasServerPermission(session, 'servers:stop', adminIds)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (server.status === 'offline' || server.status === 'stopping') { return NextResponse.json({ error: 'Server is already stopped or stopping' }, { status: 400 }) } diff --git a/src/app/api/servers/route.ts b/src/app/api/servers/route.ts index 7a6b22b..fb939c8 100644 --- a/src/app/api/servers/route.ts +++ b/src/app/api/servers/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' +import mongoose from 'mongoose' import { validateSession, hasPermission } from '@/lib/auth' import connectToDatabase from '@/lib/mongodb' import { Server } from '@/lib/models' @@ -14,12 +15,17 @@ export async function GET(request: NextRequest) { 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() + + let servers + if (hasPermission(session, 'servers:view')) { + servers = await Server.find().sort({ createdAt: -1 }).lean() + } else { + // Return only servers where this user is a server admin + servers = await Server.find({ + admins: new mongoose.Types.ObjectId(session._id), + }).sort({ createdAt: -1 }).lean() + } return NextResponse.json({ success: true, data: servers }) } catch (error) { @@ -95,6 +101,7 @@ export async function POST(request: NextRequest) { autoStart: false, autoRestart: true, createdBy: session._id, + admins: [new mongoose.Types.ObjectId(session._id)], }) // Set containerName before save — schema requires it diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c000520..921b3e1 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -149,3 +149,18 @@ export function hasPermission(user: AuthUser, permission: string): boolean { if (user.permissions.includes('*:*')) return true return user.permissions.includes(permission) } + +/** + * Checks permission for a specific server, granting access to server admins. + * Server admins have full access EXCEPT servers:delete (global-only). + */ +export function hasServerPermission( + user: AuthUser, + permission: string, + serverAdmins: string[] +): boolean { + if (user.permissions.includes('*:*')) return true + if (user.permissions.includes(permission)) return true + if (permission !== 'servers:delete' && serverAdmins.includes(user._id)) return true + return false +} diff --git a/src/lib/models.ts b/src/lib/models.ts index 2c2b71e..90e9321 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -83,6 +83,7 @@ export interface IServer extends Document { autoRestart: boolean backupSchedule: string | null backupRetention: number + admins: mongoose.Types.ObjectId[] createdBy: mongoose.Types.ObjectId createdAt: Date updatedAt: Date @@ -113,6 +114,7 @@ const serverSchema = new Schema( autoRestart: { type: Boolean, default: true }, backupSchedule: { type: String, default: null }, backupRetention: { type: Number, default: 5 }, + admins: [{ type: Schema.Types.ObjectId, ref: 'User', default: [] }], createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true }, }, { timestamps: true } diff --git a/src/types/server.ts b/src/types/server.ts index ddb97a8..8801c45 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -25,6 +25,7 @@ export interface Server { autoRestart: boolean backupSchedule: string | null backupRetention: number + admins: string[] createdBy: string createdAt: string updatedAt: string