BugFixes galore
This commit is contained in:
@@ -315,8 +315,19 @@ function LoginPageInner() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Register link */}
|
||||||
|
<p className="mt-4 text-center text-xs text-zinc-500">
|
||||||
|
No account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-zinc-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:underline"
|
||||||
|
>
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="mt-6 text-center text-[11px] text-zinc-600">
|
<p className="mt-4 text-center text-[11px] text-zinc-600">
|
||||||
CubeAdmin — Secure server management
|
CubeAdmin — Secure server management
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
321
app/(auth)/register/page.tsx
Normal file
321
app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useTransition } from "react";
|
||||||
|
import { useRouter } 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";
|
||||||
|
|
||||||
|
const registerSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
|
email: z.string().email("Please enter a valid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
confirm: z.string(),
|
||||||
|
})
|
||||||
|
.refine((d) => d.password === d.confirm, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirm"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterFormValues = z.infer<typeof registerSchema>;
|
||||||
|
type FieldErrors = Partial<Record<keyof RegisterFormValues, string>>;
|
||||||
|
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
type = "text",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
autoComplete,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: 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="relative">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function validate(): RegisterFormValues | null {
|
||||||
|
const result = registerSchema.safeParse({ name, email, password, confirm });
|
||||||
|
if (!result.success) {
|
||||||
|
const errors: FieldErrors = {};
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const field = issue.path[0] as keyof RegisterFormValues;
|
||||||
|
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.signUp.email({
|
||||||
|
name: values.name,
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const msg = error.code?.toLowerCase().includes("user_already_exists")
|
||||||
|
? "An account with this email already exists."
|
||||||
|
: (error.message ?? "Registration failed. Please try again.");
|
||||||
|
setGlobalError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Account created — welcome to CubeAdmin!");
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setGlobalError("An unexpected error occurred. Please try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
|
||||||
|
{/* Background grid */}
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-sm">
|
||||||
|
{/* Logo */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
Create an account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
The first account registered becomes the administrator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<FormField
|
||||||
|
id="name"
|
||||||
|
label="Display name"
|
||||||
|
value={name}
|
||||||
|
onChange={setName}
|
||||||
|
error={fieldErrors.name}
|
||||||
|
placeholder="Your name"
|
||||||
|
autoComplete="name"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="email"
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
error={fieldErrors.email}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="password"
|
||||||
|
label="Password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
error={fieldErrors.password}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-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>
|
||||||
|
<FormField
|
||||||
|
id="confirm"
|
||||||
|
label="Confirm password"
|
||||||
|
type={showConfirm ? "text" : "password"}
|
||||||
|
value={confirm}
|
||||||
|
onChange={setConfirm}
|
||||||
|
error={fieldErrors.confirm}
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm((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={showConfirm ? "Hide password" : "Show password"}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
Creating account…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create account"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-xs text-zinc-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-zinc-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:underline"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,8 +69,9 @@ export default function ConsolePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Receive buffered history on connect
|
// Receive buffered history on connect
|
||||||
socket.on("history", (data: { lines: string[] }) => {
|
socket.on("history", (data: string[] | { lines: string[] }) => {
|
||||||
const historicalLines = data.lines.map((line) => ({
|
const rawLines = Array.isArray(data) ? data : (data?.lines ?? []);
|
||||||
|
const historicalLines = rawLines.map((line) => ({
|
||||||
text: line,
|
text: line,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
type: classifyLine(line) as LogLine["type"],
|
type: classifyLine(line) as LogLine["type"],
|
||||||
|
|||||||
183
app/(dashboard)/settings/page.tsx
Normal file
183
app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
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 { Separator } from "@/components/ui/separator";
|
||||||
|
import { User, Lock, Shield } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient, useSession } from "@/lib/auth/client";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [nameLoading, startNameTransition] = useTransition();
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [passwordLoading, startPasswordTransition] = useTransition();
|
||||||
|
|
||||||
|
// Populate name field once session loads
|
||||||
|
const displayName = name || session?.user?.name || "";
|
||||||
|
|
||||||
|
function handleNameSave() {
|
||||||
|
if (!displayName.trim()) {
|
||||||
|
toast.error("Name cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startNameTransition(async () => {
|
||||||
|
const { error } = await authClient.updateUser({ name: displayName.trim() });
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? "Failed to update name");
|
||||||
|
} else {
|
||||||
|
toast.success("Display name updated");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasswordChange() {
|
||||||
|
if (!currentPassword) {
|
||||||
|
toast.error("Current password is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
toast.error("New password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast.error("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startPasswordTransition(async () => {
|
||||||
|
const { error } = await authClient.changePassword({
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
revokeOtherSessions: false,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? "Failed to change password");
|
||||||
|
} else {
|
||||||
|
toast.success("Password changed successfully");
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Account Settings</h1>
|
||||||
|
<p className="text-zinc-400 text-sm mt-1">Manage your profile and security preferences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile */}
|
||||||
|
<Card className="bg-zinc-900 border-zinc-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4 text-emerald-500" />
|
||||||
|
Profile
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-500">
|
||||||
|
Update your display name and view account info
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-zinc-300">Email</Label>
|
||||||
|
<Input
|
||||||
|
value={session?.user?.email ?? ""}
|
||||||
|
disabled
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-zinc-500">Email address cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-zinc-300">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name || session?.user?.name || ""}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{session?.user && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
Role: <span className="text-zinc-300 capitalize">{(session.user as { role?: string }).role ?? "moderator"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleNameSave}
|
||||||
|
disabled={nameLoading || isPending}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{nameLoading ? "Saving…" : "Save Name"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator className="bg-zinc-800" />
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<Card className="bg-zinc-900 border-zinc-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||||
|
<Lock className="w-4 h-4 text-emerald-500" />
|
||||||
|
Change Password
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-500">
|
||||||
|
Choose a strong password with at least 8 characters
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-zinc-300">Current Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-zinc-300">New Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-zinc-300">Confirm New Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handlePasswordChange}
|
||||||
|
disabled={passwordLoading}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{passwordLoading ? "Changing…" : "Change Password"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
app/(dashboard)/updates/page.tsx
Normal file
254
app/(dashboard)/updates/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Download, RefreshCw, AlertTriangle, CheckCircle2, Server } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const SERVER_TYPES = ["vanilla", "paper", "fabric"] as const;
|
||||||
|
type ServerType = (typeof SERVER_TYPES)[number];
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
serverType?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
serverJar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdatesPage() {
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
|
const [selectedType, setSelectedType] = useState<ServerType>("paper");
|
||||||
|
const [versions, setVersions] = useState<string[]>([]);
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState("");
|
||||||
|
const [loadingSettings, setLoadingSettings] = useState(true);
|
||||||
|
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
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 as ServerType) ?? "paper");
|
||||||
|
setSelectedVersion(data.settings.serverVersion ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingSettings(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchVersions = useCallback(async (type: string) => {
|
||||||
|
setLoadingVersions(true);
|
||||||
|
setVersions([]);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/server/versions?type=${type}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setVersions(data.versions ?? []);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to fetch versions");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Network error fetching versions");
|
||||||
|
} finally {
|
||||||
|
setLoadingVersions(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, [fetchSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersions(selectedType);
|
||||||
|
}, [selectedType, fetchVersions]);
|
||||||
|
|
||||||
|
const isUpToDate =
|
||||||
|
settings?.serverVersion === selectedVersion &&
|
||||||
|
settings?.serverType === selectedType;
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!selectedVersion) {
|
||||||
|
toast.error("Please select a version");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/server/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ serverType: selectedType, serverVersion: selectedVersion }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error ?? "Failed to apply");
|
||||||
|
}
|
||||||
|
setSettings((prev) => ({ ...prev, serverType: selectedType, serverVersion: selectedVersion }));
|
||||||
|
toast.success(`Server version set to ${selectedType} ${selectedVersion}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to apply version");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVersion = settings?.serverVersion
|
||||||
|
? `${settings.serverType ?? "unknown"} ${settings.serverVersion}`
|
||||||
|
: "Not configured";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Server Updates</h1>
|
||||||
|
<p className="text-zinc-400 text-sm mt-1">
|
||||||
|
Manage your Minecraft server version
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current version */}
|
||||||
|
<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" />
|
||||||
|
Current Version
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingSettings ? (
|
||||||
|
<Skeleton className="h-6 w-48 bg-zinc-800" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-white font-mono text-sm">{currentVersion}</span>
|
||||||
|
{settings?.serverVersion && (
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
isUpToDate
|
||||||
|
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUpToDate ? "Up to date" : "Update available"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Version picker */}
|
||||||
|
<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" />
|
||||||
|
Select Version
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-500">
|
||||||
|
Choose a server type and version to apply
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm text-zinc-300">Server Type</label>
|
||||||
|
<Select
|
||||||
|
value={selectedType}
|
||||||
|
onValueChange={(v) => { if (v) setSelectedType(v as ServerType); }}
|
||||||
|
>
|
||||||
|
<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-sm text-zinc-300">Version</label>
|
||||||
|
<Select
|
||||||
|
value={selectedVersion}
|
||||||
|
onValueChange={(v) => { if (v) setSelectedVersion(v); }}
|
||||||
|
disabled={loadingVersions || versions.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
loadingVersions
|
||||||
|
? "Loading…"
|
||||||
|
: versions.length === 0
|
||||||
|
? "No versions found"
|
||||||
|
: "Select version"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-64">
|
||||||
|
{versions.map((v) => (
|
||||||
|
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white font-mono text-sm">
|
||||||
|
{v}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={saving || !selectedVersion || loadingVersions}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{saving ? "Applying…" : "Apply Version"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchVersions(selectedType)}
|
||||||
|
disabled={loadingVersions}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 mr-1.5 ${loadingVersions ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVersion && settings?.serverVersion && selectedVersion !== settings.serverVersion && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-400 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Changing from <span className="font-mono">{settings.serverVersion}</span> to{" "}
|
||||||
|
<span className="font-mono">{selectedVersion}</span> requires a server restart.
|
||||||
|
Make sure to create a backup first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUpToDate && selectedVersion && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-emerald-400 text-sm">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<p>This version is already configured.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { auditLogs, users } from "@/lib/db/schema";
|
import { auditLogs, users } from "@/lib/db/schema";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
|
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { deleteBackup } from "@/lib/backup/manager";
|
import { deleteBackup } from "@/lib/backup/manager";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { backups } from "@/lib/db/schema";
|
import { backups } from "@/lib/db/schema";
|
||||||
@@ -10,7 +10,7 @@ export async function DELETE(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
@@ -30,7 +30,7 @@ export async function GET(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
|
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -9,7 +9,7 @@ const CreateBackupSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
@@ -21,7 +21,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { auditLogs } from "@/lib/db/schema";
|
import { auditLogs } from "@/lib/db/schema";
|
||||||
@@ -9,7 +9,7 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -12,7 +12,7 @@ const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
|||||||
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
|
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
|
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
@@ -13,7 +13,7 @@ export async function GET(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -38,7 +38,7 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { mcPlayers } from "@/lib/db/schema";
|
import { mcPlayers } from "@/lib/db/schema";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
import { desc, like, or, eq } from "drizzle-orm";
|
import { desc, like, or, eq } from "drizzle-orm";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { plugins, auditLogs } from "@/lib/db/schema";
|
import { plugins, auditLogs } from "@/lib/db/schema";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
@@ -36,7 +36,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { scheduledTasks } from "@/lib/db/schema";
|
import { scheduledTasks } from "@/lib/db/schema";
|
||||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||||
@@ -19,7 +19,7 @@ export async function PATCH(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
@@ -56,7 +56,7 @@ export async function DELETE(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { scheduledTasks } from "@/lib/db/schema";
|
import { scheduledTasks } from "@/lib/db/schema";
|
||||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||||
@@ -18,7 +18,7 @@ const TaskSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
|
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
|
||||||
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { auditLogs } from "@/lib/db/schema";
|
import { auditLogs } from "@/lib/db/schema";
|
||||||
@@ -14,7 +14,7 @@ const ActionSchema = z.object({
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
// Auth
|
// Auth
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { serverSettings } from "@/lib/db/schema";
|
import { serverSettings } from "@/lib/db/schema";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
@@ -25,7 +25,7 @@ const UpdateSettingsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const settings = await db.select().from(serverSettings).get();
|
const settings = await db.select().from(serverSettings).get();
|
||||||
@@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (session.user.role !== "superadmin") {
|
if (session.user.role !== "superadmin") {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
|
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth, getAuthSession } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { users, invitations } from "@/lib/db/schema";
|
import { users, invitations } from "@/lib/db/schema";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||||
@@ -15,7 +15,7 @@ const InviteSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (session.user.role !== "superadmin") {
|
if (session.user.role !== "superadmin") {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
@@ -35,7 +35,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const session = await auth.api.getSession({ headers: req.headers });
|
const session = await getAuthSession(req.headers);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (session.user.role !== "superadmin") {
|
if (session.user.role !== "superadmin") {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|||||||
66
app/page.tsx
66
app/page.tsx
@@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function RootPage() {
|
||||||
return (
|
redirect("/dashboard");
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
@@ -450,6 +451,7 @@ export function Sidebar() {
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
className="w-52"
|
className="w-52"
|
||||||
>
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
@@ -460,12 +462,16 @@ export function Sidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
Account Settings
|
Account Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -473,6 +479,7 @@ export function Sidebar() {
|
|||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Sign out
|
Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
@@ -54,7 +55,8 @@ const PAGE_TITLES: Record<string, string> = {
|
|||||||
"/plugins": "Plugins",
|
"/plugins": "Plugins",
|
||||||
"/files": "File Manager",
|
"/files": "File Manager",
|
||||||
"/backups": "Backups",
|
"/backups": "Backups",
|
||||||
"/settings": "Server Settings",
|
"/settings": "Account Settings",
|
||||||
|
"/server": "Server Settings",
|
||||||
"/updates": "Updates",
|
"/updates": "Updates",
|
||||||
"/team": "Team",
|
"/team": "Team",
|
||||||
"/audit": "Audit Log",
|
"/audit": "Audit Log",
|
||||||
@@ -96,7 +98,7 @@ function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const statusConfigs = {
|
||||||
online: {
|
online: {
|
||||||
dot: "bg-emerald-500",
|
dot: "bg-emerald-500",
|
||||||
text: "Online",
|
text: "Online",
|
||||||
@@ -117,7 +119,12 @@ function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
|
|||||||
text: "Stopping…",
|
text: "Stopping…",
|
||||||
className: "border-orange-500/20 bg-orange-500/10 text-orange-400",
|
className: "border-orange-500/20 bg-orange-500/10 text-orange-400",
|
||||||
},
|
},
|
||||||
}[status.status];
|
};
|
||||||
|
const config = statusConfigs[status.status] ?? {
|
||||||
|
dot: "bg-zinc-500",
|
||||||
|
text: status.status,
|
||||||
|
className: "border-zinc-700/50 bg-zinc-800/50 text-zinc-400",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -287,6 +294,27 @@ function NotificationBell() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render a placeholder until mounted to avoid SSR/client mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-zinc-400"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isDark = resolvedTheme === "dark";
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -344,6 +372,7 @@ function UserMenu() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
|
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
|
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
|
||||||
@@ -352,16 +381,21 @@ function UserMenu() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Sign out
|
Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
2
drizzle/0001_gifted_loa.sql
Normal file
2
drizzle/0001_gifted_loa.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `accounts` ADD `id_token` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `accounts` ADD `password` text;
|
||||||
1236
drizzle/meta/0001_snapshot.json
Normal file
1236
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1772980984285,
|
"when": 1772980984285,
|
||||||
"tag": "0000_overjoyed_thundra",
|
"tag": "0000_overjoyed_thundra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772984147555,
|
||||||
|
"tag": "0001_gifted_loa",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,11 @@ import type { Auth } from "./index";
|
|||||||
* code. Mirrors the plugins registered on the server-side `auth` instance.
|
* code. Mirrors the plugins registered on the server-side `auth` instance.
|
||||||
*/
|
*/
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "http://localhost:3000",
|
// No baseURL — uses window.location.origin automatically, which always
|
||||||
|
// produces same-origin requests and avoids CSP connect-src issues.
|
||||||
|
...(process.env.NEXT_PUBLIC_BETTER_AUTH_URL
|
||||||
|
? { baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL }
|
||||||
|
: {}),
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
|
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
|
|||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { organization } from "better-auth/plugins";
|
import { organization } from "better-auth/plugins";
|
||||||
import { magicLink } from "better-auth/plugins/magic-link";
|
import { magicLink } from "better-auth/plugins/magic-link";
|
||||||
|
import { count, eq } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import * as schema from "@/lib/db/schema";
|
import * as schema from "@/lib/db/schema";
|
||||||
|
|
||||||
@@ -19,13 +20,14 @@ export const auth = betterAuth({
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "sqlite",
|
provider: "sqlite",
|
||||||
|
// Keys must match Better Auth's internal model names (singular).
|
||||||
|
// usePlural: false (default) → "user", "session", "account", "verification"
|
||||||
schema: {
|
schema: {
|
||||||
users: schema.users,
|
user: schema.users,
|
||||||
sessions: schema.sessions,
|
session: schema.sessions,
|
||||||
accounts: schema.accounts,
|
account: schema.accounts,
|
||||||
verifications: schema.verifications,
|
verification: schema.verifications,
|
||||||
},
|
},
|
||||||
usePlural: false,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -52,6 +54,30 @@ export const auth = betterAuth({
|
|||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Database hooks — first registered user becomes admin automatically
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user) => {
|
||||||
|
// Count all users; if this is the very first, promote to admin
|
||||||
|
const [{ total }] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(schema.users);
|
||||||
|
|
||||||
|
if (total === 1) {
|
||||||
|
await db
|
||||||
|
.update(schema.users)
|
||||||
|
.set({ role: "admin" } as Record<string, unknown>)
|
||||||
|
.where(eq(schema.users.id, user.id));
|
||||||
|
console.log(`[Auth] First user ${user.id} (${user.email}) promoted to admin`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Plugins
|
// Plugins
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -104,5 +130,18 @@ export type Auth = typeof auth;
|
|||||||
/** The server-side session type returned by auth.api.getSession */
|
/** The server-side session type returned by auth.api.getSession */
|
||||||
export type Session = typeof auth.$Infer.Session.session;
|
export type Session = typeof auth.$Infer.Session.session;
|
||||||
|
|
||||||
/** The user type embedded in every session */
|
/** The user type embedded in every session, with custom additionalFields */
|
||||||
export type User = typeof auth.$Infer.Session.user;
|
export type User = typeof auth.$Infer.Session.user & {
|
||||||
|
role?: "superadmin" | "admin" | "moderator" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
||||||
|
|
||||||
|
/** Typed wrapper around auth.api.getSession that includes the role field */
|
||||||
|
export async function getAuthSession(
|
||||||
|
headers: Headers,
|
||||||
|
): Promise<(Omit<RawSession, "user"> & { user: User }) | null> {
|
||||||
|
return auth.api.getSession({ headers }) as Promise<
|
||||||
|
(Omit<RawSession, "user"> & { user: User }) | null
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export const users = sqliteTable("users", {
|
|||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("moderator"),
|
.default("moderator"),
|
||||||
createdAt: integer("created_at").notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
updatedAt: integer("updated_at").notNull(),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessions = sqliteTable("sessions", {
|
export const sessions = sqliteTable("sessions", {
|
||||||
@@ -34,11 +34,11 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
expiresAt: integer("expires_at").notNull(),
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
ipAddress: text("ip_address"),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
createdAt: integer("created_at").notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
updatedAt: integer("updated_at").notNull(),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = sqliteTable("accounts", {
|
export const accounts = sqliteTable("accounts", {
|
||||||
@@ -50,18 +50,20 @@ export const accounts = sqliteTable("accounts", {
|
|||||||
providerId: text("provider_id").notNull(),
|
providerId: text("provider_id").notNull(),
|
||||||
accessToken: text("access_token"),
|
accessToken: text("access_token"),
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
expiresAt: integer("expires_at"),
|
idToken: text("id_token"),
|
||||||
createdAt: integer("created_at").notNull(),
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
|
||||||
updatedAt: integer("updated_at").notNull(),
|
password: text("password"), // hashed password for email/password auth
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const verifications = sqliteTable("verifications", {
|
export const verifications = sqliteTable("verifications", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
identifier: text("identifier").notNull(),
|
identifier: text("identifier").notNull(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
expiresAt: integer("expires_at").notNull(),
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
createdAt: integer("created_at").notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
updatedAt: integer("updated_at").notNull(),
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,76 +28,28 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Security headers (CSP + non-CSP) are applied by proxy.ts so they can
|
||||||
|
// include a per-request nonce. Only static headers that don't conflict are
|
||||||
|
// set here for paths the middleware doesn't cover (e.g. _next/static).
|
||||||
async headers() {
|
async headers() {
|
||||||
const cspDirectives = [
|
return [
|
||||||
"default-src 'self'",
|
|
||||||
// Scripts: self + strict-dynamic (Turbopack compatible)
|
|
||||||
"script-src 'self' 'unsafe-inline'",
|
|
||||||
// Styles: self + unsafe-inline (required for Tailwind/CSS-in-JS in Next.js)
|
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
||||||
// Fonts
|
|
||||||
"font-src 'self' https://fonts.gstatic.com data:",
|
|
||||||
// Images: self + data URIs + MC avatar APIs
|
|
||||||
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
|
||||||
// Connect: self + WebSocket for Socket.io
|
|
||||||
"connect-src 'self' ws: wss:",
|
|
||||||
// Frames: allow same-origin (BlueMap) + configurable origins
|
|
||||||
"frame-src 'self'",
|
|
||||||
// Frame ancestors: only same origin (replaces X-Frame-Options)
|
|
||||||
"frame-ancestors 'self'",
|
|
||||||
// Workers: self + blob (xterm.js, Monaco)
|
|
||||||
"worker-src 'self' blob:",
|
|
||||||
// Media
|
|
||||||
"media-src 'self'",
|
|
||||||
// Manifest
|
|
||||||
"manifest-src 'self'",
|
|
||||||
// Object: none
|
|
||||||
"object-src 'none'",
|
|
||||||
// Base URI
|
|
||||||
"base-uri 'self'",
|
|
||||||
// Form actions
|
|
||||||
"form-action 'self'",
|
|
||||||
// Upgrade insecure requests in production
|
|
||||||
...(process.env.NODE_ENV === "production"
|
|
||||||
? ["upgrade-insecure-requests"]
|
|
||||||
: []),
|
|
||||||
].join("; ");
|
|
||||||
|
|
||||||
const securityHeaders = [
|
|
||||||
{
|
{
|
||||||
key: "Content-Security-Policy",
|
source: "/(.*)",
|
||||||
value: cspDirectives,
|
headers: [
|
||||||
},
|
// CSP is intentionally omitted here — proxy.ts owns it.
|
||||||
{
|
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
||||||
key: "X-Frame-Options",
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
value: "SAMEORIGIN",
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "X-Content-Type-Options",
|
|
||||||
value: "nosniff",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "Referrer-Policy",
|
|
||||||
value: "strict-origin-when-cross-origin",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "Permissions-Policy",
|
key: "Permissions-Policy",
|
||||||
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
|
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
|
||||||
},
|
},
|
||||||
{
|
{ key: "X-DNS-Prefetch-Control", value: "on" },
|
||||||
key: "X-DNS-Prefetch-Control",
|
|
||||||
value: "on",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "Strict-Transport-Security",
|
key: "Strict-Transport-Security",
|
||||||
value: "max-age=63072000; includeSubDomains; preload",
|
value: "max-age=63072000; includeSubDomains; preload",
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/(.*)",
|
|
||||||
headers: securityHeaders,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
14
proxy.ts
14
proxy.ts
@@ -51,14 +51,24 @@ function isPublicPath(pathname: string): boolean {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Security headers applied to every response
|
// Security headers applied to every response
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
function buildCSP(nonce: string): string {
|
function buildCSP(nonce: string): string {
|
||||||
|
// In dev, Next.js hot-reload and some auth libs require 'unsafe-eval'.
|
||||||
|
// In production we restrict to 'wasm-unsafe-eval' (WebAssembly only).
|
||||||
|
const evalDirective = isDev ? "'unsafe-eval'" : "'wasm-unsafe-eval'";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
`script-src 'self' 'nonce-${nonce}'`,
|
`script-src 'self' 'nonce-${nonce}' ${evalDirective} 'unsafe-inline'`,
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
"font-src 'self' https://fonts.gstatic.com data:",
|
"font-src 'self' https://fonts.gstatic.com data:",
|
||||||
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
||||||
"connect-src 'self' ws: wss:",
|
// In dev, include http://localhost:* explicitly so absolute-URL fetches
|
||||||
|
// (e.g. from Better Auth client) aren't blocked by a strict 'self' check.
|
||||||
|
isDev
|
||||||
|
? "connect-src 'self' http://localhost:* ws://localhost:* wss://localhost:* ws: wss:"
|
||||||
|
: "connect-src 'self' ws: wss:",
|
||||||
"frame-src 'self'",
|
"frame-src 'self'",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
"worker-src 'self' blob:",
|
"worker-src 'self' blob:",
|
||||||
|
|||||||
11
types/better-auth.d.ts
vendored
Normal file
11
types/better-auth.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Augment Better Auth's session user type to include the `role` additional field
|
||||||
|
* defined in lib/auth/index.ts.
|
||||||
|
*/
|
||||||
|
declare module "better-auth" {
|
||||||
|
interface UserAdditionalFields {
|
||||||
|
role?: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
Reference in New Issue
Block a user