diff --git a/app/(auth)/accept-invite/page.tsx b/app/(auth)/accept-invite/page.tsx new file mode 100644 index 0000000..8e0ffe8 --- /dev/null +++ b/app/(auth)/accept-invite/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { Suspense, useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert } from "@/components/ui/alert"; +import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; + +function AcceptInvitePageInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (!token) { + setError("Invalid or missing invitation token."); + } + }, [token]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (password.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/accept-invite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, name, password }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setSuccess(true); + setTimeout(() => router.push("/login"), 2000); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+
+ + + + + + + CubeAdmin +
+
+ + + + Accept Invitation + + Create your account to access the Minecraft server panel. + + + + {success ? ( +
+ +

Account created!

+

+ Redirecting you to the login page... +

+
+ ) : ( +
+ {error && ( + + + {error} + + )} +
+ + setName(e.target.value)} + placeholder="John Doe" + required + disabled={!token || loading} + className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50" + /> +
+
+ + setPassword(e.target.value)} + placeholder="At least 8 characters" + required + disabled={!token || loading} + className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50" + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Repeat your password" + required + disabled={!token || loading} + className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50" + /> +
+ +
+ )} +
+
+
+
+ ); +} + +export default function AcceptInvitePage() { + return ( + + + + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..7c65fd5 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,377 @@ +"use client"; + +import React, { Suspense, useState, useTransition } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { z } from "zod"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth/client"; +import { cn } from "@/lib/utils"; +import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +// --------------------------------------------------------------------------- +// Validation schema +// --------------------------------------------------------------------------- +const loginSchema = z.object({ + email: z.string().email("Please enter a valid email address"), + password: z.string().min(1, "Password is required"), +}); + +type LoginFormValues = z.infer; +type FieldErrors = Partial>; + +// --------------------------------------------------------------------------- +// CubeAdmin logo (duplicated from sidebar so this page is self-contained) +// --------------------------------------------------------------------------- +function CubeIcon({ className }: { className?: string }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Form field component +// --------------------------------------------------------------------------- +interface FormFieldProps { + id: string; + label: string; + type?: string; + value: string; + onChange: (value: string) => void; + error?: string; + placeholder?: string; + autoComplete?: string; + disabled?: boolean; + children?: React.ReactNode; // for additional elements (e.g., show/hide button) + className?: string; +} + +function FormField({ + id, + label, + type = "text", + value, + onChange, + error, + placeholder, + autoComplete, + disabled, + children, + className, +}: FormFieldProps) { + return ( +
+ +
+ 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 && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Login page +// --------------------------------------------------------------------------- +function LoginPageInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + const [globalError, setGlobalError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function validate(): LoginFormValues | null { + const result = loginSchema.safeParse({ email, password }); + if (!result.success) { + const errors: FieldErrors = {}; + for (const issue of result.error.issues) { + const field = issue.path[0] as keyof LoginFormValues; + if (!errors[field]) errors[field] = issue.message; + } + setFieldErrors(errors); + return null; + } + setFieldErrors({}); + return result.data; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setGlobalError(null); + + const values = validate(); + if (!values) return; + + startTransition(async () => { + try { + const { error } = await authClient.signIn.email({ + email: values.email, + password: values.password, + }); + + if (error) { + // Map common Better Auth error codes to friendly messages + const message = mapAuthError(error.code ?? error.message ?? ""); + setGlobalError(message); + return; + } + + toast.success("Signed in successfully"); + router.push(callbackUrl); + router.refresh(); + } catch (err) { + setGlobalError("An unexpected error occurred. Please try again."); + console.error("[login] Unexpected error:", err); + } + }); + } + + return ( +
+ {/* Background grid pattern */} +