diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx
index 7c65fd5..e39e73d 100644
--- a/app/(auth)/login/page.tsx
+++ b/app/(auth)/login/page.tsx
@@ -315,8 +315,19 @@ function LoginPageInner() {
+ {/* Register link */}
+
+ No account?{" "}
+
+ Create one
+
+
+
{/* Footer */}
-
+
CubeAdmin — Secure server management
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..cdd044e
--- /dev/null
+++ b/app/(auth)/register/page.tsx
@@ -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;
+type FieldErrors = Partial>;
+
+function CubeIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+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 (
+
+
+
+ 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}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+ );
+}
+
+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({});
+ const [globalError, setGlobalError] = useState(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) {
+ 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 (
+
+ {/* Background grid */}
+
+ {/* Radial glow */}
+
+
+
+ {/* Logo */}
+
+
+
+
+
+
+ CubeAdmin
+
+
+ Minecraft Server Management
+
+
+
+
+ {/* Card */}
+
+
+
+ Create an account
+
+
+ The first account registered becomes the administrator.
+
+
+
+ {globalError && (
+
+ )}
+
+
+
+
+
+ Already have an account?{" "}
+
+ Sign in
+
+
+
+
+ );
+}
diff --git a/app/(dashboard)/console/page.tsx b/app/(dashboard)/console/page.tsx
index 0604092..cb20974 100644
--- a/app/(dashboard)/console/page.tsx
+++ b/app/(dashboard)/console/page.tsx
@@ -69,8 +69,9 @@ export default function ConsolePage() {
});
// Receive buffered history on connect
- socket.on("history", (data: { lines: string[] }) => {
- const historicalLines = data.lines.map((line) => ({
+ socket.on("history", (data: string[] | { lines: string[] }) => {
+ const rawLines = Array.isArray(data) ? data : (data?.lines ?? []);
+ const historicalLines = rawLines.map((line) => ({
text: line,
timestamp: Date.now(),
type: classifyLine(line) as LogLine["type"],
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/dashboard/page.tsx
similarity index 100%
rename from app/(dashboard)/page.tsx
rename to app/(dashboard)/dashboard/page.tsx
diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx
new file mode 100644
index 0000000..1c5bdaf
--- /dev/null
+++ b/app/(dashboard)/settings/page.tsx
@@ -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 (
+
+
+
Account Settings
+
Manage your profile and security preferences
+
+
+ {/* Profile */}
+
+
+
+
+ Profile
+
+
+ Update your display name and view account info
+
+
+
+
+
+
+
Email address cannot be changed
+
+
+
+ setName(e.target.value)}
+ placeholder="Your name"
+ className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
+ disabled={isPending}
+ />
+
+ {session?.user && (
+
+
+ Role: {(session.user as { role?: string }).role ?? "moderator"}
+
+ )}
+
+
+
+
+
+
+ {/* Password */}
+
+
+
+
+ Change Password
+
+
+ Choose a strong password with at least 8 characters
+
+
+
+
+
+ setCurrentPassword(e.target.value)}
+ placeholder="••••••••"
+ className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
+ />
+
+
+
+ setNewPassword(e.target.value)}
+ placeholder="••••••••"
+ className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
+ />
+
+
+
+ setConfirmPassword(e.target.value)}
+ placeholder="••••••••"
+ className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/(dashboard)/updates/page.tsx b/app/(dashboard)/updates/page.tsx
new file mode 100644
index 0000000..2069560
--- /dev/null
+++ b/app/(dashboard)/updates/page.tsx
@@ -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(null);
+ const [selectedType, setSelectedType] = useState("paper");
+ const [versions, setVersions] = useState([]);
+ 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 (
+
+
+
Server Updates
+
+ Manage your Minecraft server version
+
+
+
+ {/* Current version */}
+
+
+
+
+ Current Version
+
+
+
+ {loadingSettings ? (
+
+ ) : (
+
+ {currentVersion}
+ {settings?.serverVersion && (
+
+ {isUpToDate ? "Up to date" : "Update available"}
+
+ )}
+
+ )}
+
+
+
+ {/* Version picker */}
+
+
+
+
+ Select Version
+
+
+ Choose a server type and version to apply
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {selectedVersion && settings?.serverVersion && selectedVersion !== settings.serverVersion && (
+
+
+
+ Changing from {settings.serverVersion} to{" "}
+ {selectedVersion} requires a server restart.
+ Make sure to create a backup first.
+
+
+ )}
+
+ {isUpToDate && selectedVersion && (
+
+
+
This version is already configured.
+
+ )}
+
+
+
+ );
+}
diff --git a/app/api/audit/route.ts b/app/api/audit/route.ts
index 4780515..495a615 100644
--- a/app/api/audit/route.ts
+++ b/app/api/audit/route.ts
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } 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 });
+ const session = await getAuthSession(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 });
diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts
new file mode 100644
index 0000000..5b67b06
--- /dev/null
+++ b/app/api/auth/[...all]/route.ts
@@ -0,0 +1,4 @@
+import { auth } from "@/lib/auth";
+import { toNextJsHandler } from "better-auth/next-js";
+
+export const { GET, POST } = toNextJsHandler(auth);
diff --git a/app/api/backups/[id]/route.ts b/app/api/backups/[id]/route.ts
index cb3ccd9..fb3229f 100644
--- a/app/api/backups/[id]/route.ts
+++ b/app/api/backups/[id]/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { deleteBackup } from "@/lib/backup/manager";
import { db } from "@/lib/db";
import { backups } from "@/lib/db/schema";
@@ -10,7 +10,7 @@ export async function DELETE(
req: NextRequest,
{ 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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -30,7 +30,7 @@ export async function GET(
req: NextRequest,
{ 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 });
const { id } = await params;
diff --git a/app/api/backups/route.ts b/app/api/backups/route.ts
index 9dff751..fb6757a 100644
--- a/app/api/backups/route.ts
+++ b/app/api/backups/route.ts
@@ -1,5 +1,5 @@
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 { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
import { z } from "zod";
@@ -9,7 +9,7 @@ const CreateBackupSchema = z.object({
});
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 });
const ip = getClientIp(req);
@@ -21,7 +21,7 @@ export async function GET(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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/files/delete/route.ts b/app/api/files/delete/route.ts
index b80937e..4d0310a 100644
--- a/app/api/files/delete/route.ts
+++ b/app/api/files/delete/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
@@ -9,7 +9,7 @@ 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 });
+ const session = await getAuthSession(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 });
diff --git a/app/api/files/download/route.ts b/app/api/files/download/route.ts
index 30a0caa..fbf78cb 100644
--- a/app/api/files/download/route.ts
+++ b/app/api/files/download/route.ts
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } 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 });
+ const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
diff --git a/app/api/files/list/route.ts b/app/api/files/list/route.ts
index fb510e0..5c4db95 100644
--- a/app/api/files/list/route.ts
+++ b/app/api/files/list/route.ts
@@ -1,12 +1,12 @@
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 { 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 });
+ const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
diff --git a/app/api/files/upload/route.ts b/app/api/files/upload/route.ts
index a66aee7..4d76ddc 100644
--- a/app/api/files/upload/route.ts
+++ b/app/api/files/upload/route.ts
@@ -1,5 +1,5 @@
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 { sanitizeFilePath } from "@/lib/security/sanitize";
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"]);
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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/monitoring/route.ts b/app/api/monitoring/route.ts
index 12aaf81..ea9b7f1 100644
--- a/app/api/monitoring/route.ts
+++ b/app/api/monitoring/route.ts
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } 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 });
+ const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
diff --git a/app/api/players/[id]/route.ts b/app/api/players/[id]/route.ts
index ff18b69..66a0c73 100644
--- a/app/api/players/[id]/route.ts
+++ b/app/api/players/[id]/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } 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";
@@ -13,7 +13,7 @@ export async function GET(
req: NextRequest,
{ 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 });
const { id } = await params;
@@ -38,7 +38,7 @@ export async function POST(
req: NextRequest,
{ 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 (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/players/route.ts b/app/api/players/route.ts
index 329bfb7..aaae73c 100644
--- a/app/api/players/route.ts
+++ b/app/api/players/route.ts
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } 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 });
+ const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts
index 9a9654e..2e24729 100644
--- a/app/api/plugins/route.ts
+++ b/app/api/plugins/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { plugins, auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
@@ -11,7 +11,7 @@ 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 });
+ const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
@@ -36,7 +36,7 @@ export async function GET(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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/scheduler/[id]/route.ts b/app/api/scheduler/[id]/route.ts
index 2b165c4..1aad922 100644
--- a/app/api/scheduler/[id]/route.ts
+++ b/app/api/scheduler/[id]/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { scheduledTasks } from "@/lib/db/schema";
import { scheduleTask, stopTask } from "@/lib/scheduler";
@@ -19,7 +19,7 @@ export async function PATCH(
req: NextRequest,
{ 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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -56,7 +56,7 @@ export async function DELETE(
req: NextRequest,
{ 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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/scheduler/route.ts b/app/api/scheduler/route.ts
index bd95881..945e5d4 100644
--- a/app/api/scheduler/route.ts
+++ b/app/api/scheduler/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { scheduledTasks } from "@/lib/db/schema";
import { scheduleTask, stopTask } from "@/lib/scheduler";
@@ -18,7 +18,7 @@ const TaskSchema = z.object({
});
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 });
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) {
- 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 (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/server/control/route.ts b/app/api/server/control/route.ts
index 6622a3d..9ad0889 100644
--- a/app/api/server/control/route.ts
+++ b/app/api/server/control/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
@@ -14,7 +14,7 @@ const ActionSchema = z.object({
export async function POST(req: NextRequest) {
// Auth
- const session = await auth.api.getSession({ headers: req.headers });
+ const session = await getAuthSession(req.headers);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
diff --git a/app/api/server/settings/route.ts b/app/api/server/settings/route.ts
index 3782efe..f24a599 100644
--- a/app/api/server/settings/route.ts
+++ b/app/api/server/settings/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { serverSettings } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
@@ -25,7 +25,7 @@ const UpdateSettingsSchema = z.object({
});
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 });
const settings = await db.select().from(serverSettings).get();
@@ -38,7 +38,7 @@ export async function GET(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.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/api/server/status/route.ts b/app/api/server/status/route.ts
index 8d45c82..f115939 100644
--- a/app/api/server/status/route.ts
+++ b/app/api/server/status/route.ts
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } 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 });
+ const session = await getAuthSession(req.headers);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
diff --git a/app/api/server/versions/route.ts b/app/api/server/versions/route.ts
index 1a62c89..c9fdd4f 100644
--- a/app/api/server/versions/route.ts
+++ b/app/api/server/versions/route.ts
@@ -1,10 +1,10 @@
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 { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
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 });
const ip = getClientIp(req);
diff --git a/app/api/team/route.ts b/app/api/team/route.ts
index 370991b..183b2fa 100644
--- a/app/api/team/route.ts
+++ b/app/api/team/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { auth } from "@/lib/auth";
+import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { users, invitations } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
@@ -15,7 +15,7 @@ const InviteSchema = z.object({
});
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.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -35,7 +35,7 @@ export async function GET(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.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/app/page.tsx b/app/page.tsx
index 295f8fd..c3a1c90 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,65 +1,5 @@
-import Image from "next/image";
+import { redirect } from "next/navigation";
-export default function Home() {
- return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+export default function RootPage() {
+ redirect("/dashboard");
}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 1df048a..2a0e6f0 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -31,6 +31,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -450,29 +451,35 @@ export function Sidebar() {
sideOffset={8}
className="w-52"
>
-
-
-
- {session?.user?.name ?? "—"}
-
-
- {session?.user?.email ?? "—"}
-
-
-
+
+
+
+
+ {session?.user?.name ?? "—"}
+
+
+ {session?.user?.email ?? "—"}
+
+
+
+
- router.push("/settings")}>
-
- Account Settings
-
+
+ router.push("/settings")}>
+
+ Account Settings
+
+
-
-
- Sign out
-
+
+
+
+ Sign out
+
+
diff --git a/components/layout/topbar.tsx b/components/layout/topbar.tsx
index 1549b39..d3b39ff 100644
--- a/components/layout/topbar.tsx
+++ b/components/layout/topbar.tsx
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
+ DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
@@ -54,7 +55,8 @@ const PAGE_TITLES: Record = {
"/plugins": "Plugins",
"/files": "File Manager",
"/backups": "Backups",
- "/settings": "Server Settings",
+ "/settings": "Account Settings",
+ "/server": "Server Settings",
"/updates": "Updates",
"/team": "Team",
"/audit": "Audit Log",
@@ -96,7 +98,7 @@ function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
);
}
- const config = {
+ const statusConfigs = {
online: {
dot: "bg-emerald-500",
text: "Online",
@@ -117,7 +119,12 @@ function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
text: "Stopping…",
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 (
{
+ setMounted(true);
+ }, []);
+
+ // Render a placeholder until mounted to avoid SSR/client mismatch
+ if (!mounted) {
+ return (
+
+ );
+ }
+
const isDark = resolvedTheme === "dark";
return (
@@ -344,24 +372,30 @@ function UserMenu() {
-
-
- {session?.user?.name ?? "—"}
-
- {session?.user?.email ?? "—"}
-
-
-
+
+
+
+ {session?.user?.name ?? "—"}
+
+ {session?.user?.email ?? "—"}
+
+
+
+
- router.push("/settings")}>
-
- Settings
-
+
+ router.push("/settings")}>
+
+ Settings
+
+
-
-
- Sign out
-
+
+
+
+ Sign out
+
+
);
diff --git a/data/cubeadmin.db-shm b/data/cubeadmin.db-shm
index fe9ac28..5d4e46c 100644
Binary files a/data/cubeadmin.db-shm and b/data/cubeadmin.db-shm differ
diff --git a/data/cubeadmin.db-wal b/data/cubeadmin.db-wal
index e69de29..9497e22 100644
Binary files a/data/cubeadmin.db-wal and b/data/cubeadmin.db-wal differ
diff --git a/drizzle/0001_gifted_loa.sql b/drizzle/0001_gifted_loa.sql
new file mode 100644
index 0000000..b5103bd
--- /dev/null
+++ b/drizzle/0001_gifted_loa.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `accounts` ADD `id_token` text;--> statement-breakpoint
+ALTER TABLE `accounts` ADD `password` text;
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..f0fd607
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,1236 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "6bd4ab07-5bb3-41eb-8a1a-cd6a392e1152",
+ "prevId": "6c037435-c4bf-4871-912d-11eb618c4e68",
+ "tables": {
+ "accounts": {
+ "name": "accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "audit_logs": {
+ "name": "audit_logs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "details": {
+ "name": "details",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_logs_user_id_users_id_fk": {
+ "name": "audit_logs_user_id_users_id_fk",
+ "tableFrom": "audit_logs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "backups": {
+ "name": "backups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "backups_triggered_by_users_id_fk": {
+ "name": "backups_triggered_by_users_id_fk",
+ "tableFrom": "backups",
+ "tableTo": "users",
+ "columnsFrom": [
+ "triggered_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invitations": {
+ "name": "invitations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'moderator'"
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accepted_at": {
+ "name": "accepted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invitations_token_unique": {
+ "name": "invitations_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invitations_invited_by_users_id_fk": {
+ "name": "invitations_invited_by_users_id_fk",
+ "tableFrom": "invitations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "invited_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "mc_players": {
+ "name": "mc_players",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "uuid": {
+ "name": "uuid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_seen": {
+ "name": "first_seen",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_online": {
+ "name": "is_online",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "play_time": {
+ "name": "play_time",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_banned": {
+ "name": "is_banned",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "mc_players_uuid_idx": {
+ "name": "mc_players_uuid_idx",
+ "columns": [
+ "uuid"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "player_bans": {
+ "name": "player_bans",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "player_id": {
+ "name": "player_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "banned_by": {
+ "name": "banned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "banned_at": {
+ "name": "banned_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "unbanned_by": {
+ "name": "unbanned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unbanned_at": {
+ "name": "unbanned_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "player_bans_player_id_mc_players_id_fk": {
+ "name": "player_bans_player_id_mc_players_id_fk",
+ "tableFrom": "player_bans",
+ "tableTo": "mc_players",
+ "columnsFrom": [
+ "player_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "player_bans_banned_by_users_id_fk": {
+ "name": "player_bans_banned_by_users_id_fk",
+ "tableFrom": "player_bans",
+ "tableTo": "users",
+ "columnsFrom": [
+ "banned_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "player_bans_unbanned_by_users_id_fk": {
+ "name": "player_bans_unbanned_by_users_id_fk",
+ "tableFrom": "player_bans",
+ "tableTo": "users",
+ "columnsFrom": [
+ "unbanned_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "player_chat_history": {
+ "name": "player_chat_history",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "player_id": {
+ "name": "player_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "player_chat_history_player_id_mc_players_id_fk": {
+ "name": "player_chat_history_player_id_mc_players_id_fk",
+ "tableFrom": "player_chat_history",
+ "tableTo": "mc_players",
+ "columnsFrom": [
+ "player_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "player_spawn_points": {
+ "name": "player_spawn_points",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "player_id": {
+ "name": "player_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "world": {
+ "name": "world",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x": {
+ "name": "x",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y": {
+ "name": "y",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "z": {
+ "name": "z",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "player_spawn_points_player_id_mc_players_id_fk": {
+ "name": "player_spawn_points_player_id_mc_players_id_fk",
+ "tableFrom": "player_spawn_points",
+ "tableTo": "mc_players",
+ "columnsFrom": [
+ "player_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "plugins": {
+ "name": "plugins",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "jar_file": {
+ "name": "jar_file",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "config": {
+ "name": "config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "installed_at": {
+ "name": "installed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "scheduled_tasks": {
+ "name": "scheduled_tasks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "last_run": {
+ "name": "last_run",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "next_run": {
+ "name": "next_run",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "server_settings": {
+ "name": "server_settings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "minecraft_path": {
+ "name": "minecraft_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "server_jar": {
+ "name": "server_jar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "server_version": {
+ "name": "server_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "server_type": {
+ "name": "server_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_ram": {
+ "name": "max_ram",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 4096
+ },
+ "min_ram": {
+ "name": "min_ram",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 1024
+ },
+ "rcon_enabled": {
+ "name": "rcon_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "rcon_port": {
+ "name": "rcon_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 25575
+ },
+ "rcon_password": {
+ "name": "rcon_password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "java_args": {
+ "name": "java_args",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "auto_start": {
+ "name": "auto_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "restart_on_crash": {
+ "name": "restart_on_crash",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "backup_enabled": {
+ "name": "backup_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "backup_schedule": {
+ "name": "backup_schedule",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bluemap_enabled": {
+ "name": "bluemap_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "bluemap_url": {
+ "name": "bluemap_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'moderator'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verifications": {
+ "name": "verifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index f042696..5ffd025 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1772980984285,
"tag": "0000_overjoyed_thundra",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1772984147555,
+ "tag": "0001_gifted_loa",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/lib/auth/client.ts b/lib/auth/client.ts
index 678d0b4..1d4324b 100644
--- a/lib/auth/client.ts
+++ b/lib/auth/client.ts
@@ -10,7 +10,11 @@ import type { Auth } from "./index";
* code. Mirrors the plugins registered on the server-side `auth` instance.
*/
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: [
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
diff --git a/lib/auth/index.ts b/lib/auth/index.ts
index 0db9b59..203a843 100644
--- a/lib/auth/index.ts
+++ b/lib/auth/index.ts
@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
+import { count, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
@@ -19,13 +20,14 @@ export const auth = betterAuth({
// -------------------------------------------------------------------------
database: drizzleAdapter(db, {
provider: "sqlite",
+ // Keys must match Better Auth's internal model names (singular).
+ // usePlural: false (default) → "user", "session", "account", "verification"
schema: {
- users: schema.users,
- sessions: schema.sessions,
- accounts: schema.accounts,
- verifications: schema.verifications,
+ user: schema.users,
+ session: schema.sessions,
+ account: schema.accounts,
+ verification: schema.verifications,
},
- usePlural: false,
}),
// -------------------------------------------------------------------------
@@ -52,6 +54,30 @@ export const auth = betterAuth({
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)
+ .where(eq(schema.users.id, user.id));
+ console.log(`[Auth] First user ${user.id} (${user.email}) promoted to admin`);
+ }
+ },
+ },
+ },
+ },
+
// -------------------------------------------------------------------------
// Plugins
// -------------------------------------------------------------------------
@@ -104,5 +130,18 @@ export type Auth = typeof auth;
/** The server-side session type returned by auth.api.getSession */
export type Session = typeof auth.$Infer.Session.session;
-/** The user type embedded in every session */
-export type User = typeof auth.$Infer.Session.user;
+/** The user type embedded in every session, with custom additionalFields */
+export type User = typeof auth.$Infer.Session.user & {
+ role?: "superadmin" | "admin" | "moderator" | null;
+};
+
+type RawSession = NonNullable>>;
+
+/** Typed wrapper around auth.api.getSession that includes the role field */
+export async function getAuthSession(
+ headers: Headers,
+): Promise<(Omit & { user: User }) | null> {
+ return auth.api.getSession({ headers }) as Promise<
+ (Omit & { user: User }) | null
+ >;
+}
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index a95b0e6..395d9c9 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -24,8 +24,8 @@ export const users = sqliteTable("users", {
})
.notNull()
.default("moderator"),
- createdAt: integer("created_at").notNull(),
- updatedAt: integer("updated_at").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
export const sessions = sqliteTable("sessions", {
@@ -34,11 +34,11 @@ export const sessions = sqliteTable("sessions", {
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
- expiresAt: integer("expires_at").notNull(),
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
- createdAt: integer("created_at").notNull(),
- updatedAt: integer("updated_at").notNull(),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
export const accounts = sqliteTable("accounts", {
@@ -50,18 +50,20 @@ export const accounts = sqliteTable("accounts", {
providerId: text("provider_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
- expiresAt: integer("expires_at"),
- createdAt: integer("created_at").notNull(),
- updatedAt: integer("updated_at").notNull(),
+ idToken: text("id_token"),
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
+ 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", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
- expiresAt: integer("expires_at").notNull(),
- createdAt: integer("created_at").notNull(),
- updatedAt: integer("updated_at").notNull(),
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
diff --git a/next.config.ts b/next.config.ts
index 3ae42e6..b299518 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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() {
- const cspDirectives = [
- "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",
- value: cspDirectives,
- },
- {
- key: "X-Frame-Options",
- value: "SAMEORIGIN",
- },
- {
- key: "X-Content-Type-Options",
- value: "nosniff",
- },
- {
- key: "Referrer-Policy",
- value: "strict-origin-when-cross-origin",
- },
- {
- key: "Permissions-Policy",
- value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
- },
- {
- key: "X-DNS-Prefetch-Control",
- value: "on",
- },
- {
- key: "Strict-Transport-Security",
- value: "max-age=63072000; includeSubDomains; preload",
- },
- ];
-
return [
{
source: "/(.*)",
- headers: securityHeaders,
+ headers: [
+ // CSP is intentionally omitted here — proxy.ts owns it.
+ { key: "X-Frame-Options", value: "SAMEORIGIN" },
+ { key: "X-Content-Type-Options", value: "nosniff" },
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
+ {
+ key: "Permissions-Policy",
+ value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
+ },
+ { key: "X-DNS-Prefetch-Control", value: "on" },
+ {
+ key: "Strict-Transport-Security",
+ value: "max-age=63072000; includeSubDomains; preload",
+ },
+ ],
},
];
},
diff --git a/proxy.ts b/proxy.ts
index 8cb600c..a76ccb6 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -51,14 +51,24 @@ function isPublicPath(pathname: string): boolean {
// ---------------------------------------------------------------------------
// Security headers applied to every response
// ---------------------------------------------------------------------------
+const isDev = process.env.NODE_ENV !== "production";
+
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 [
"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",
"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",
- "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-ancestors 'self'",
"worker-src 'self' blob:",
diff --git a/types/better-auth.d.ts b/types/better-auth.d.ts
new file mode 100644
index 0000000..e430317
--- /dev/null
+++ b/types/better-auth.d.ts
@@ -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 {};