Initial push
This commit is contained in:
166
app/(auth)/accept-invite/page.tsx
Normal file
166
app/(auth)/accept-invite/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
|
||||
function AcceptInvitePageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError("Invalid or missing invitation token.");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/accept-invite", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, name, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/login"), 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-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 gap-2 text-2xl font-bold text-emerald-500">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="2" y="2" width="11" height="11" fill="#10b981" rx="2" />
|
||||
<rect x="15" y="2" width="11" height="11" fill="#10b981" rx="2" opacity="0.6" />
|
||||
<rect x="2" y="15" width="11" height="11" fill="#10b981" rx="2" opacity="0.6" />
|
||||
<rect x="15" y="15" width="11" height="11" fill="#10b981" rx="2" />
|
||||
</svg>
|
||||
CubeAdmin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Accept Invitation</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
Create your account to access the Minecraft server panel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{success ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-500" />
|
||||
<p className="text-white font-medium">Account created!</p>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
Redirecting you to the login page...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30 text-red-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="ml-2 text-sm">{error}</span>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Your Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
disabled={!token || loading}
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
disabled={!token || loading}
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Confirm Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
disabled={!token || loading}
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!token || loading || !name || !password || !confirmPassword}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AcceptInvitePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
377
app/(auth)/login/page.tsx
Normal file
377
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation schema
|
||||
// ---------------------------------------------------------------------------
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
type FieldErrors = Partial<Record<keyof LoginFormValues, string>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CubeAdmin logo (duplicated from sidebar so this page is self-contained)
|
||||
// ---------------------------------------------------------------------------
|
||||
function CubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="16,4 28,10 16,16 4,10" fill="#059669" opacity="0.9" />
|
||||
<polygon points="4,10 16,16 16,28 4,22" fill="#047857" opacity="0.95" />
|
||||
<polygon points="28,10 16,16 16,28 28,22" fill="#10b981" opacity="0.85" />
|
||||
<polyline
|
||||
points="4,10 16,4 28,10 16,16 4,10"
|
||||
stroke="#34d399"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<line
|
||||
x1="16" y1="16" x2="16" y2="28"
|
||||
stroke="#34d399"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form field component
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode; // for additional elements (e.g., show/hide button)
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
id,
|
||||
label,
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-zinc-300">
|
||||
{label}
|
||||
</Label>
|
||||
<div className={cn("relative", className)}>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
className={cn(
|
||||
"h-9 bg-zinc-900/60 border-zinc-800 text-zinc-100 placeholder:text-zinc-600 focus-visible:border-emerald-500/50 focus-visible:ring-emerald-500/20",
|
||||
children && "pr-10",
|
||||
error &&
|
||||
"border-red-500/50 focus-visible:border-red-500/50 focus-visible:ring-red-500/20"
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
id={`${id}-error`}
|
||||
className="flex items-center gap-1 text-xs text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login page
|
||||
// ---------------------------------------------------------------------------
|
||||
function LoginPageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function validate(): LoginFormValues | null {
|
||||
const result = loginSchema.safeParse({ email, password });
|
||||
if (!result.success) {
|
||||
const errors: FieldErrors = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as keyof LoginFormValues;
|
||||
if (!errors[field]) errors[field] = issue.message;
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return null;
|
||||
}
|
||||
setFieldErrors({});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setGlobalError(null);
|
||||
|
||||
const values = validate();
|
||||
if (!values) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const { error } = await authClient.signIn.email({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// Map common Better Auth error codes to friendly messages
|
||||
const message = mapAuthError(error.code ?? error.message ?? "");
|
||||
setGlobalError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Signed in successfully");
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setGlobalError("An unexpected error occurred. Please try again.");
|
||||
console.error("[login] Unexpected error:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
|
||||
{/* Background grid pattern */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 bg-[size:32px_32px] opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Subtle radial glow */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="h-[500px] w-[500px] rounded-full bg-emerald-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative z-10 w-full max-w-sm">
|
||||
{/* Logo + branding */}
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-zinc-900 ring-1 ring-white/[0.08]">
|
||||
<CubeIcon className="h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-100">
|
||||
CubeAdmin
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Minecraft Server Management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="rounded-xl bg-zinc-900/50 p-6 ring-1 ring-white/[0.08] backdrop-blur-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-base font-semibold text-zinc-100">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Enter your credentials to access the admin panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Global error banner */}
|
||||
{globalError && (
|
||||
<div
|
||||
className="mb-4 flex items-start gap-2.5 rounded-lg bg-red-500/10 px-3 py-2.5 ring-1 ring-red-500/20"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" />
|
||||
<p className="text-xs text-red-300">{globalError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
{/* Email */}
|
||||
<FormField
|
||||
id="email"
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
error={fieldErrors.email}
|
||||
placeholder="admin@example.com"
|
||||
autoComplete="email"
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<FormField
|
||||
id="password"
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
error={fieldErrors.password}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={isPending}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</FormField>
|
||||
|
||||
{/* Forgot password */}
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="mt-1 h-9 w-full bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/50 disabled:opacity-60 border-0 font-medium"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-[11px] text-zinc-600">
|
||||
CubeAdmin — Secure server management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function mapAuthError(code: string): string {
|
||||
const normalised = code.toLowerCase();
|
||||
|
||||
if (
|
||||
normalised.includes("invalid_credentials") ||
|
||||
normalised.includes("invalid credentials") ||
|
||||
normalised.includes("user_not_found") ||
|
||||
normalised.includes("incorrect password") ||
|
||||
normalised.includes("wrong password")
|
||||
) {
|
||||
return "Invalid email or password. Please check your credentials and try again.";
|
||||
}
|
||||
|
||||
if (normalised.includes("account_not_found")) {
|
||||
return "No account found with this email address.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalised.includes("email_not_verified") ||
|
||||
normalised.includes("email not verified")
|
||||
) {
|
||||
return "Please verify your email address before signing in.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalised.includes("too_many_requests") ||
|
||||
normalised.includes("rate_limit")
|
||||
) {
|
||||
return "Too many sign-in attempts. Please wait a few minutes before trying again.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalised.includes("account_disabled") ||
|
||||
normalised.includes("user_banned")
|
||||
) {
|
||||
return "Your account has been disabled. Please contact your administrator.";
|
||||
}
|
||||
|
||||
return "Sign in failed. Please try again or contact your administrator.";
|
||||
}
|
||||
198
app/(dashboard)/audit/page.tsx
Normal file
198
app/(dashboard)/audit/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollText, RefreshCw, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface AuditEntry {
|
||||
log: {
|
||||
id: string;
|
||||
userId: string;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId: string | null;
|
||||
details: string | null;
|
||||
ipAddress: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
"server.start": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
"server.stop": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
|
||||
"server.restart": "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
"player.ban": "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
"player.unban": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
"player.kick": "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
"file.upload": "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
"file.delete": "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
"plugin.enable": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
"plugin.disable": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
|
||||
function getActionColor(action: string): string {
|
||||
return ACTION_COLORS[action] ?? "bg-zinc-500/20 text-zinc-400 border-zinc-500/30";
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const LIMIT = 50;
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(LIMIT),
|
||||
});
|
||||
if (search) params.set("action", search);
|
||||
const res = await fetch(`/api/audit?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.logs);
|
||||
setHasMore(data.logs.length === LIMIT);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Complete history of admin actions
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Filter by action (e.g. player.ban)"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<ScrollText className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No audit log entries</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
<span>Action</span>
|
||||
<span>Details</span>
|
||||
<span>User</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
{entries.map(({ log, userName, userEmail }) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-3 items-start hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<Badge className={`text-xs shrink-0 mt-0.5 ${getActionColor(log.action)}`}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
<div>
|
||||
{log.targetId && (
|
||||
<p className="text-sm text-zinc-300">
|
||||
Target:{" "}
|
||||
<span className="font-mono text-xs text-zinc-400">
|
||||
{log.targetId}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{log.details && (
|
||||
<p className="text-xs text-zinc-500 font-mono mt-0.5 truncate max-w-xs">
|
||||
{log.details}
|
||||
</p>
|
||||
)}
|
||||
{log.ipAddress && (
|
||||
<p className="text-xs text-zinc-600 mt-0.5">
|
||||
IP: {log.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-zinc-300">
|
||||
{userName ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">{userEmail ?? ""}</p>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(log.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">Page {page}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1 || loading}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
app/(dashboard)/backups/page.tsx
Normal file
324
app/(dashboard)/backups/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Archive,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Globe,
|
||||
Puzzle,
|
||||
Settings,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
|
||||
interface Backup {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "worlds" | "plugins" | "config" | "full";
|
||||
size: number;
|
||||
path: string;
|
||||
createdAt: number;
|
||||
status: "pending" | "running" | "completed" | "failed";
|
||||
triggeredBy: string;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
worlds: { label: "Worlds", icon: Globe, color: "text-blue-400", bg: "bg-blue-500/10" },
|
||||
plugins: { label: "Plugins", icon: Puzzle, color: "text-amber-400", bg: "bg-amber-500/10" },
|
||||
config: { label: "Config", icon: Settings, color: "text-violet-400", bg: "bg-violet-500/10" },
|
||||
full: { label: "Full", icon: Package, color: "text-emerald-400", bg: "bg-emerald-500/10" },
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const [backups, setBackups] = useState<Backup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/backups");
|
||||
if (res.ok) setBackups((await res.json()).backups);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackups();
|
||||
// Poll every 5s to catch running->completed transitions
|
||||
const interval = setInterval(fetchBackups, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchBackups]);
|
||||
|
||||
const createBackup = async (type: Backup["type"]) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch("/api/backups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${TYPE_CONFIG[type].label} backup started`);
|
||||
fetchBackups();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Backup failed");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBackup = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/backups/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Backup deleted");
|
||||
setBackups((prev) => prev.filter((b) => b.id !== id));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
setDeleteId(null);
|
||||
};
|
||||
|
||||
const downloadBackup = (id: string) => {
|
||||
window.open(`/api/backups/${id}`, "_blank");
|
||||
};
|
||||
|
||||
const statusBadge = (status: Backup["status"]) => {
|
||||
const config = {
|
||||
pending: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
|
||||
running: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
completed: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
failed: "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
};
|
||||
return (
|
||||
<Badge className={`text-xs ${config[status]}`}>
|
||||
{status === "running" && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5 animate-pulse" />
|
||||
)}
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Backups</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Create and manage server backups
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchBackups}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={creating} className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium h-7 gap-1 px-2.5 bg-emerald-600 hover:bg-emerald-500 text-white transition-all">
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
New Backup
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
|
||||
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
|
||||
([type, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
onClick={() => createBackup(type)}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
>
|
||||
<Icon className={`w-4 h-4 mr-2 ${config.color}`} />
|
||||
{config.label} backup
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
|
||||
([type, config]) => {
|
||||
const Icon = config.icon;
|
||||
const count = backups.filter(
|
||||
(b) => b.type === type && b.status === "completed",
|
||||
).length;
|
||||
return (
|
||||
<Card
|
||||
key={type}
|
||||
className="bg-zinc-900 border-zinc-800 cursor-pointer hover:border-zinc-700 transition-colors"
|
||||
onClick={() => createBackup(type)}
|
||||
>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${config.bg}`}>
|
||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{config.label}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">{count} backup(s)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backups list */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full bg-zinc-800" />
|
||||
))}
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<Archive className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No backups yet</p>
|
||||
<p className="text-sm mt-1">Create your first backup above</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
<span />
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Status</span>
|
||||
<span>Created</span>
|
||||
<span />
|
||||
</div>
|
||||
{backups.map((backup) => {
|
||||
const typeConfig = TYPE_CONFIG[backup.type];
|
||||
const Icon = typeConfig.icon;
|
||||
return (
|
||||
<div
|
||||
key={backup.id}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div className={`p-1.5 rounded-md ${typeConfig.bg}`}>
|
||||
<Icon className={`w-4 h-4 ${typeConfig.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white truncate max-w-xs">
|
||||
{backup.name}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 capitalize">
|
||||
{backup.type}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">
|
||||
{backup.size > 0 ? formatBytes(backup.size) : "—"}
|
||||
</span>
|
||||
{statusBadge(backup.status)}
|
||||
<span className="text-sm text-zinc-500">
|
||||
{formatDistanceToNow(new Date(backup.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
|
||||
{backup.status === "completed" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadBackup(backup.id)}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(backup.id)}
|
||||
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">
|
||||
Delete backup?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-zinc-400">
|
||||
This will permanently delete the backup file from disk. This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-zinc-700 text-zinc-400">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteBackup(deleteId)}
|
||||
className="bg-red-600 hover:bg-red-500 text-white"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
app/(dashboard)/console/page.tsx
Normal file
254
app/(dashboard)/console/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Terminal, Send, Trash2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LogLine {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
type: "info" | "warn" | "error" | "raw";
|
||||
}
|
||||
|
||||
function classifyLine(line: string): LogLine["type"] {
|
||||
if (/\[WARN\]|WARNING/i.test(line)) return "warn";
|
||||
if (/\[ERROR\]|SEVERE|Exception|Error/i.test(line)) return "error";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function LineColor({ type }: { type: LogLine["type"] }) {
|
||||
const colors = {
|
||||
info: "text-zinc-300",
|
||||
warn: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
raw: "text-zinc-500",
|
||||
};
|
||||
return colors[type];
|
||||
}
|
||||
|
||||
const MAX_LINES = 1000;
|
||||
|
||||
export default function ConsolePage() {
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io("/console", {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => setConnected(true));
|
||||
socket.on("disconnect", () => setConnected(false));
|
||||
socket.on("connect_error", () => {
|
||||
toast.error("Failed to connect to server console");
|
||||
});
|
||||
|
||||
socket.on("output", (data: { line: string; timestamp: number }) => {
|
||||
setLines((prev) => {
|
||||
const newLine: LogLine = {
|
||||
text: data.line,
|
||||
timestamp: data.timestamp,
|
||||
type: classifyLine(data.line),
|
||||
};
|
||||
const updated = [...prev, newLine];
|
||||
return updated.length > MAX_LINES ? updated.slice(-MAX_LINES) : updated;
|
||||
});
|
||||
});
|
||||
|
||||
// Receive buffered history on connect
|
||||
socket.on("history", (data: { lines: string[] }) => {
|
||||
const historicalLines = data.lines.map((line) => ({
|
||||
text: line,
|
||||
timestamp: Date.now(),
|
||||
type: classifyLine(line) as LogLine["type"],
|
||||
}));
|
||||
setLines(historicalLines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [lines]);
|
||||
|
||||
const sendCommand = useCallback(() => {
|
||||
const cmd = command.trim();
|
||||
if (!cmd || !socketRef.current) return;
|
||||
|
||||
// Add to local history
|
||||
setHistory((prev) => {
|
||||
const updated = [cmd, ...prev.filter((h) => h !== cmd)].slice(0, 50);
|
||||
return updated;
|
||||
});
|
||||
setHistoryIndex(-1);
|
||||
|
||||
// Echo to console
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ text: `> ${cmd}`, timestamp: Date.now(), type: "raw" },
|
||||
]);
|
||||
|
||||
socketRef.current.emit("command", { command: cmd });
|
||||
setCommand("");
|
||||
}, [command]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
sendCommand();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
setHistoryIndex(newIndex);
|
||||
if (history[newIndex]) setCommand(history[newIndex]);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.max(historyIndex - 1, -1);
|
||||
setHistoryIndex(newIndex);
|
||||
setCommand(newIndex === -1 ? "" : history[newIndex] ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (ts: number) =>
|
||||
new Date(ts).toLocaleTimeString("en", { hour12: false });
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Server Console</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time server output and command input
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
className={
|
||||
connected
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||
: "bg-red-500/20 text-red-400 border-red-500/30"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||
connected ? "bg-emerald-500" : "bg-red-500"
|
||||
} animate-pulse`}
|
||||
/>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
onClick={() => setLines([])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800 flex-1 min-h-0 flex flex-col">
|
||||
<CardHeader className="py-3 px-4 border-b border-zinc-800 flex-row items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-emerald-500" />
|
||||
<CardTitle className="text-sm font-medium text-zinc-400">
|
||||
Console Output
|
||||
</CardTitle>
|
||||
<span className="ml-auto text-xs text-zinc-600">
|
||||
{lines.length} lines
|
||||
</span>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 flex-1 min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
autoScrollRef.current =
|
||||
el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
|
||||
}}
|
||||
className="h-full overflow-y-auto font-mono text-xs p-4 space-y-0.5"
|
||||
style={{ maxHeight: "calc(100vh - 280px)" }}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-zinc-600">
|
||||
<Terminal className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p>Waiting for server output...</p>
|
||||
</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} className="flex gap-3 leading-5">
|
||||
<span className="text-zinc-700 shrink-0 select-none">
|
||||
{formatTime(line.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={`break-all ${
|
||||
{
|
||||
info: "text-zinc-300",
|
||||
warn: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
raw: "text-emerald-400",
|
||||
}[line.type]
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Command input */}
|
||||
<div className="p-3 border-t border-zinc-800 flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 bg-zinc-950 border border-zinc-700 rounded-md px-3 focus-within:border-emerald-500/50 transition-colors">
|
||||
<span className="text-emerald-500 font-mono text-sm select-none">
|
||||
/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter server command... (↑↓ for history)"
|
||||
disabled={!connected}
|
||||
className="flex-1 bg-transparent py-2 text-sm text-white placeholder:text-zinc-600 outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendCommand}
|
||||
disabled={!connected || !command.trim()}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!connected && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Console disconnected. The server may be offline or restarting.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
app/(dashboard)/files/page.tsx
Normal file
441
app/(dashboard)/files/page.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
FolderOpen,
|
||||
File,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "—";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([".txt", ".yml", ".yaml", ".json", ".properties", ".toml", ".cfg", ".conf", ".log", ".md", ".sh", ".ini"]);
|
||||
|
||||
function getEditorLanguage(name: string): string {
|
||||
const ext = name.split(".").pop() ?? "";
|
||||
const map: Record<string, string> = {
|
||||
json: "json", yml: "yaml", yaml: "yaml",
|
||||
properties: "ini", toml: "ini", cfg: "ini",
|
||||
sh: "shell", md: "markdown", log: "plaintext",
|
||||
};
|
||||
return map[ext] ?? "plaintext";
|
||||
}
|
||||
|
||||
function isEditable(name: string): boolean {
|
||||
const ext = "." + name.split(".").pop()?.toLowerCase();
|
||||
return TEXT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(null);
|
||||
const [editFile, setEditFile] = useState<{ path: string; content: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchEntries = useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`);
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
const data = await res.json();
|
||||
setEntries(data.entries);
|
||||
setCurrentPath(data.path);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to load directory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchEntries("/"); }, [fetchEntries]);
|
||||
|
||||
const handleOpen = (entry: FileEntry) => {
|
||||
if (entry.isDirectory) {
|
||||
fetchEntries(entry.path);
|
||||
} else if (isEditable(entry.name)) {
|
||||
openEditor(entry);
|
||||
} else {
|
||||
window.open(`/api/files/download?path=${encodeURIComponent(entry.path)}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = async (entry: FileEntry) => {
|
||||
try {
|
||||
const res = await fetch(`/api/files/download?path=${encodeURIComponent(entry.path)}`);
|
||||
const text = await res.text();
|
||||
setEditFile({ path: entry.path, content: text });
|
||||
} catch {
|
||||
toast.error("Failed to open file");
|
||||
}
|
||||
};
|
||||
|
||||
const saveFile = async () => {
|
||||
if (!editFile) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = new Blob([editFile.content], { type: "text/plain" });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const FileClass = (globalThis as any).File ?? Blob;
|
||||
const file = new FileClass([blob], editFile.path.split("/").pop() ?? "file") as File;
|
||||
const dir = editFile.path.split("/").slice(0, -1).join("/") || "/";
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(dir)}`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("File saved");
|
||||
setEditFile(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: FileEntry) => {
|
||||
try {
|
||||
const res = await fetch("/api/files/delete", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filePath: entry.path }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${entry.name} deleted`);
|
||||
fetchEntries(currentPath);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open: openUpload } = useDropzone({
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
onDrop: async (acceptedFiles) => {
|
||||
for (const file of acceptedFiles) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
try {
|
||||
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(currentPath)}`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${file.name} uploaded`);
|
||||
} catch (err) {
|
||||
toast.error(`Failed to upload ${file.name}`);
|
||||
}
|
||||
}
|
||||
fetchEntries(currentPath);
|
||||
},
|
||||
});
|
||||
|
||||
// Breadcrumbs
|
||||
const parts = currentPath === "/" ? [] : currentPath.split("/").filter(Boolean);
|
||||
const navigateUp = () => {
|
||||
if (parts.length === 0) return;
|
||||
const parent = parts.length === 1 ? "/" : "/" + parts.slice(0, -1).join("/");
|
||||
fetchEntries(parent);
|
||||
};
|
||||
|
||||
if (editFile) {
|
||||
const fileName = editFile.path.split("/").pop() ?? "file";
|
||||
return (
|
||||
<div className="p-6 h-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditFile(null)}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-1.5" />
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">{fileName}</h1>
|
||||
<p className="text-xs text-zinc-500 font-mono">{editFile.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={saveFile}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save File"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-zinc-800" style={{ minHeight: "500px" }}>
|
||||
<MonacoEditor
|
||||
height="100%"
|
||||
language={getEditorLanguage(fileName)}
|
||||
theme="vs-dark"
|
||||
value={editFile.content}
|
||||
onChange={(v) => setEditFile((prev) => prev ? { ...prev, content: v ?? "" } : null)}
|
||||
options={{
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
padding: { top: 16, bottom: 16 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4" {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{isDragActive && (
|
||||
<div className="fixed inset-0 z-50 bg-emerald-500/10 border-2 border-dashed border-emerald-500/50 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center text-emerald-400">
|
||||
<Upload className="w-12 h-12 mx-auto mb-2" />
|
||||
<p className="text-lg font-semibold">Drop files to upload</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">File Explorer</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Browse and manage Minecraft server files
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fetchEntries(currentPath)}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openUpload}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1.5" />
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-1 text-sm text-zinc-500">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fetchEntries("/")}
|
||||
className="h-7 px-2 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fetchEntries("/" + parts.slice(0, i + 1).join("/"))}
|
||||
className="h-7 px-2 text-zinc-400 hover:text-white"
|
||||
>
|
||||
{part}
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full bg-zinc-800" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<FolderOpen className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>Empty directory</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{parts.length > 0 && (
|
||||
<button
|
||||
onClick={navigateUp}
|
||||
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-zinc-600" />
|
||||
<span className="text-sm text-zinc-500">..</span>
|
||||
</button>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<ContextMenu key={entry.path}>
|
||||
<ContextMenuTrigger>
|
||||
<button
|
||||
onDoubleClick={() => handleOpen(entry)}
|
||||
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
|
||||
>
|
||||
{entry.isDirectory ? (
|
||||
<FolderOpen className="w-4 h-4 text-amber-500 shrink-0" />
|
||||
) : isEditable(entry.name) ? (
|
||||
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-zinc-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-sm text-zinc-300 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{!entry.isDirectory && (
|
||||
<span className="text-xs text-zinc-600 shrink-0">
|
||||
{formatBytes(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-600 shrink-0 hidden sm:block">
|
||||
{entry.modifiedAt
|
||||
? formatDistanceToNow(new Date(entry.modifiedAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="bg-zinc-900 border-zinc-700">
|
||||
{entry.isDirectory ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => fetchEntries(entry.path)}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{isEditable(entry.name) && (
|
||||
<ContextMenuItem
|
||||
onClick={() => openEditor(entry)}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/api/files/download?path=${encodeURIComponent(entry.path)}`,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator className="bg-zinc-700" />
|
||||
<ContextMenuItem
|
||||
onClick={() => setDeleteTarget(entry)}
|
||||
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-zinc-600 text-center">
|
||||
Double-click to open files/folders • Right-click for options • Drag and drop to upload
|
||||
</p>
|
||||
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">
|
||||
Delete {deleteTarget?.isDirectory ? "folder" : "file"}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-zinc-400">
|
||||
<span className="font-mono text-white">{deleteTarget?.name}</span>{" "}
|
||||
will be permanently deleted.
|
||||
{deleteTarget?.isDirectory && " All contents will be removed."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTarget && handleDelete(deleteTarget)}
|
||||
className="bg-red-600 hover:bg-red-500 text-white"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/(dashboard)/layout.tsx
Normal file
31
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard Layout
|
||||
// Renders the persistent sidebar + topbar shell around all dashboard pages.
|
||||
// Auth protection is handled in middleware.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-zinc-950">
|
||||
{/* Fixed sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content column */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Sticky topbar */}
|
||||
<Topbar />
|
||||
|
||||
{/* Scrollable page content */}
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950">
|
||||
<div className="min-h-full p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/(dashboard)/map/page.tsx
Normal file
127
app/(dashboard)/map/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Map, ExternalLink, AlertCircle } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export default function MapPage() {
|
||||
const [bluemapUrl, setBluemapUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false);
|
||||
const [iframeError, setIframeError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch BlueMap URL from server settings
|
||||
fetch("/api/server/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setBluemapUrl(data?.settings?.bluemapUrl ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-48 bg-zinc-800" />
|
||||
<Skeleton className="h-[70vh] w-full bg-zinc-800 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bluemapUrl) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Interactive BlueMap integration
|
||||
</p>
|
||||
</div>
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20">
|
||||
<Map className="w-12 h-12 text-zinc-600 mb-4" />
|
||||
<h3 className="text-white font-semibold mb-2">
|
||||
BlueMap not configured
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-sm text-center max-w-md mb-6">
|
||||
BlueMap is a 3D world map plugin for Minecraft servers. Configure
|
||||
its URL in <strong>Server Settings</strong> to embed it here.
|
||||
</p>
|
||||
<a href="/server" className={buttonVariants({ className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>Go to Server Settings</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Powered by BlueMap — live world view with player positions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs">
|
||||
BlueMap
|
||||
</Badge>
|
||||
<a href={bluemapUrl} target="_blank" rel="noopener noreferrer" className={buttonVariants({ variant: "outline", size: "sm", className: "border-zinc-700 text-zinc-400 hover:text-white" })}>
|
||||
<ExternalLink className="w-4 h-4 mr-1.5" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-zinc-800 overflow-hidden bg-zinc-950 relative">
|
||||
{!iframeLoaded && !iframeError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
|
||||
<div className="flex flex-col items-center gap-3 text-zinc-500">
|
||||
<div className="w-8 h-8 border-2 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin" />
|
||||
<p className="text-sm">Loading BlueMap...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iframeError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
|
||||
<div className="flex flex-col items-center gap-3 text-zinc-500 max-w-sm text-center">
|
||||
<AlertCircle className="w-10 h-10 text-amber-500" />
|
||||
<p className="text-white font-medium">Could not load BlueMap</p>
|
||||
<p className="text-sm">
|
||||
Make sure BlueMap is running at{" "}
|
||||
<code className="text-emerald-400 text-xs">{bluemapUrl}</code>{" "}
|
||||
and that the URL is accessible from your browser.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400"
|
||||
onClick={() => {
|
||||
setIframeError(false);
|
||||
setIframeLoaded(false);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={bluemapUrl}
|
||||
className="w-full h-full border-0"
|
||||
style={{ minHeight: "600px" }}
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
onError={() => setIframeError(true)}
|
||||
title="BlueMap 3D World Map"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
app/(dashboard)/monitoring/page.tsx
Normal file
269
app/(dashboard)/monitoring/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from "recharts";
|
||||
import { Activity, HardDrive, Clock, Cpu, Server } from "lucide-react";
|
||||
|
||||
interface DataPoint {
|
||||
time: string;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
players: number;
|
||||
}
|
||||
|
||||
interface MonitoringData {
|
||||
system: {
|
||||
cpuPercent: number;
|
||||
totalMemMb: number;
|
||||
usedMemMb: number;
|
||||
loadAvg: number[];
|
||||
uptime: number;
|
||||
};
|
||||
server: { running: boolean; uptime?: number };
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 60; // 60 data points (e.g. 2 minutes at 2s intervals)
|
||||
|
||||
function formatUptime(s: number) {
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [data, setData] = useState<MonitoringData | null>(null);
|
||||
const [history, setHistory] = useState<DataPoint[]>([]);
|
||||
const socketRef = useRef<ReturnType<typeof io> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Use REST polling (Socket.io monitoring namespace is optional here)
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring");
|
||||
if (!res.ok) return;
|
||||
const json: MonitoringData = await res.json();
|
||||
setData(json);
|
||||
const time = new Date(json.timestamp).toLocaleTimeString("en", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
setHistory((prev) => {
|
||||
const updated = [
|
||||
...prev,
|
||||
{
|
||||
time,
|
||||
cpu: json.system.cpuPercent,
|
||||
memory: Math.round(
|
||||
(json.system.usedMemMb / json.system.totalMemMb) * 100,
|
||||
),
|
||||
players: 0,
|
||||
},
|
||||
];
|
||||
return updated.length > MAX_HISTORY
|
||||
? updated.slice(-MAX_HISTORY)
|
||||
: updated;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const memPercent = data
|
||||
? Math.round((data.system.usedMemMb / data.system.totalMemMb) * 100)
|
||||
: 0;
|
||||
|
||||
const chartTheme = {
|
||||
grid: "#27272a",
|
||||
axis: "#52525b",
|
||||
tooltip: { bg: "#18181b", border: "#3f3f46", text: "#fff" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Monitoring</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time system and server performance metrics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
title: "CPU",
|
||||
value: `${data?.system.cpuPercent ?? 0}%`,
|
||||
icon: Cpu,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "Memory",
|
||||
value: `${memPercent}%`,
|
||||
icon: HardDrive,
|
||||
color: "text-emerald-500",
|
||||
bg: "bg-emerald-500/10",
|
||||
sub: `${data?.system.usedMemMb ?? 0} / ${data?.system.totalMemMb ?? 0} MB`,
|
||||
},
|
||||
{
|
||||
title: "Load Avg",
|
||||
value: data?.system.loadAvg[0].toFixed(2) ?? "—",
|
||||
icon: Activity,
|
||||
color: "text-amber-500",
|
||||
bg: "bg-amber-500/10",
|
||||
},
|
||||
{
|
||||
title: "Uptime",
|
||||
value: data ? formatUptime(data.system.uptime) : "—",
|
||||
icon: Clock,
|
||||
color: "text-violet-500",
|
||||
bg: "bg-violet-500/10",
|
||||
},
|
||||
].map(({ title, value, icon: Icon, color, bg, sub }) => (
|
||||
<Card key={title} className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<p className="text-sm text-zinc-400">{title}</p>
|
||||
<div className={`p-2 rounded-lg ${bg}`}>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPU chart */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-blue-500" />
|
||||
CPU Usage Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: chartTheme.tooltip.bg,
|
||||
border: `1px solid ${chartTheme.tooltip.border}`,
|
||||
borderRadius: 8,
|
||||
color: chartTheme.tooltip.text,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(v) => [`${v}%`, "CPU"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="url(#cpuGrad)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "#3b82f6" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory chart */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-emerald-500" />
|
||||
Memory Usage Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: chartTheme.tooltip.bg,
|
||||
border: `1px solid ${chartTheme.tooltip.border}`,
|
||||
borderRadius: 8,
|
||||
color: chartTheme.tooltip.text,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(v) => [`${v}%`, "Memory"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="url(#memGrad)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "#10b981" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
app/(dashboard)/page.tsx
Normal file
277
app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Users,
|
||||
Puzzle,
|
||||
HardDrive,
|
||||
Activity,
|
||||
Clock,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MonitoringData {
|
||||
system: {
|
||||
cpuPercent: number;
|
||||
totalMemMb: number;
|
||||
usedMemMb: number;
|
||||
loadAvg: number[];
|
||||
uptime: number;
|
||||
};
|
||||
server: {
|
||||
running: boolean;
|
||||
uptime?: number;
|
||||
startedAt?: string;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface StatsData {
|
||||
totalPlayers: number;
|
||||
onlinePlayers: number;
|
||||
enabledPlugins: number;
|
||||
totalPlugins: number;
|
||||
pendingBackups: number;
|
||||
recentAlerts: string[];
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
accent = "emerald",
|
||||
loading = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: React.ElementType;
|
||||
accent?: "emerald" | "blue" | "amber" | "red";
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const colors = {
|
||||
emerald: "text-emerald-500 bg-emerald-500/10",
|
||||
blue: "text-blue-500 bg-blue-500/10",
|
||||
amber: "text-amber-500 bg-amber-500/10",
|
||||
red: "text-red-500 bg-red-500/10",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-zinc-400 mb-1">{title}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20 bg-zinc-800" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-zinc-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2.5 rounded-lg ${colors[accent]}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [monitoring, setMonitoring] = useState<MonitoringData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitoring = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring");
|
||||
if (res.ok) setMonitoring(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonitoring();
|
||||
const interval = setInterval(fetchMonitoring, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const memPercent = monitoring
|
||||
? Math.round((monitoring.system.usedMemMb / monitoring.system.totalMemMb) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time overview of your Minecraft server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
<div
|
||||
className={`flex items-center gap-3 p-4 rounded-xl border ${
|
||||
monitoring?.server.running
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
|
||||
: "bg-red-500/10 border-red-500/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full animate-pulse ${
|
||||
monitoring?.server.running ? "bg-emerald-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
Server is {monitoring?.server.running ? "Online" : "Offline"}
|
||||
</span>
|
||||
{monitoring?.server.running && monitoring.server.uptime && (
|
||||
<span className="text-sm opacity-70 ml-auto">
|
||||
Up for {formatUptime(monitoring.server.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="CPU Usage"
|
||||
value={loading ? "—" : `${monitoring?.system.cpuPercent ?? 0}%`}
|
||||
subtitle={`Load avg: ${monitoring?.system.loadAvg[0].toFixed(2) ?? "—"}`}
|
||||
icon={Activity}
|
||||
accent="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Memory"
|
||||
value={loading ? "—" : `${monitoring?.system.usedMemMb ?? 0} MB`}
|
||||
subtitle={`of ${monitoring?.system.totalMemMb ?? 0} MB total`}
|
||||
icon={HardDrive}
|
||||
accent={memPercent > 85 ? "red" : memPercent > 60 ? "amber" : "emerald"}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="System Uptime"
|
||||
value={loading ? "—" : formatUptime(monitoring?.system.uptime ?? 0)}
|
||||
icon={Clock}
|
||||
accent="emerald"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Server Status"
|
||||
value={monitoring?.server.running ? "Online" : "Offline"}
|
||||
icon={Zap}
|
||||
accent={monitoring?.server.running ? "emerald" : "red"}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource gauges */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
CPU Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-4 w-full bg-zinc-800" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-white font-medium">
|
||||
{monitoring?.system.cpuPercent ?? 0}%
|
||||
</span>
|
||||
<span className="text-zinc-500">100%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={monitoring?.system.cpuPercent ?? 0}
|
||||
className="h-2 bg-zinc-800"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-emerald-500" />
|
||||
Memory Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-4 w-full bg-zinc-800" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-white font-medium">
|
||||
{monitoring?.system.usedMemMb ?? 0} MB
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{monitoring?.system.totalMemMb ?? 0} MB
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={memPercent}
|
||||
className="h-2 bg-zinc-800"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick info */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-500" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "View Console", href: "/console", icon: "⌨️" },
|
||||
{ label: "Manage Players", href: "/players", icon: "👥" },
|
||||
{ label: "Plugins", href: "/plugins", icon: "🔌" },
|
||||
{ label: "Create Backup", href: "/backups", icon: "💾" },
|
||||
].map(({ label, href, icon }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-sm text-zinc-300 hover:text-white"
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
386
app/(dashboard)/players/page.tsx
Normal file
386
app/(dashboard)/players/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Ban,
|
||||
UserX,
|
||||
Shield,
|
||||
Clock,
|
||||
Users,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
uuid: string | null;
|
||||
username: string;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
isOnline: boolean;
|
||||
playTime: number;
|
||||
role: string | null;
|
||||
isBanned: boolean;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
function PlayerAvatar({ username }: { username: string }) {
|
||||
return (
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage
|
||||
src={`https://crafatar.com/avatars/${username}?size=32&overlay`}
|
||||
alt={username}
|
||||
/>
|
||||
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
|
||||
{username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlayTime(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h === 0) return `${m}m`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function PlayersPage() {
|
||||
const [players, setPlayers] = useState<Player[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "online" | "banned">("all");
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||
const [banReason, setBanReason] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const fetchPlayers = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: search, limit: "100" });
|
||||
if (filter === "online") params.set("online", "true");
|
||||
if (filter === "banned") params.set("banned", "true");
|
||||
const res = await fetch(`/api/players?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlayers(data.players);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchPlayers();
|
||||
}, [fetchPlayers]);
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!selectedPlayer || !banReason.trim()) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/players/${selectedPlayer.id}?action=ban`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: banReason }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${selectedPlayer.username} has been banned`);
|
||||
setBanDialogOpen(false);
|
||||
setBanReason("");
|
||||
fetchPlayers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to ban player");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async (player: Player) => {
|
||||
try {
|
||||
const res = await fetch(`/api/players/${player.id}?action=unban`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${player.username} has been unbanned`);
|
||||
fetchPlayers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to unban player");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKick = async (player: Player) => {
|
||||
try {
|
||||
const res = await fetch(`/api/players/${player.id}?action=kick`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "Kicked by admin" }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${player.username} has been kicked`);
|
||||
fetchPlayers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to kick player");
|
||||
}
|
||||
};
|
||||
|
||||
const onlinePlayers = players.filter((p) => p.isOnline).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Players</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage players, bans, and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse" />
|
||||
{onlinePlayers} online
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
onClick={fetchPlayers}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search players..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "online", "banned"] as const).map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={filter === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilter(f)}
|
||||
className={
|
||||
filter === f
|
||||
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
: "border-zinc-700 text-zinc-400 hover:text-white"
|
||||
}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Players table */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
|
||||
))}
|
||||
</div>
|
||||
) : players.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<Users className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No players found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
<span className="w-8" />
|
||||
<span>Player</span>
|
||||
<span>Status</span>
|
||||
<span>Play Time</span>
|
||||
<span>Last Seen</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{players.map((player) => (
|
||||
<div
|
||||
key={player.id}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<PlayerAvatar username={player.username} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{player.username}
|
||||
</span>
|
||||
{player.role && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-zinc-700 text-zinc-400"
|
||||
>
|
||||
{player.role}
|
||||
</Badge>
|
||||
)}
|
||||
{player.isBanned && (
|
||||
<Badge className="text-xs bg-red-500/20 text-red-400 border-red-500/30">
|
||||
Banned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{player.uuid ?? "UUID unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
player.isOnline ? "bg-emerald-500" : "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
player.isOnline ? "text-emerald-400" : "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{player.isOnline ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">
|
||||
{formatPlayTime(player.playTime)}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
{formatDistanceToNow(new Date(player.lastSeen), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-900 border-zinc-700"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
onClick={() =>
|
||||
(window.location.href = `/players/${player.id}`)
|
||||
}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
View Profile
|
||||
</DropdownMenuItem>
|
||||
{player.isOnline && (
|
||||
<DropdownMenuItem
|
||||
className="text-amber-400 focus:text-amber-300 focus:bg-zinc-800"
|
||||
onClick={() => handleKick(player)}
|
||||
>
|
||||
<WifiOff className="w-4 h-4 mr-2" />
|
||||
Kick
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator className="bg-zinc-700" />
|
||||
{player.isBanned ? (
|
||||
<DropdownMenuItem
|
||||
className="text-emerald-400 focus:text-emerald-300 focus:bg-zinc-800"
|
||||
onClick={() => handleUnban(player)}
|
||||
>
|
||||
<UserX className="w-4 h-4 mr-2" />
|
||||
Unban
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setBanDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ban className="w-4 h-4 mr-2" />
|
||||
Ban
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ban Dialog */}
|
||||
<Dialog open={banDialogOpen} onOpenChange={setBanDialogOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
Ban {selectedPlayer?.username}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">
|
||||
This will ban the player from the server and record the ban in the
|
||||
history.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-zinc-300">Reason</Label>
|
||||
<Textarea
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder="Enter ban reason..."
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBanDialogOpen(false)}
|
||||
className="border-zinc-700 text-zinc-400"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBan}
|
||||
disabled={!banReason.trim() || actionLoading}
|
||||
className="bg-red-600 hover:bg-red-500 text-white"
|
||||
>
|
||||
{actionLoading ? "Banning..." : "Ban Player"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
app/(dashboard)/plugins/page.tsx
Normal file
275
app/(dashboard)/plugins/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Puzzle,
|
||||
Search,
|
||||
RefreshCw,
|
||||
MoreHorizontal,
|
||||
Power,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string | null;
|
||||
description: string | null;
|
||||
isEnabled: boolean;
|
||||
jarFile: string | null;
|
||||
installedAt: number;
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
const [jarFiles, setJarFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/plugins");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlugins(data.plugins);
|
||||
setJarFiles(data.jarFiles);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins();
|
||||
}, [fetchPlugins]);
|
||||
|
||||
const handleAction = async (
|
||||
name: string,
|
||||
action: "enable" | "disable" | "reload",
|
||||
) => {
|
||||
setActionLoading(`${name}-${action}`);
|
||||
try {
|
||||
const res = await fetch(`/api/plugins?action=${action}&name=${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
const labels = { enable: "enabled", disable: "disabled", reload: "reloaded" };
|
||||
toast.success(`${name} ${labels[action]}`);
|
||||
fetchPlugins();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Action failed");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = plugins.filter((p) =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const enabledCount = plugins.filter((p) => p.isEnabled).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Plugins</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage your server plugins
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
|
||||
{enabledCount} / {plugins.length} active
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchPlugins}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<a href="/files?path=plugins" className={buttonVariants({ size: "sm", className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>
|
||||
<Upload className="w-4 h-4 mr-1.5" />
|
||||
Upload Plugin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search plugins..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plugins grid */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 bg-zinc-800 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-600">
|
||||
<Puzzle className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>
|
||||
{plugins.length === 0
|
||||
? "No plugins installed"
|
||||
: "No plugins match your search"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((plugin) => (
|
||||
<Card
|
||||
key={plugin.id}
|
||||
className={`bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors ${
|
||||
!plugin.isEnabled ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`p-1.5 rounded-md ${
|
||||
plugin.isEnabled
|
||||
? "bg-emerald-500/10"
|
||||
: "bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<Puzzle
|
||||
className={`w-4 h-4 ${
|
||||
plugin.isEnabled
|
||||
? "text-emerald-500"
|
||||
: "text-zinc-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{plugin.name}
|
||||
</p>
|
||||
{plugin.version && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
v{plugin.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-7 h-7 text-zinc-500 hover:text-white transition-all">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-zinc-900 border-zinc-700"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
plugin.name,
|
||||
plugin.isEnabled ? "disable" : "enable",
|
||||
)
|
||||
}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<Power className="w-4 h-4 mr-2" />
|
||||
{plugin.isEnabled ? "Disable" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAction(plugin.name, "reload")}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
disabled={!plugin.isEnabled || !!actionLoading}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reload
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-zinc-500 line-clamp-2 mb-3">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-800">
|
||||
<Badge
|
||||
className={
|
||||
plugin.isEnabled
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs"
|
||||
: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30 text-xs"
|
||||
}
|
||||
>
|
||||
{plugin.isEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={plugin.isEnabled}
|
||||
disabled={
|
||||
actionLoading === `${plugin.name}-enable` ||
|
||||
actionLoading === `${plugin.name}-disable`
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleAction(plugin.name, checked ? "enable" : "disable")
|
||||
}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jar files not in DB */}
|
||||
{jarFiles.filter(
|
||||
(jar) => !plugins.find((p) => p.jarFile === jar || p.name + ".jar" === jar),
|
||||
).length > 0 && (
|
||||
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-500">
|
||||
Jar files detected (not yet in database)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jarFiles.map((jar) => (
|
||||
<Badge
|
||||
key={jar}
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-zinc-400 text-xs"
|
||||
>
|
||||
{jar}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
346
app/(dashboard)/scheduler/page.tsx
Normal file
346
app/(dashboard)/scheduler/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Clock, Plus, MoreHorizontal, Trash2, Edit, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
cronExpression: string;
|
||||
command: string;
|
||||
isEnabled: boolean;
|
||||
lastRun: number | null;
|
||||
nextRun: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const CRON_PRESETS = [
|
||||
{ label: "Every minute", value: "* * * * *" },
|
||||
{ label: "Every 5 minutes", value: "*/5 * * * *" },
|
||||
{ label: "Every hour", value: "0 * * * *" },
|
||||
{ label: "Every day at midnight", value: "0 0 * * *" },
|
||||
{ label: "Every Sunday at 3am", value: "0 3 * * 0" },
|
||||
];
|
||||
|
||||
function TaskForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
initial?: Partial<Task>;
|
||||
onSubmit: (data: Omit<Task, "id" | "lastRun" | "nextRun" | "createdAt">) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [cronExpression, setCronExpression] = useState(initial?.cronExpression ?? "0 0 * * *");
|
||||
const [command, setCommand] = useState(initial?.command ?? "");
|
||||
const [isEnabled, setIsEnabled] = useState(initial?.isEnabled ?? true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-zinc-300">Task Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Daily restart"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-zinc-300">Description (optional)</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-zinc-300">Cron Expression</Label>
|
||||
<Input
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="* * * * *"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{CRON_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => setCronExpression(p.value)}
|
||||
className="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white transition-colors"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-zinc-300">Minecraft Command</Label>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="e.g. say Server restart in 5 minutes"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
Enter a Minecraft command (without leading /) to execute via RCON.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={setIsEnabled}
|
||||
/>
|
||||
<Label className="text-zinc-300">Enable immediately</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} className="border-zinc-700 text-zinc-400">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onSubmit({ name, description: description || null, cronExpression, command, isEnabled })}
|
||||
disabled={!name || !cronExpression || !command || loading}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulerPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTask, setEditTask] = useState<Task | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/scheduler");
|
||||
if (res.ok) setTasks((await res.json()).tasks);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
|
||||
const handleCreate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/scheduler", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Task created");
|
||||
setDialogOpen(false);
|
||||
fetchTasks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
|
||||
if (!editTask) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/scheduler/${editTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Task updated");
|
||||
setEditTask(null);
|
||||
fetchTasks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/scheduler/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Task deleted");
|
||||
setTasks((p) => p.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||
}
|
||||
setDeleteId(null);
|
||||
};
|
||||
|
||||
const toggleTask = async (task: Task) => {
|
||||
try {
|
||||
const res = await fetch(`/api/scheduler/${task.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isEnabled: !task.isEnabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
fetchTasks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to toggle");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Scheduler</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">Automated recurring tasks</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchTasks} className="border-zinc-700 text-zinc-400 hover:text-white">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> New Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{[1,2,3].map(i => <Skeleton key={i} className="h-16 w-full bg-zinc-800" />)}
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<Clock className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No scheduled tasks</p>
|
||||
<p className="text-sm mt-1">Create a task to automate server commands</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className="flex items-center gap-4 px-4 py-4 hover:bg-zinc-800/50 transition-colors">
|
||||
<Switch checked={task.isEnabled} onCheckedChange={() => toggleTask(task)} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<p className="text-sm font-medium text-white">{task.name}</p>
|
||||
{task.description && (
|
||||
<span className="text-xs text-zinc-500">— {task.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<code className="text-xs text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded">{task.cronExpression}</code>
|
||||
<code className="text-xs text-zinc-400 font-mono">{task.command}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{task.lastRun ? (
|
||||
<p className="text-xs text-zinc-500">Last: {formatDistanceToNow(new Date(task.lastRun), { addSuffix: true })}</p>
|
||||
) : (
|
||||
<p className="text-xs text-zinc-600">Never run</p>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
|
||||
<DropdownMenuItem onClick={() => setEditTask(task)} className="text-zinc-300 focus:text-white focus:bg-zinc-800">
|
||||
<Edit className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteId(task.id)} className="text-red-400 focus:text-red-300 focus:bg-zinc-800">
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Scheduled Task</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">Schedule a Minecraft command to run automatically.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskForm onSubmit={handleCreate} onCancel={() => setDialogOpen(false)} loading={saving} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={!!editTask} onOpenChange={(o) => !o && setEditTask(null)}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editTask && <TaskForm initial={editTask} onSubmit={handleUpdate} onCancel={() => setEditTask(null)} loading={saving} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">Delete task?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-zinc-400">This will stop and remove the scheduled task permanently.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteId && handleDelete(deleteId)} className="bg-red-600 hover:bg-red-500 text-white">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
app/(dashboard)/server/page.tsx
Normal file
362
app/(dashboard)/server/page.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Settings, RefreshCw, Download, Server, Map } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ServerSettings {
|
||||
minecraftPath?: string;
|
||||
serverJar?: string;
|
||||
serverVersion?: string;
|
||||
serverType?: string;
|
||||
maxRam?: number;
|
||||
minRam?: number;
|
||||
rconEnabled?: boolean;
|
||||
rconPort?: number;
|
||||
javaArgs?: string;
|
||||
autoStart?: boolean;
|
||||
restartOnCrash?: boolean;
|
||||
backupEnabled?: boolean;
|
||||
backupSchedule?: string;
|
||||
bluemapEnabled?: boolean;
|
||||
bluemapUrl?: string;
|
||||
}
|
||||
|
||||
const SERVER_TYPES = ["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"];
|
||||
|
||||
export default function ServerPage() {
|
||||
const [settings, setSettings] = useState<ServerSettings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [versions, setVersions] = useState<string[]>([]);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState("paper");
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/server/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.settings) {
|
||||
setSettings(data.settings);
|
||||
setSelectedType(data.settings.serverType ?? "paper");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchVersions = useCallback(async (type: string) => {
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const res = await fetch(`/api/server/versions?type=${type}`);
|
||||
if (res.ok) setVersions((await res.json()).versions);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSettings(); }, [fetchSettings]);
|
||||
useEffect(() => { fetchVersions(selectedType); }, [selectedType, fetchVersions]);
|
||||
|
||||
const save = async (updates: Partial<ServerSettings>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/server/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
toast.success("Settings saved");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-48 bg-zinc-800" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full bg-zinc-800 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Server Settings</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Configure your Minecraft server and CubeAdmin options
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList className="bg-zinc-900 border border-zinc-800">
|
||||
<TabsTrigger value="general" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="performance" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
Performance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="updates" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
Updates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="integrations" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
Integrations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-emerald-500" />
|
||||
Server Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Minecraft Server Path</Label>
|
||||
<Input
|
||||
value={settings.minecraftPath ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, minecraftPath: e.target.value }))}
|
||||
placeholder="/opt/minecraft/server"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Server JAR filename</Label>
|
||||
<Input
|
||||
value={settings.serverJar ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, serverJar: e.target.value }))}
|
||||
placeholder="server.jar"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300">Auto-start on boot</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Start server when CubeAdmin starts</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.autoStart ?? false}
|
||||
onCheckedChange={(v) => save({ autoStart: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300">Auto-restart on crash</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Automatically restart if server crashes</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.restartOnCrash ?? false}
|
||||
onCheckedChange={(v) => save({ restartOnCrash: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => save({ minecraftPath: settings.minecraftPath, serverJar: settings.serverJar })}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Performance */}
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300">JVM Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Min RAM (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.minRam ?? 512}
|
||||
onChange={(e) => setSettings(p => ({ ...p, minRam: parseInt(e.target.value) }))}
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
min={256}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Max RAM (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.maxRam ?? 2048}
|
||||
onChange={(e) => setSettings(p => ({ ...p, maxRam: parseInt(e.target.value) }))}
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
min={512}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Additional Java Arguments</Label>
|
||||
<Input
|
||||
value={settings.javaArgs ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, javaArgs: e.target.value }))}
|
||||
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Aikar's flags are applied by default with the Docker image.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => save({ minRam: settings.minRam, maxRam: settings.maxRam, javaArgs: settings.javaArgs })}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Updates */}
|
||||
<TabsContent value="updates" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-emerald-500" />
|
||||
Server Version
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Server Type</Label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(v) => { if (v) { setSelectedType(v); fetchVersions(v); } }}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
{SERVER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-zinc-300 focus:bg-zinc-700 focus:text-white capitalize">
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Version</Label>
|
||||
<Select
|
||||
value={settings.serverVersion ?? ""}
|
||||
onValueChange={(v) => setSettings(p => ({ ...p, serverVersion: v ?? undefined }))}
|
||||
disabled={loadingVersions}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue placeholder={loadingVersions ? "Loading..." : "Select version"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-60">
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
|
||||
<span>⚠️</span>
|
||||
<p>Changing server version requires a server restart. Always backup first!</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => save({ serverType: selectedType, serverVersion: settings.serverVersion })}
|
||||
disabled={saving || !settings.serverVersion}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Apply Version"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Integrations */}
|
||||
<TabsContent value="integrations" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Map className="w-4 h-4 text-emerald-500" />
|
||||
BlueMap Integration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300">Enable BlueMap</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Show the 3D map in the Map section</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.bluemapEnabled ?? false}
|
||||
onCheckedChange={(v) => save({ bluemapEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
{settings.bluemapEnabled && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">BlueMap URL</Label>
|
||||
<Input
|
||||
value={settings.bluemapUrl ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, bluemapUrl: e.target.value }))}
|
||||
placeholder="http://localhost:8100"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
The URL where BlueMap is accessible from your browser.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => save({ bluemapEnabled: settings.bluemapEnabled, bluemapUrl: settings.bluemapUrl })}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
app/(dashboard)/team/page.tsx
Normal file
239
app/(dashboard)/team/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { UserPlus, Mail, Clock, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface TeamUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
createdAt: number;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
expiresAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const ROLE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
superadmin: { label: "Super Admin", color: "bg-amber-500/20 text-amber-400 border-amber-500/30" },
|
||||
admin: { label: "Admin", color: "bg-blue-500/20 text-blue-400 border-blue-500/30" },
|
||||
moderator: { label: "Moderator", color: "bg-violet-500/20 text-violet-400 border-violet-500/30" },
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
const [users, setUsers] = useState<TeamUser[]>([]);
|
||||
const [invites, setInvites] = useState<PendingInvite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<"admin" | "moderator">("moderator");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
||||
const fetchTeam = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/team");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsers(data.users);
|
||||
setInvites(data.pendingInvites ?? []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTeam(); }, [fetchTeam]);
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail) return;
|
||||
setInviting(true);
|
||||
try {
|
||||
const res = await fetch("/api/team", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
toast.success(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteOpen(false);
|
||||
setInviteEmail("");
|
||||
fetchTeam();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to send invitation");
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Team</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage who can access the CubeAdmin panel
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchTeam} className="border-zinc-700 text-zinc-400 hover:text-white">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setInviteOpen(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active members */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-14 w-full bg-zinc-800" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Active Members ({users.length})
|
||||
</div>
|
||||
{users.map(user => (
|
||||
<div key={user.id} className="flex items-center gap-4 px-4 py-3 hover:bg-zinc-800/50 transition-colors">
|
||||
<Avatar className="w-9 h-9 shrink-0">
|
||||
<AvatarImage src={user.image ?? undefined} />
|
||||
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-sm">
|
||||
{user.name?.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">{user.name}</p>
|
||||
<p className="text-xs text-zinc-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
<Badge className={`text-xs ${ROLE_CONFIG[user.role ?? ""]?.color ?? "bg-zinc-500/20 text-zinc-400"}`}>
|
||||
{ROLE_CONFIG[user.role ?? ""]?.label ?? user.role ?? "No role"}
|
||||
</Badge>
|
||||
<p className="text-xs text-zinc-600 hidden sm:block shrink-0">
|
||||
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending invites */}
|
||||
{invites.length > 0 && (
|
||||
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Pending Invitations ({invites.length})
|
||||
</div>
|
||||
{invites.map(invite => (
|
||||
<div key={invite.id} className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-800 flex items-center justify-center shrink-0">
|
||||
<Mail className="w-4 h-4 text-zinc-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-300">{invite.email}</p>
|
||||
<p className="text-xs text-zinc-500 flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Expires {formatDistanceToNow(new Date(invite.expiresAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`text-xs ${ROLE_CONFIG[invite.role]?.color ?? ""}`}>
|
||||
{ROLE_CONFIG[invite.role]?.label ?? invite.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invite dialog */}
|
||||
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Invite Team Member</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">
|
||||
They'll receive an email with a link to create their account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Email Address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="teammate@example.com"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(v) => { if (v === "admin" || v === "moderator") setInviteRole(v); }}>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
<SelectItem value="admin" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
|
||||
Admin — Full access except team management
|
||||
</SelectItem>
|
||||
<SelectItem value="moderator" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
|
||||
Moderator — Player management only
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInviteOpen(false)} className="border-zinc-700 text-zinc-400">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInvite}
|
||||
disabled={!inviteEmail || inviting}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
{inviting ? "Sending..." : "Send Invitation"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
app/api/accept-invite/route.ts
Normal file
71
app/api/accept-invite/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { invitations, users } from "@/lib/db/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const AcceptSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
name: z.string().min(2).max(100),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: z.infer<typeof AcceptSchema>;
|
||||
try {
|
||||
body = AcceptSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(invitations)
|
||||
.where(eq(invitations.token, body.token))
|
||||
.get();
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: "Invalid invitation token" }, { status: 404 });
|
||||
}
|
||||
if (invitation.acceptedAt) {
|
||||
return NextResponse.json({ error: "Invitation already used" }, { status: 409 });
|
||||
}
|
||||
if (Number(invitation.expiresAt) < Date.now()) {
|
||||
return NextResponse.json({ error: "Invitation has expired" }, { status: 410 });
|
||||
}
|
||||
|
||||
// Check if email already registered
|
||||
const existing = await db.select().from(users).where(eq(users.email, invitation.email)).get();
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Create user via Better Auth
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: invitation.email,
|
||||
password: body.password,
|
||||
name: body.name,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create account";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Mark invitation as accepted and set role
|
||||
await db
|
||||
.update(invitations)
|
||||
.set({ acceptedAt: Date.now() })
|
||||
.where(eq(invitations.token, body.token));
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ role: invitation.role })
|
||||
.where(eq(users.email, invitation.email));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
48
app/api/audit/route.ts
Normal file
48
app/api/audit/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs, users } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
|
||||
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "50"));
|
||||
const offset = (page - 1) * limit;
|
||||
const userId = searchParams.get("userId");
|
||||
const action = searchParams.get("action");
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
|
||||
const conditions = [];
|
||||
if (userId) conditions.push(eq(auditLogs.userId, userId));
|
||||
if (action) conditions.push(like(auditLogs.action, `${action}%`));
|
||||
if (from) conditions.push(gte(auditLogs.createdAt, parseInt(from)));
|
||||
if (to) conditions.push(lte(auditLogs.createdAt, parseInt(to)));
|
||||
|
||||
const logs = await db
|
||||
.select({
|
||||
log: auditLogs,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.leftJoin(users, eq(auditLogs.userId, users.id))
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return NextResponse.json({ logs, page, limit });
|
||||
}
|
||||
56
app/api/backups/[id]/route.ts
Normal file
56
app/api/backups/[id]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deleteBackup } from "@/lib/backup/manager";
|
||||
import { db } from "@/lib/db";
|
||||
import { backups } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
await deleteBackup(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const backup = await db.select().from(backups).where(eq(backups.id, id)).get();
|
||||
if (!backup) return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||
if (backup.status !== "completed") {
|
||||
return NextResponse.json({ error: "Backup not ready" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!backup.path || !fs.existsSync(backup.path)) {
|
||||
return NextResponse.json({ error: "Backup file not found on disk" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(backup.path);
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(backup.name)}"`,
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(fileBuffer.length),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
48
app/api/backups/route.ts
Normal file
48
app/api/backups/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateBackupSchema = z.object({
|
||||
type: z.enum(["worlds", "plugins", "config", "full"]),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const backups = await listBackups();
|
||||
return NextResponse.json({ backups });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 5); // Strict limit for backup creation
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof CreateBackupSchema>;
|
||||
try {
|
||||
body = CreateBackupSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createBackup(body.type as BackupType, session.user.id);
|
||||
return NextResponse.json({ success: true, id }, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Backup failed";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
47
app/api/files/delete/route.ts
Normal file
47
app/api/files/delete/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getClientIp } from "@/lib/security/rateLimit";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const { filePath } = await req.json();
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = sanitizeFilePath(filePath, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(resolvedPath, { recursive: true });
|
||||
} else {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id,
|
||||
action: "file.delete", target: "file", targetId: path.relative(mcBase, resolvedPath),
|
||||
details: null, ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
37
app/api/files/download/route.ts
Normal file
37
app/api/files/download/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const filePath = req.nextUrl.searchParams.get("path") ?? "";
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = sanitizeFilePath(filePath, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileName = path.basename(resolvedPath);
|
||||
const fileBuffer = fs.readFileSync(resolvedPath);
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(fileBuffer.length),
|
||||
// Prevent XSS via content sniffing
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
60
app/api/files/list/route.ts
Normal file
60
app/api/files/list/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const relativePath = new URL(req.url).searchParams.get("path") ?? "/";
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = sanitizeFilePath(relativePath, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true });
|
||||
const files = entries.map((entry) => {
|
||||
const fullPath = path.join(resolvedPath, entry.name);
|
||||
let size = 0;
|
||||
let modifiedAt = 0;
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
size = stat.size;
|
||||
modifiedAt = stat.mtimeMs;
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: path.relative(mcBase, fullPath),
|
||||
isDirectory: entry.isDirectory(),
|
||||
size,
|
||||
modifiedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetically
|
||||
files.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
path: path.relative(mcBase, resolvedPath) || "/",
|
||||
entries: files,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Cannot read directory" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
app/api/files/upload/route.ts
Normal file
71
app/api/files/upload/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 20);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const targetDir = req.nextUrl.searchParams.get("path") ?? "/";
|
||||
|
||||
let resolvedDir: string;
|
||||
try {
|
||||
resolvedDir = sanitizeFilePath(targetDir, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: "File too large (max 500 MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
// Check extension
|
||||
const ext = path.extname(file.name).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sanitize filename: allow alphanumeric, dots, dashes, underscores, spaces
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._\- ]/g, "_");
|
||||
const destPath = path.join(resolvedDir, safeName);
|
||||
|
||||
// Ensure destination is still within base
|
||||
if (!destPath.startsWith(mcBase)) {
|
||||
return NextResponse.json({ error: "Invalid destination" }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
fs.mkdirSync(resolvedDir, { recursive: true });
|
||||
fs.writeFileSync(destPath, buffer);
|
||||
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id,
|
||||
action: "file.upload", target: "file", targetId: path.relative(mcBase, destPath),
|
||||
details: JSON.stringify({ size: file.size, name: safeName }), ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, path: path.relative(mcBase, destPath) });
|
||||
}
|
||||
9
app/api/health/route.ts
Normal file
9
app/api/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/** Health check endpoint used by Docker and monitoring. */
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{ status: "ok", timestamp: Date.now() },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
66
app/api/monitoring/route.ts
Normal file
66
app/api/monitoring/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import * as os from "node:os";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const totalMemMb = Math.round(os.totalmem() / 1024 / 1024);
|
||||
const freeMemMb = Math.round(os.freemem() / 1024 / 1024);
|
||||
const usedMemMb = totalMemMb - freeMemMb;
|
||||
|
||||
// CPU usage (average across all cores, sampled over 100ms)
|
||||
const cpuPercent = await getCpuPercent();
|
||||
|
||||
const serverStatus = mcProcessManager.getStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
system: {
|
||||
cpuPercent,
|
||||
totalMemMb,
|
||||
usedMemMb,
|
||||
freeMemMb,
|
||||
loadAvg: os.loadavg(),
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
},
|
||||
server: serverStatus,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function getCpuPercent(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const cpus1 = os.cpus();
|
||||
setTimeout(() => {
|
||||
const cpus2 = os.cpus();
|
||||
let totalIdle = 0;
|
||||
let totalTick = 0;
|
||||
|
||||
for (let i = 0; i < cpus1.length; i++) {
|
||||
const cpu1 = cpus1[i].times;
|
||||
const cpu2 = cpus2[i].times;
|
||||
const idle = cpu2.idle - cpu1.idle;
|
||||
const total =
|
||||
(cpu2.user - cpu1.user) +
|
||||
(cpu2.nice - cpu1.nice) +
|
||||
(cpu2.sys - cpu1.sys) +
|
||||
(cpu2.idle - cpu1.idle) +
|
||||
((cpu2.irq ?? 0) - (cpu1.irq ?? 0));
|
||||
totalIdle += idle;
|
||||
totalTick += total;
|
||||
}
|
||||
|
||||
const percent = totalTick === 0 ? 0 : Math.round(((totalTick - totalIdle) / totalTick) * 100);
|
||||
resolve(percent);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
133
app/api/players/[id]/route.ts
Normal file
133
app/api/players/[id]/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { sanitizeRconCommand } from "@/lib/security/sanitize";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
|
||||
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
|
||||
|
||||
const [bans, chatHistory, spawnPoints] = await Promise.all([
|
||||
db.select().from(playerBans).where(eq(playerBans.playerId, id)).orderBy(desc(playerBans.bannedAt)),
|
||||
db.select().from(playerChatHistory).where(eq(playerChatHistory.playerId, id)).orderBy(desc(playerChatHistory.timestamp)).limit(200),
|
||||
db.select().from(playerSpawnPoints).where(eq(playerSpawnPoints.playerId, id)),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ player, bans, chatHistory, spawnPoints });
|
||||
}
|
||||
|
||||
const BanSchema = z.object({
|
||||
reason: z.string().min(1).max(500),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(req.url);
|
||||
const action = searchParams.get("action");
|
||||
|
||||
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
|
||||
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
|
||||
|
||||
if (action === "ban") {
|
||||
let body: z.infer<typeof BanSchema>;
|
||||
try {
|
||||
body = BanSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert ban record
|
||||
await db.insert(playerBans).values({
|
||||
id: nanoid(),
|
||||
playerId: id,
|
||||
reason: body.reason,
|
||||
bannedBy: session.user.id,
|
||||
bannedAt: Date.now(),
|
||||
expiresAt: body.expiresAt ?? null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await db.update(mcPlayers).set({ isBanned: true }).where(eq(mcPlayers.id, id));
|
||||
|
||||
// Execute ban via RCON
|
||||
try {
|
||||
const cmd = sanitizeRconCommand(`ban ${player.username} ${body.reason}`);
|
||||
await rconClient.sendCommand(cmd);
|
||||
} catch { /* RCON might be unavailable */ }
|
||||
|
||||
// Audit
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
action: "player.ban",
|
||||
target: "player",
|
||||
targetId: id,
|
||||
details: JSON.stringify({ reason: body.reason }),
|
||||
ipAddress: ip,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === "unban") {
|
||||
await db.update(playerBans).set({ isActive: false, unbannedBy: session.user.id, unbannedAt: Date.now() }).where(eq(playerBans.playerId, id));
|
||||
await db.update(mcPlayers).set({ isBanned: false }).where(eq(mcPlayers.id, id));
|
||||
|
||||
try {
|
||||
const cmd = sanitizeRconCommand(`pardon ${player.username}`);
|
||||
await rconClient.sendCommand(cmd);
|
||||
} catch { /* RCON might be unavailable */ }
|
||||
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id, action: "player.unban",
|
||||
target: "player", targetId: id, details: null, ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === "kick") {
|
||||
const { reason } = await req.json();
|
||||
try {
|
||||
const cmd = sanitizeRconCommand(`kick ${player.username} ${reason ?? "Kicked by admin"}`);
|
||||
await rconClient.sendCommand(cmd);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "RCON unavailable" }, { status: 503 });
|
||||
}
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id, action: "player.kick",
|
||||
target: "player", targetId: id, details: JSON.stringify({ reason }), ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
42
app/api/players/route.ts
Normal file
42
app/api/players/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { desc, like, or, eq } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const search = searchParams.get("q")?.trim() ?? "";
|
||||
const onlineOnly = searchParams.get("online") === "true";
|
||||
const bannedOnly = searchParams.get("banned") === "true";
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "50")));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = db.select().from(mcPlayers);
|
||||
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
conditions.push(like(mcPlayers.username, `%${search}%`));
|
||||
}
|
||||
if (onlineOnly) conditions.push(eq(mcPlayers.isOnline, true));
|
||||
if (bannedOnly) conditions.push(eq(mcPlayers.isBanned, true));
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mcPlayers)
|
||||
.where(conditions.length ? (conditions.length === 1 ? conditions[0] : or(...conditions)) : undefined)
|
||||
.orderBy(desc(mcPlayers.lastSeen))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return NextResponse.json({ players: rows, page, limit });
|
||||
}
|
||||
88
app/api/plugins/route.ts
Normal file
88
app/api/plugins/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { plugins, auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { sanitizeRconCommand } from "@/lib/security/sanitize";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
// Read plugins from DB + sync with filesystem
|
||||
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
|
||||
const pluginsDir = path.join(mcPath, "plugins");
|
||||
|
||||
const dbPlugins = await db.select().from(plugins);
|
||||
|
||||
// Try to read actual plugin jars from filesystem
|
||||
let jarFiles: string[] = [];
|
||||
try {
|
||||
jarFiles = fs
|
||||
.readdirSync(pluginsDir)
|
||||
.filter((f) => f.endsWith(".jar"));
|
||||
} catch { /* plugins dir might not exist */ }
|
||||
|
||||
return NextResponse.json({ plugins: dbPlugins, jarFiles });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const action = searchParams.get("action");
|
||||
const pluginName = searchParams.get("name");
|
||||
|
||||
if (!pluginName || !/^[a-zA-Z0-9_\-]+$/.test(pluginName)) {
|
||||
return NextResponse.json({ error: "Invalid plugin name" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "enable" || action === "disable") {
|
||||
const cmd = sanitizeRconCommand(
|
||||
`plugman ${action} ${pluginName}`,
|
||||
);
|
||||
const result = await rconClient.sendCommand(cmd);
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({ isEnabled: action === "enable" })
|
||||
.where(eq(plugins.name, pluginName));
|
||||
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id,
|
||||
action: `plugin.${action}`, target: "plugin", targetId: pluginName,
|
||||
details: null, ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
}
|
||||
|
||||
if (action === "reload") {
|
||||
const cmd = sanitizeRconCommand(`plugman reload ${pluginName}`);
|
||||
const result = await rconClient.sendCommand(cmd);
|
||||
return NextResponse.json({ success: true, result });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
}
|
||||
69
app/api/scheduler/[id]/route.ts
Normal file
69
app/api/scheduler/[id]/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import cron from "node-cron";
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
cronExpression: z.string().max(100).optional(),
|
||||
command: z.string().min(1).max(500).optional(),
|
||||
isEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const task = await db.select().from(scheduledTasks).where(eq(scheduledTasks.id, id)).get();
|
||||
if (!task) return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
|
||||
let body: z.infer<typeof UpdateSchema>;
|
||||
try {
|
||||
body = UpdateSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.cronExpression && !cron.validate(body.cronExpression)) {
|
||||
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = { ...task, ...body, updatedAt: Date.now() };
|
||||
await db.update(scheduledTasks).set(updated).where(eq(scheduledTasks.id, id));
|
||||
|
||||
// Reschedule
|
||||
stopTask(id);
|
||||
if (updated.isEnabled) {
|
||||
scheduleTask(id, updated.cronExpression, updated.command);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
stopTask(id);
|
||||
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
67
app/api/scheduler/route.ts
Normal file
67
app/api/scheduler/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import cron from "node-cron";
|
||||
|
||||
const TaskSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
cronExpression: z.string().max(100),
|
||||
command: z.string().min(1).max(500),
|
||||
isEnabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
|
||||
return NextResponse.json({ tasks });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof TaskSchema>;
|
||||
try {
|
||||
body = TaskSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!cron.validate(body.cronExpression)) {
|
||||
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
await db.insert(scheduledTasks).values({
|
||||
id,
|
||||
name: body.name,
|
||||
description: body.description ?? null,
|
||||
cronExpression: body.cronExpression,
|
||||
command: body.command,
|
||||
isEnabled: body.isEnabled,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (body.isEnabled) {
|
||||
scheduleTask(id, body.cronExpression, body.command);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id }, { status: 201 });
|
||||
}
|
||||
74
app/api/server/control/route.ts
Normal file
74
app/api/server/control/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const ActionSchema = z.object({
|
||||
action: z.enum(["start", "stop", "restart"]),
|
||||
force: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Role check — only admin+
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 20); // stricter limit for control actions
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Validate body
|
||||
let body: z.infer<typeof ActionSchema>;
|
||||
try {
|
||||
body = ActionSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { action, force } = body;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "start":
|
||||
await mcProcessManager.start();
|
||||
break;
|
||||
case "stop":
|
||||
await mcProcessManager.stop(force);
|
||||
break;
|
||||
case "restart":
|
||||
await mcProcessManager.restart(force);
|
||||
break;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
action: `server.${action}${force ? ".force" : ""}`,
|
||||
target: "server",
|
||||
targetId: null,
|
||||
details: JSON.stringify({ action, force }),
|
||||
ipAddress: ip,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, action });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
66
app/api/server/settings/route.ts
Normal file
66
app/api/server/settings/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { serverSettings } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { z } from "zod";
|
||||
|
||||
const UpdateSettingsSchema = z.object({
|
||||
minecraftPath: z.string().optional(),
|
||||
serverJar: z.string().optional(),
|
||||
serverVersion: z.string().optional(),
|
||||
serverType: z.enum(["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"]).optional(),
|
||||
maxRam: z.number().min(512).max(32768).optional(),
|
||||
minRam: z.number().min(256).max(32768).optional(),
|
||||
rconEnabled: z.boolean().optional(),
|
||||
rconPort: z.number().min(1).max(65535).optional(),
|
||||
rconPassword: z.string().min(8).optional(),
|
||||
javaArgs: z.string().max(1000).optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
restartOnCrash: z.boolean().optional(),
|
||||
backupEnabled: z.boolean().optional(),
|
||||
backupSchedule: z.string().optional(),
|
||||
bluemapEnabled: z.boolean().optional(),
|
||||
bluemapUrl: z.string().url().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const settings = await db.select().from(serverSettings).get();
|
||||
// Never return RCON password
|
||||
if (settings) {
|
||||
const { rconPassword: _, ...safe } = settings;
|
||||
return NextResponse.json({ settings: safe });
|
||||
}
|
||||
return NextResponse.json({ settings: null });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof UpdateSettingsSchema>;
|
||||
try {
|
||||
body = UpdateSettingsSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.select().from(serverSettings).get();
|
||||
if (existing) {
|
||||
await db.update(serverSettings).set({ ...body, updatedAt: Date.now() });
|
||||
} else {
|
||||
await db.insert(serverSettings).values({ id: 1, ...body, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
20
app/api/server/status/route.ts
Normal file
20
app/api/server/status/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const status = mcProcessManager.getStatus();
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
35
app/api/server/versions/route.ts
Normal file
35
app/api/server/versions/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 20);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const type = req.nextUrl.searchParams.get("type") ?? "vanilla";
|
||||
|
||||
try {
|
||||
let versionInfos: VersionInfo[];
|
||||
switch (type) {
|
||||
case "paper":
|
||||
versionInfos = await fetchPaperVersions();
|
||||
break;
|
||||
case "fabric":
|
||||
versionInfos = await fetchFabricVersions();
|
||||
break;
|
||||
case "vanilla":
|
||||
default:
|
||||
versionInfos = await fetchVanillaVersions();
|
||||
}
|
||||
const versions = versionInfos.map((v) => v.id);
|
||||
return NextResponse.json({ versions, type });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch versions";
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
}
|
||||
91
app/api/team/route.ts
Normal file
91
app/api/team/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { users, invitations } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sendInvitationEmail } from "@/lib/email";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, ne } from "drizzle-orm";
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().email().max(254),
|
||||
role: z.enum(["admin", "moderator"]),
|
||||
playerUuid: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const teamUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(ne(users.id, session.user.id));
|
||||
|
||||
const pendingInvites = await db
|
||||
.select()
|
||||
.from(invitations)
|
||||
.where(eq(invitations.acceptedAt, null as unknown as number));
|
||||
|
||||
return NextResponse.json({ users: teamUsers, pendingInvites });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 10);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof InviteSchema>;
|
||||
try {
|
||||
body = InviteSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existing = await db.select().from(users).where(eq(users.email, body.email)).get();
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "User already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Create invitation
|
||||
const token = nanoid(48);
|
||||
const expiresAt = Date.now() + 48 * 60 * 60 * 1000; // 48 hours
|
||||
|
||||
await db.insert(invitations).values({
|
||||
id: nanoid(),
|
||||
email: body.email,
|
||||
role: body.role,
|
||||
invitedBy: session.user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`;
|
||||
|
||||
try {
|
||||
await sendInvitationEmail({
|
||||
to: body.email,
|
||||
invitedByName: session.user.name ?? "An admin",
|
||||
inviteUrl,
|
||||
role: body.role,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log but don't fail — admin can resend
|
||||
console.error("[Team] Failed to send invitation email:", err);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, inviteUrl }, { status: 201 });
|
||||
}
|
||||
@@ -1,33 +1,80 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/layout/providers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts
|
||||
// ---------------------------------------------------------------------------
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "CubeAdmin | Minecraft Server Manager",
|
||||
template: "%s | CubeAdmin",
|
||||
},
|
||||
description:
|
||||
"A professional, self-hosted admin panel for managing your Minecraft server. Monitor performance, manage players, control files, and more.",
|
||||
keywords: [
|
||||
"Minecraft",
|
||||
"server admin",
|
||||
"panel",
|
||||
"management",
|
||||
"console",
|
||||
"monitoring",
|
||||
],
|
||||
authors: [{ name: "CubeAdmin" }],
|
||||
creator: "CubeAdmin",
|
||||
robots: {
|
||||
index: false, // Admin panel should not be indexed
|
||||
follow: false,
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html
|
||||
lang="en"
|
||||
// Apply dark class by default; next-themes will manage it from here
|
||||
className="dark"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user