feat: integrate Cloudflare Turnstile, error handling, design tweaks
This commit is contained in:
parent
b474be9227
commit
e01b54aca7
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type React from "react"
|
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 { Card, CardContent, CardHeader, CardFooter, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@ -10,11 +10,19 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
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 { useRouter } from "next/navigation"
|
||||||
import validator from "validator"
|
|
||||||
import PasswordValidator from "password-validator"
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
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() {
|
export default function Signup() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -29,10 +37,13 @@ export default function Signup() {
|
|||||||
migrateTerms: false,
|
migrateTerms: false,
|
||||||
migrateName: "",
|
migrateName: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
const [isValid, setIsValid] = useState(false)
|
const [isValid, setIsValid] = useState(false)
|
||||||
const [validationMessage, setValidationMessage] = useState("")
|
const [validationMessage, setValidationMessage] = useState("")
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [turnstileStatus, setTurnstileStatus] = useState<"success" | "error" | "expired" | "required">("required")
|
||||||
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
|
const [errorAlert, setErrorAlert] = useState<string | null>(null)
|
||||||
|
const [forceRefresh, setForceRefresh] = useState(false)
|
||||||
|
|
||||||
const fadeInOut = {
|
const fadeInOut = {
|
||||||
initial: { opacity: 0, y: 20 },
|
initial: { opacity: 0, y: 20 },
|
||||||
@ -47,51 +58,133 @@ export default function Signup() {
|
|||||||
...prev,
|
...prev,
|
||||||
[name]: type === "checkbox" ? checked : value,
|
[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(() => {
|
useEffect(() => {
|
||||||
if (formType === "create") {
|
if (formType === "create") {
|
||||||
const { name, emailUsername, emailDomain, password, terms } = formData
|
const { name, emailUsername, emailDomain, password, terms } = formData
|
||||||
const isNameValid = name.length >= 2
|
if (name.length < 2) {
|
||||||
const isEmailValid = validateEmail(emailUsername, emailDomain)
|
setIsValid(false)
|
||||||
const isPasswordValid = validatePassword(password)
|
setValidationMessage("Enter your name")
|
||||||
setIsValid(isNameValid && isEmailValid && isPasswordValid && terms)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNameValid) setValidationMessage("Enter your name")
|
const emailValidation = validateEmail(emailUsername, emailDomain)
|
||||||
else if (!isEmailValid) setValidationMessage("Enter a valid email address")
|
if (!emailValidation.valid) {
|
||||||
else if (!isPasswordValid) setValidationMessage("Weak Password")
|
setIsValid(false)
|
||||||
else if (!terms) setValidationMessage("Accept the terms")
|
setValidationMessage(emailValidation.message)
|
||||||
else setValidationMessage("Create Account")
|
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") {
|
} else if (formType === "migrate") {
|
||||||
const { emailUsername, emailDomain, migratePassword, migrateTerms, migrateName } = formData
|
const { emailUsername, emailDomain, migratePassword, migrateTerms, migrateName } = formData
|
||||||
const isEmailValid = validateEmail(emailUsername, emailDomain)
|
if (migrateName.length < 2) {
|
||||||
const isPasswordValid = validatePassword(migratePassword)
|
setIsValid(false)
|
||||||
const isNameValid = migrateName.length >= 2
|
setValidationMessage("Enter your name")
|
||||||
setIsValid(isEmailValid && isPasswordValid && migrateTerms && isNameValid)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!isNameValid) setValidationMessage("Enter your name")
|
const emailValidation = validateEmail(emailUsername, emailDomain)
|
||||||
else if (!isEmailValid) setValidationMessage("Enter a valid email address")
|
if (!emailValidation.valid) {
|
||||||
else if (!isPasswordValid) setValidationMessage("Weak Password")
|
setIsValid(false)
|
||||||
else if (!migrateTerms) setValidationMessage("Accept the terms")
|
setValidationMessage(emailValidation.message)
|
||||||
else setValidationMessage("Migrate Account")
|
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 = () => {
|
const getButtonIcon = () => {
|
||||||
if (isValid) return <CheckCircle2 className="mr-2 h-4 w-4" />
|
if (isValid) return <CheckCircle2 size={30} />
|
||||||
if (validationMessage.includes("name")) return <User className="mr-2 h-4 w-4" />
|
if (validationMessage.includes("name")) return <User size={30} />
|
||||||
if (validationMessage.includes("email")) return <Mail className="mr-2 h-4 w-4" />
|
if (validationMessage.includes("Email") || validationMessage.includes("email")) return <Mail size={30} />
|
||||||
if (validationMessage.includes("Password")) return <Lock className="mr-2 h-4 w-4" />
|
if (validationMessage.includes("Password") || validationMessage.includes("password")) return <Lock size={30} />
|
||||||
if (validationMessage.includes("terms")) return <AlertCircle className="mr-2 h-4 w-4" />
|
if (validationMessage.includes("terms")) return <AlertCircle size={30} />
|
||||||
|
if (validationMessage.includes("robot") || validationMessage.includes("Security")) return <Bot size={30} />
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
setErrorAlert(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (turnstileStatus !== "success") {
|
||||||
|
setValidationMessage("Please verify you are not a robot")
|
||||||
|
setIsSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const email = `${formData.emailUsername}@${formData.emailDomain}`
|
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", {
|
const response = await fetch("/api/users/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -102,58 +195,64 @@ export default function Signup() {
|
|||||||
email: email,
|
email: email,
|
||||||
password: formType === "create" ? formData.password : formData.migratePassword,
|
password: formType === "create" ? formData.password : formData.migratePassword,
|
||||||
migrate: formType === "migrate",
|
migrate: formType === "migrate",
|
||||||
|
token: token,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
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) {
|
if (data.success) {
|
||||||
router.push("/account/signup/success")
|
router.push("/account/signup/success")
|
||||||
} else {
|
} else {
|
||||||
setValidationMessage(data.message || "Failed to create account. Please try again.")
|
setErrorAlert(data.message || "Failed to create account.")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[!] " + error)
|
console.error("Form submission error:", error)
|
||||||
setValidationMessage("An error occurred. Please contact us (see the sidebar)")
|
setErrorAlert("An unexpected error occurred. Please try again later.")
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
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 (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center p-4">
|
<div className="flex h-screen items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-md overflow-hidden">
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Account Setup</CardTitle>
|
<CardTitle className="text-2xl">Account Setup</CardTitle>
|
||||||
<CardDescription>Create a new account or migrate an existing one.</CardDescription>
|
<CardDescription>Create a new account or migrate an existing one.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{errorAlert && (
|
||||||
|
<Alert variant="destructive" className="text-red-500 mb-4">
|
||||||
|
<AlertCircle color={"#EF4444" /* this is text-red-500 btw */} size={18} />
|
||||||
|
<AlertTitle className="text-lg font-bold">Oops! Something went wrong.</AlertTitle>
|
||||||
|
<AlertDescription>{errorAlert}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{formType === "initial" && (
|
{formType === "initial" && (
|
||||||
<motion.div key="initial" {...fadeInOut} className="space-y-4">
|
<motion.div key="initial" {...fadeInOut} className="space-y-4">
|
||||||
<Button onClick={() => setFormType("create")} className="w-full h-16 text-lg">
|
<Button onClick={() => setFormType("create")} className="w-full h-16 text-lg">
|
||||||
<UserPlus className="mr-2 h-6 w-6" />
|
<UserPlus className="mr-2" />
|
||||||
Create New Account
|
Create New Account
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setFormType("migrate")} className="w-full h-16 text-lg">
|
<Button onClick={() => setFormType("migrate")} className="w-full h-16 text-lg">
|
||||||
<UserCog className="mr-2 h-6 w-6" />
|
<UserCog className="mr-2" />
|
||||||
Migrate p0ntus mail Account
|
Migrate p0ntus mail Account
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
{formType === "create" && (
|
{formType === "create" && (
|
||||||
<motion.form key="create" {...fadeInOut} className="space-y-4" onSubmit={handleSubmit}>
|
<motion.form key="create" ref={formRef} {...fadeInOut} className="space-y-4" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -182,7 +281,10 @@ export default function Signup() {
|
|||||||
<Select
|
<Select
|
||||||
name="emailDomain"
|
name="emailDomain"
|
||||||
value={formData.emailDomain}
|
value={formData.emailDomain}
|
||||||
onValueChange={(value) => setFormData((prev) => ({ ...prev, emailDomain: value }))}
|
onValueChange={(value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, emailDomain: value }))
|
||||||
|
if (errorAlert) setErrorAlert(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Select domain" />
|
<SelectValue placeholder="Select domain" />
|
||||||
@ -212,7 +314,7 @@ export default function Signup() {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 py-2">
|
<div className="flex items-center space-x-4 py-2">
|
||||||
@ -221,7 +323,10 @@ export default function Signup() {
|
|||||||
name="terms"
|
name="terms"
|
||||||
required
|
required
|
||||||
checked={formData.terms}
|
checked={formData.terms}
|
||||||
onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, terms: checked }))}
|
onCheckedChange={(checked) => {
|
||||||
|
setFormData((prev) => ({ ...prev, terms: checked }))
|
||||||
|
if (errorAlert) setErrorAlert(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="terms" className="text-sm">
|
<Label htmlFor="terms" className="text-sm">
|
||||||
I agree to the{" "}
|
I agree to the{" "}
|
||||||
@ -234,10 +339,35 @@ export default function Signup() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{!forceRefresh && (
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_CF_SITEKEY!}
|
||||||
|
retry="auto"
|
||||||
|
refreshExpired="auto"
|
||||||
|
onError={() => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</motion.form>
|
</motion.form>
|
||||||
)}
|
)}
|
||||||
{formType === "migrate" && (
|
{formType === "migrate" && (
|
||||||
<motion.form key="migrate" {...fadeInOut} className="space-y-4" onSubmit={handleSubmit}>
|
<motion.form key="migrate" ref={formRef} {...fadeInOut} className="space-y-4" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="migrateName">Name</Label>
|
<Label htmlFor="migrateName">Name</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -266,7 +396,10 @@ export default function Signup() {
|
|||||||
<Select
|
<Select
|
||||||
name="emailDomain"
|
name="emailDomain"
|
||||||
value={formData.emailDomain}
|
value={formData.emailDomain}
|
||||||
onValueChange={(value) => setFormData((prev) => ({ ...prev, emailDomain: value }))}
|
onValueChange={(value) => {
|
||||||
|
setFormData((prev) => ({ ...prev, emailDomain: value }))
|
||||||
|
if (errorAlert) setErrorAlert(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Select domain" />
|
<SelectValue placeholder="Select domain" />
|
||||||
@ -308,7 +441,10 @@ export default function Signup() {
|
|||||||
name="migrateTerms"
|
name="migrateTerms"
|
||||||
required
|
required
|
||||||
checked={formData.migrateTerms}
|
checked={formData.migrateTerms}
|
||||||
onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, migrateTerms: checked }))}
|
onCheckedChange={(checked) => {
|
||||||
|
setFormData((prev) => ({ ...prev, migrateTerms: checked }))
|
||||||
|
if (errorAlert) setErrorAlert(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="migrateTerms" className="text-sm">
|
<Label htmlFor="migrateTerms" className="text-sm">
|
||||||
I agree to the{" "}
|
I agree to the{" "}
|
||||||
@ -321,40 +457,70 @@ export default function Signup() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{!forceRefresh && (
|
||||||
|
<Turnstile
|
||||||
|
siteKey={process.env.NEXT_PUBLIC_CF_SITEKEY!}
|
||||||
|
retry="auto"
|
||||||
|
refreshExpired="auto"
|
||||||
|
onError={() => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</motion.form>
|
</motion.form>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{formType !== "initial" ? (
|
{!forceRefresh ? (
|
||||||
|
formType !== "initial" ? (
|
||||||
|
<motion.div key="buttons" {...fadeInOut} className="w-full space-y-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full mb-4"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Loader size={30} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
getButtonIcon()
|
||||||
|
)}
|
||||||
|
{isSubmitting ? "Submitting..." : validationMessage}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full" onClick={() => setFormType("initial")}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div key="welcome" {...fadeInOut} className="flex w-full justify-center items-center">
|
||||||
|
<span className="text-sm text-center">Welcome to the LibreCloud family!</span>
|
||||||
|
<Heart size={16} className="ml-1" />
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
<motion.div key="buttons" {...fadeInOut} className="w-full space-y-2">
|
<motion.div key="buttons" {...fadeInOut} className="w-full space-y-2">
|
||||||
<Button
|
<Button className="w-full" onClick={() => { setFormType("initial"); setForceRefresh(false); setErrorAlert(null) }}>
|
||||||
type="submit"
|
<ArrowLeft size={30} />
|
||||||
className="w-full mb-4"
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<motion.div
|
|
||||||
className="h-5 w-5 animate-spin rounded-full border-b-2 border-white"
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
getButtonIcon()
|
|
||||||
)}
|
|
||||||
{isSubmitting ? "Submitting..." : validationMessage}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" className="w-full" onClick={() => setFormType("initial")}>
|
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
|
||||||
<motion.div key="welcome" {...fadeInOut} className="flex w-full justify-center items-center">
|
|
||||||
<span className="text-sm text-center">Welcome to the LibreCloud family!</span>
|
|
||||||
<Heart className="h-4 w-4 ml-1" />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
@ -1,23 +1,42 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
import { validateToken } from "@/lib/utils"
|
||||||
|
|
||||||
// This endpoint has two functions:
|
// This endpoint has two functions:
|
||||||
// (1) Create a new LibreCloud user (Authentik, Email)
|
// (1) Create a new LibreCloud user (Authentik, Email)
|
||||||
// (2) Migrate a p0ntus mail account to a LibreCloud account (creates/migrates 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) {
|
async function createEmail(email: string, password: string, migrate: boolean) {
|
||||||
const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, {
|
try {
|
||||||
email,
|
if (!process.env.MAIL_CONNECT_API_URL) {
|
||||||
password,
|
console.error("[!] Missing MAIL_CONNECT_API_URL environment variable")
|
||||||
migrate,
|
return { success: false, message: "Server configuration error" }
|
||||||
})
|
}
|
||||||
|
|
||||||
const responseData = response.data
|
const response = await axios.post(`${process.env.MAIL_CONNECT_API_URL}/accounts/add`, {
|
||||||
if (responseData.success) {
|
email,
|
||||||
return response.data
|
password,
|
||||||
} else if (responseData.error) {
|
migrate,
|
||||||
return { success: false, message: responseData.error }
|
})
|
||||||
} else {
|
|
||||||
|
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" }
|
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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { name, email, password, migrate } = body
|
const { name, email, password, migrate, token } = body
|
||||||
|
|
||||||
|
// Validate fields
|
||||||
if (!name || !email || !password) {
|
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
|
// Create Authentik user
|
||||||
@ -43,6 +74,9 @@ export async function POST(request: Request) {
|
|||||||
email,
|
email,
|
||||||
type: "internal",
|
type: "internal",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[i] Creating user in Authentik:", { username: genUser, email })
|
||||||
|
|
||||||
const response = await axios.request({
|
const response = await axios.request({
|
||||||
method: "post",
|
method: "post",
|
||||||
maxBodyLength: Infinity,
|
maxBodyLength: Infinity,
|
||||||
@ -57,56 +91,82 @@ export async function POST(request: Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (response.data?.detail) {
|
if (response.data?.detail) {
|
||||||
console.log(response.data.detail)
|
console.error("[!] Authentik user creation issue:", response.data.detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status !== 201) {
|
if (response.status !== 201) {
|
||||||
if (response.data.username && response.data.username[0] === "This field must be unique.") {
|
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: "Username already exists" }, { status: 409 })
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updCfg.status === 204) {
|
console.error("Failed to create user in Authentik:", response.status, response.data)
|
||||||
// account created successfully, now create email
|
return NextResponse.json({ success: false, message: "Failed to create user account" }, { status: 500 })
|
||||||
const emailRes = await createEmail(email, password, migrate)
|
}
|
||||||
return NextResponse.json(emailRes)
|
|
||||||
} else if (updCfg.status === 400) {
|
// User created successfully, now set password
|
||||||
return NextResponse.json({ success: false, message: "Bad Request - Failed to set Authentik 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 {
|
} 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) {
|
} catch (error: unknown) {
|
||||||
if (axios.isAxiosError(error) && error.response?.data?.detail) {
|
if (axios.isAxiosError(error)) {
|
||||||
console.log("Error:", error.response.data.detail)
|
if (error.response?.data?.detail) {
|
||||||
return NextResponse.json({ success: false, message: "Internal server error - Failed to create user" })
|
console.error("[!] Request error with detail:", error.response.data.detail)
|
||||||
} else if (axios.isAxiosError(error) && error.response?.data?.error) {
|
return NextResponse.json({ success: false, message: "Server error - Failed to create user" }, { status: 500 })
|
||||||
console.log("Error:", error.response.data.error)
|
} else if (error.response?.data?.error) {
|
||||||
return NextResponse.json({ success: false, message: error.response.data.error })
|
console.error("[!] Request error (passed from Authentik):", error.response.data.error)
|
||||||
} else {
|
return NextResponse.json({ success: false, message: error.response.data.error }, { status: 500 })
|
||||||
console.log(error)
|
} else if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
|
||||||
return NextResponse.json({ success: false, message: "Internal server error - Failed to create user" })
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
76
lib/utils.ts
76
lib/utils.ts
@ -1,6 +1,82 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import { validateTurnstileToken } from "next-turnstile"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -29,13 +29,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.0",
|
"cmdk": "1.0.0",
|
||||||
"cookies-next": "^5.1.0",
|
"cookies-next": "^5.1.0",
|
||||||
"framer-motion": "^12.4.10",
|
"framer-motion": "^12.4.11",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"next": "^15.2.1",
|
"next": "^15.2.1",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"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",
|
"password-validator": "^5.3.0",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user