From e01b54aca7ca79dd8604267face8f504c1e1a59d Mon Sep 17 00:00:00 2001 From: Aidan Honor Date: Tue, 11 Mar 2025 00:55:40 -0400 Subject: [PATCH] feat: integrate Cloudflare Turnstile, error handling, design tweaks --- app/account/signup/page.tsx | 320 ++++++++++++++++++++++++++-------- app/api/users/create/route.ts | 164 +++++++++++------ lib/utils.ts | 76 ++++++++ package.json | 5 +- 4 files changed, 434 insertions(+), 131 deletions(-) diff --git a/app/account/signup/page.tsx b/app/account/signup/page.tsx index 2ff98a0..5eee11d 100644 --- a/app/account/signup/page.tsx +++ b/app/account/signup/page.tsx @@ -2,7 +2,7 @@ import type React from "react" -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { Card, CardContent, CardHeader, CardFooter, CardTitle, CardDescription } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -10,11 +10,19 @@ import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import Link from "next/link" import { motion, AnimatePresence } from "framer-motion" -import { UserPlus, UserCog, Heart, AlertCircle, CheckCircle2, Mail, Lock, User } from "lucide-react" +import { UserPlus, UserCog, Heart, AlertCircle, CheckCircle2, Mail, Lock, User, Bot, Loader, ArrowLeft } from "lucide-react" import { useRouter } from "next/navigation" -import validator from "validator" -import PasswordValidator from "password-validator" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Turnstile } from "next-turnstile" +import { validateEmail, validatePassword } from "@/lib/utils" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" + +declare global { + interface Window { + onTurnstileSuccess?: (token: string) => void + onloadTurnstileCallback?: () => void + } +} export default function Signup() { const router = useRouter() @@ -29,10 +37,13 @@ export default function Signup() { migrateTerms: false, migrateName: "", }) - const [isValid, setIsValid] = useState(false) const [validationMessage, setValidationMessage] = useState("") const [isSubmitting, setIsSubmitting] = useState(false) + const [turnstileStatus, setTurnstileStatus] = useState<"success" | "error" | "expired" | "required">("required") + const formRef = useRef(null) + const [errorAlert, setErrorAlert] = useState(null) + const [forceRefresh, setForceRefresh] = useState(false) const fadeInOut = { initial: { opacity: 0, y: 20 }, @@ -47,51 +58,133 @@ export default function Signup() { ...prev, [name]: type === "checkbox" ? checked : value, })) + + if (errorAlert) { + setErrorAlert(null) + } } + const turnstileCallback = () => { + console.log("[i] Turnstile token received") + } + + useEffect(() => { + window.onTurnstileSuccess = turnstileCallback + + return () => { + delete window.onTurnstileSuccess + } + }, []) + useEffect(() => { if (formType === "create") { const { name, emailUsername, emailDomain, password, terms } = formData - const isNameValid = name.length >= 2 - const isEmailValid = validateEmail(emailUsername, emailDomain) - const isPasswordValid = validatePassword(password) - setIsValid(isNameValid && isEmailValid && isPasswordValid && terms) + if (name.length < 2) { + setIsValid(false) + setValidationMessage("Enter your name") + return + } - if (!isNameValid) setValidationMessage("Enter your name") - else if (!isEmailValid) setValidationMessage("Enter a valid email address") - else if (!isPasswordValid) setValidationMessage("Weak Password") - else if (!terms) setValidationMessage("Accept the terms") - else setValidationMessage("Create Account") + const emailValidation = validateEmail(emailUsername, emailDomain) + if (!emailValidation.valid) { + setIsValid(false) + setValidationMessage(emailValidation.message) + return + } + + const passwordValidation = validatePassword(password) + if (!passwordValidation.valid) { + setIsValid(false) + setValidationMessage(passwordValidation.message) + return + } + + if (!terms) { + setIsValid(false) + setValidationMessage("Accept the terms") + return + } + + if (turnstileStatus !== "success") { + setIsValid(false) + setValidationMessage("Please verify you are not a robot") + return + } + + setIsValid(true) + setValidationMessage("Create Account") } else if (formType === "migrate") { const { emailUsername, emailDomain, migratePassword, migrateTerms, migrateName } = formData - const isEmailValid = validateEmail(emailUsername, emailDomain) - const isPasswordValid = validatePassword(migratePassword) - const isNameValid = migrateName.length >= 2 - setIsValid(isEmailValid && isPasswordValid && migrateTerms && isNameValid) + if (migrateName.length < 2) { + setIsValid(false) + setValidationMessage("Enter your name") + return + } - if (!isNameValid) setValidationMessage("Enter your name") - else if (!isEmailValid) setValidationMessage("Enter a valid email address") - else if (!isPasswordValid) setValidationMessage("Weak Password") - else if (!migrateTerms) setValidationMessage("Accept the terms") - else setValidationMessage("Migrate Account") + const emailValidation = validateEmail(emailUsername, emailDomain) + if (!emailValidation.valid) { + setIsValid(false) + setValidationMessage(emailValidation.message) + return + } + + const passwordValidation = validatePassword(migratePassword) + if (!passwordValidation.valid) { + setIsValid(false) + setValidationMessage(passwordValidation.message) + return + } + + if (!migrateTerms) { + setIsValid(false) + setValidationMessage("Accept the terms") + return + } + + if (turnstileStatus !== "success") { + setIsValid(false) + setValidationMessage("Please verify you are not a robot") + return + } + + setIsValid(true) + setValidationMessage("Migrate Account") } - }, [formData, formType]) + }, [formData, formType, turnstileStatus]) const getButtonIcon = () => { - if (isValid) return - if (validationMessage.includes("name")) return - if (validationMessage.includes("email")) return - if (validationMessage.includes("Password")) return - if (validationMessage.includes("terms")) return + if (isValid) return + if (validationMessage.includes("name")) return + if (validationMessage.includes("Email") || validationMessage.includes("email")) return + if (validationMessage.includes("Password") || validationMessage.includes("password")) return + if (validationMessage.includes("terms")) return + if (validationMessage.includes("robot") || validationMessage.includes("Security")) return return null } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsSubmitting(true) + setErrorAlert(null) try { + if (turnstileStatus !== "success") { + setValidationMessage("Please verify you are not a robot") + setIsSubmitting(false) + return + } + const email = `${formData.emailUsername}@${formData.emailDomain}` + const formDataObj = new FormData(formRef.current as HTMLFormElement) + const token = formDataObj.get("cf-turnstile-response") as string + + if (!token) { + setErrorAlert("Security verification token is missing. Please refresh") + setIsSubmitting(false) + setForceRefresh(true) + return + } + const response = await fetch("/api/users/create", { method: "POST", headers: { @@ -102,58 +195,64 @@ export default function Signup() { email: email, password: formType === "create" ? formData.password : formData.migratePassword, migrate: formType === "migrate", + token: token, }), }) const data = await response.json() + if (!response.ok) { + console.error("API error:", response.status, data) + setErrorAlert(data.message || `Error ${response.status}: Failed to create account`) + setIsSubmitting(false) + setForceRefresh(true) + return + } + if (data.success) { router.push("/account/signup/success") } else { - setValidationMessage(data.message || "Failed to create account. Please try again.") + setErrorAlert(data.message || "Failed to create account.") } } catch (error) { - console.log("[!] " + error) - setValidationMessage("An error occurred. Please contact us (see the sidebar)") + console.error("Form submission error:", error) + setErrorAlert("An unexpected error occurred. Please try again later.") } finally { setIsSubmitting(false) } } - function validateEmail(username: string, domain: string) { - return username.length > 0 && domain.length > 0 && validator.isEmail(`${username}@${domain}`) - } - - function validatePassword(password: string) { - const passwordSchema = new PasswordValidator() - passwordSchema.is().min(8).is().max(128).has().letters().has().digits().has().not().spaces() - - return passwordSchema.validate(password) - } - return (
- Account Setup + Account Setup Create a new account or migrate an existing one. + {errorAlert && ( + + + Oops! Something went wrong. + {errorAlert} + + )} + {formType === "initial" && ( )} {formType === "create" && ( - +
setFormData((prev) => ({ ...prev, emailDomain: value }))} + onValueChange={(value) => { + setFormData((prev) => ({ ...prev, emailDomain: value })) + if (errorAlert) setErrorAlert(null) + }} > @@ -212,7 +314,7 @@ export default function Signup() { onChange={handleInputChange} />

- Password must be 8-64 characters long, include letters and digits, and not contain spaces. + Password must be 8-128 characters long, include letters and digits, and not contain spaces.

@@ -221,7 +323,10 @@ export default function Signup() { name="terms" required checked={formData.terms} - onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, terms: checked }))} + onCheckedChange={(checked) => { + setFormData((prev) => ({ ...prev, terms: checked })) + if (errorAlert) setErrorAlert(null) + }} />
+ {!forceRefresh && ( + { + setTurnstileStatus("error") + setValidationMessage("Security check failed. Please try again.") + console.error("[!] Turnstile error occurred") + }} + onExpire={() => { + setTurnstileStatus("expired") + setValidationMessage("Security check expired. Please verify again.") + console.warn("[!] Turnstile token expired") + }} + onLoad={() => { + setTurnstileStatus("required") + }} + onVerify={() => { + setTurnstileStatus("success") + console.log("[S] Turnstile verification successful") + }} + className="flex justify-center" + /> + )}
)} {formType === "migrate" && ( - +
setFormData((prev) => ({ ...prev, emailDomain: value }))} + onValueChange={(value) => { + setFormData((prev) => ({ ...prev, emailDomain: value })) + if (errorAlert) setErrorAlert(null) + }} > @@ -308,7 +441,10 @@ export default function Signup() { name="migrateTerms" required checked={formData.migrateTerms} - onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, migrateTerms: checked }))} + onCheckedChange={(checked) => { + setFormData((prev) => ({ ...prev, migrateTerms: checked })) + if (errorAlert) setErrorAlert(null) + }} />
+ {!forceRefresh && ( + { + setTurnstileStatus("error") + setValidationMessage("Security check failed. Please try again.") + console.error("[!] Turnstile error occurred") + }} + onExpire={() => { + setTurnstileStatus("expired") + setValidationMessage("Security check expired. Please verify again.") + console.warn("[!] Turnstile token expired") + }} + onLoad={() => { + setTurnstileStatus("required") + }} + onVerify={() => { + setTurnstileStatus("success") + console.log("[S] Turnstile verification successful") + }} + className="flex justify-center" + /> + )}
)}
- {formType !== "initial" ? ( + {!forceRefresh ? ( + formType !== "initial" ? ( + + + + + ) : ( + + Welcome to the LibreCloud family! + + + ) + ) : ( - - - ) : ( - - Welcome to the LibreCloud family! - - )} diff --git a/app/api/users/create/route.ts b/app/api/users/create/route.ts index 5660553..beebf2f 100644 --- a/app/api/users/create/route.ts +++ b/app/api/users/create/route.ts @@ -1,23 +1,42 @@ import axios from "axios" import { NextResponse } from "next/server" +import { validateToken } from "@/lib/utils" // This endpoint has two functions: // (1) Create a new LibreCloud user (Authentik, Email) // (2) Migrate a p0ntus mail account to a LibreCloud account (creates/migrates Authentik/Email) async function createEmail(email: string, password: string, migrate: boolean) { - const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, { - email, - password, - migrate, - }) + try { + if (!process.env.MAIL_CONNECT_API_URL) { + console.error("[!] Missing MAIL_CONNECT_API_URL environment variable") + return { success: false, message: "Server configuration error" } + } - const responseData = response.data - if (responseData.success) { - return response.data - } else if (responseData.error) { - return { success: false, message: responseData.error } - } else { + const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, { + email, + password, + migrate, + }) + + const responseData = response.data + if (responseData.success) { + return response.data + } else if (responseData.error) { + console.error("[!] Email creation failed:", responseData.error) + return { success: false, message: responseData.error } + } else { + console.error("[!] Email creation failed with unknown error") + return { success: false, message: "Failed to create email account" } + } + } catch (error) { + console.error("[!] Email creation error:", error) + if (axios.isAxiosError(error)) { + return { + success: false, + message: error.response?.data?.error || "Failed to connect to email service", + } + } return { success: false, message: "Failed to create email account" } } } @@ -25,10 +44,22 @@ async function createEmail(email: string, password: string, migrate: boolean) { export async function POST(request: Request) { try { const body = await request.json() - const { name, email, password, migrate } = body + const { name, email, password, migrate, token } = body + // Validate fields if (!name || !email || !password) { - return NextResponse.json({ success: false, message: "Request is incomplete" }) + return NextResponse.json({ success: false, message: "The form you submitted is incomplete" }, { status: 400 }) + } + + const tokenValidation = await validateToken(token) + if (!tokenValidation.success) { + console.error("Turnstile validation failed:", tokenValidation.error) + return NextResponse.json({ success: false, message: "Robot check failed, try refreshing" }, { status: 400 }) + } + + if (!process.env.AUTHENTIK_API_URL || !process.env.AUTHENTIK_API_KEY) { + console.error("Missing Authentik environment variables") + return NextResponse.json({ success: false, message: "Server configuration error" }, { status: 500 }) } // Create Authentik user @@ -43,6 +74,9 @@ export async function POST(request: Request) { email, type: "internal", } + + console.log("[i] Creating user in Authentik:", { username: genUser, email }) + const response = await axios.request({ method: "post", maxBodyLength: Infinity, @@ -57,56 +91,82 @@ export async function POST(request: Request) { }) if (response.data?.detail) { - console.log(response.data.detail) + console.error("[!] Authentik user creation issue:", response.data.detail) } if (response.status !== 201) { if (response.data.username && response.data.username[0] === "This field must be unique.") { - return NextResponse.json({ success: false, message: "Username already exists" }) - } - return NextResponse.json({ success: false, message: "Failed to create user in Authentik" }) - } else { - const userID = response.data.pk - const updData = { - password, - } - const updCfg = await axios.request({ - method: "post", - maxBodyLength: Infinity, - url: `${process.env.AUTHENTIK_API_URL}/core/users/${userID}/set_password/`, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`, - }, - data: updData, - validateStatus: () => true, // capture response even for error status codes - }) - - if (updCfg.data?.detail) { - console.log(updCfg.data.detail) + return NextResponse.json({ success: false, message: "Username already exists" }, { status: 409 }) } - if (updCfg.status === 204) { - // account created successfully, now create email - const emailRes = await createEmail(email, password, migrate) - return NextResponse.json(emailRes) - } else if (updCfg.status === 400) { - return NextResponse.json({ success: false, message: "Bad Request - Failed to set Authentik password" }) + console.error("Failed to create user in Authentik:", response.status, response.data) + return NextResponse.json({ success: false, message: "Failed to create user account" }, { status: 500 }) + } + + // User created successfully, now set password + const userID = response.data.pk + const updData = { + password, + } + + console.log("[i] Setting password for user:", userID) + + const updCfg = await axios.request({ + method: "post", + maxBodyLength: Number.POSITIVE_INFINITY, + url: `${process.env.AUTHENTIK_API_URL}/core/users/${userID}/set_password/`, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.AUTHENTIK_API_KEY}`, + }, + data: updData, + validateStatus: () => true, // capture response even for error status codes + }) + + if (updCfg.data?.detail) { + console.error("[!] Password setting issue:", updCfg.data.detail) + } + + if (updCfg.status === 204) { + // account created successfully, now create email + console.log("[i] Creating email account for:", email) + const emailRes = await createEmail(email, password, migrate) + + if (emailRes.success) { + console.log("[S] Account creation successful for:", email) } else { - return NextResponse.json({ success: false, message: "Internal Server Error - Failed to set Authentik password" }) + console.error("[!] Email creation failed for:", email, emailRes.message) } + + return NextResponse.json(emailRes, { + status: emailRes.success ? 200 : 500, + }) + } else if (updCfg.status === 400) { + console.error("[!] Failed to set password:", updCfg.data) + return NextResponse.json({ success: false, message: "Invalid password format" }, { status: 400 }) + } else { + console.error("[!] Unknown error setting password:", updCfg.status, updCfg.data) + return NextResponse.json({ success: false, message: "Failed to complete account setup" }, { status: 500 }) } } catch (error: unknown) { - if (axios.isAxiosError(error) && error.response?.data?.detail) { - console.log("Error:", error.response.data.detail) - return NextResponse.json({ success: false, message: "Internal server error - Failed to create user" }) - } else if (axios.isAxiosError(error) && error.response?.data?.error) { - console.log("Error:", error.response.data.error) - return NextResponse.json({ success: false, message: error.response.data.error }) - } else { - console.log(error) - return NextResponse.json({ success: false, message: "Internal server error - Failed to create user" }) + if (axios.isAxiosError(error)) { + if (error.response?.data?.detail) { + console.error("[!] Request error with detail:", error.response.data.detail) + return NextResponse.json({ success: false, message: "Server error - Failed to create user" }, { status: 500 }) + } else if (error.response?.data?.error) { + console.error("[!] Request error (passed from Authentik):", error.response.data.error) + return NextResponse.json({ success: false, message: error.response.data.error }, { status: 500 }) + } else if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") { + console.error("[!] Connection error:", error.message) + return NextResponse.json( + { success: false, message: "Failed to connect to authentication service" }, + { status: 503 }, + ) + } } + + console.error("[!] Unhandled error while creating user:", error) + return NextResponse.json({ success: false, message: "An unexpected error occurred" }, { status: 500 }) } } diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..b9ddcea 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,82 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import { validateTurnstileToken } from "next-turnstile" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export async function validateToken(token: string) { + try { + if (!token) { + console.error("Validation failed: No token provided") + return { success: false, error: "No token provided" } + } + + if (!process.env.CF_SECRETKEY) { + console.error("Validation failed: Missing CF_SECRETKEY environment variable") + return { success: false, error: "Server configuration error" } + } + + const result = await validateTurnstileToken({ + token, + secretKey: process.env.CF_SECRETKEY, + }) + + if (result.success) { + return { success: true } + } else { + console.error("Validation failed:", result) + return { success: false, error: "Invalid token" } + } + } catch (error) { + console.error("Turnstile validation error:", error) + return { success: false, error: "Validation service error" } + } +} + +// Email validation +export function validateEmail(username: string, domain: string) { + if (!username || !domain) { + return { valid: false, message: "Email is required" } + } + + const email = `${username}@${domain}` + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + if (!emailRegex.test(email)) { + return { valid: false, message: "Invalid email format" } + } + + return { valid: true, message: "" } +} + +// Password validation +export function validatePassword(password: string) { + if (!password) { + return { valid: false, message: "Password is required" } + } + + if (password.length < 8) { + return { valid: false, message: "Password must be at least 8 characters" } + } + + if (password.length > 128) { + return { valid: false, message: "Password must be less than 128 characters" } + } + + if (!/[A-Za-z]/.test(password)) { + return { valid: false, message: "Password must contain letters" } + } + + if (!/\d/.test(password)) { + return { valid: false, message: "Password must contain digits" } + } + + if (/\s/.test(password)) { + return { valid: false, message: "Password cannot contain spaces" } + } + + return { valid: true, message: "" } +} + diff --git a/package.json b/package.json index 1c43e10..6ba8ef3 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,14 @@ "clsx": "^2.1.1", "cmdk": "1.0.0", "cookies-next": "^5.1.0", - "framer-motion": "^12.4.10", + "framer-motion": "^12.4.11", "geist": "^1.3.1", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "next": "^15.2.1", "next-auth": "^5.0.0-beta.25", - "next-themes": "^0.4.4", + "next-themes": "^0.4.5", + "next-turnstile": "^1.0.2", "password-validator": "^5.3.0", "prisma": "^6.4.1", "react": "^19.0.0",