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 && ( + + )} +
+ ); +} + +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 */} + 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 {};