feat: session checking on signup, improve signup flow and validation, update mail/auth endpoints to use validation, cleanup

This commit is contained in:
Aidan 2025-04-21 16:12:28 -04:00
parent 09cb5de6f2
commit 6edb1f6c16
8 changed files with 209 additions and 41 deletions

View File

@ -10,12 +10,13 @@ import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import Link from "next/link"
import { motion, AnimatePresence } from "motion/react"
import { UserPlus, UserCog, Heart, AlertCircle, CheckCircle2, Mail, Lock, User, Bot, Loader, ArrowLeft } from "lucide-react"
import { UserPlus, UserCog, Heart, AlertCircle, CheckCircle2, Mail, Lock, User, Bot, Loader2, ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
import { validateEmail, validatePassword } from "@/lib/utils"
import { validateEmail, validatePassword, validateName } from "@/lib/utils"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import EmailField from "@/components/custom/signup/EmailField"
import Altcha from "@/components/custom/Altcha"
import { useSession } from "next-auth/react"
export default function Signup() {
const router = useRouter()
@ -76,9 +77,11 @@ export default function Signup() {
useEffect(() => {
if (formType === "create") {
const { name, emailUsername, emailDomain, password, terms } = formData
if (name.length < 2) {
const nameValidation = validateName(name)
if (!nameValidation.valid) {
setIsValid(false)
setValidationMessage("Enter your name")
setValidationMessage(nameValidation.message)
return
}
@ -112,9 +115,11 @@ export default function Signup() {
setValidationMessage("Create Account")
} else if (formType === "migrate") {
const { emailUsername, emailDomain, migratePassword, migrateTerms, migrateName } = formData
if (migrateName.length < 2) {
const nameValidation = validateName(migrateName)
if (!nameValidation.valid) {
setIsValid(false)
setValidationMessage("Enter your name")
setValidationMessage(nameValidation.message)
return
}
@ -151,11 +156,12 @@ export default function Signup() {
const getButtonIcon = () => {
if (isValid) return <CheckCircle2 size={30} />
if (validationMessage.includes("name")) return <User size={30} />
if (validationMessage.includes("name") || validationMessage.includes("Name")) return <User size={30} />
if (validationMessage.includes("Email") || validationMessage.includes("email")) return <Mail size={30} />
if (validationMessage.includes("Password") || validationMessage.includes("password")) return <Lock size={30} />
if (validationMessage.includes("terms")) return <AlertCircle size={30} />
if (validationMessage.includes("robot") || validationMessage.includes("Security")) return <Bot size={30} />
if (validationMessage.includes("special characters")) return <AlertCircle size={30} />
return null
}
@ -219,12 +225,54 @@ export default function Signup() {
}
}
const { data: session } = useSession()
useEffect(() => {
if (session) {
router.push("/account/dashboard")
}
}, [session, router])
return (
<div className="flex h-screen items-center justify-center p-4">
<Card className="w-full max-w-md overflow-hidden">
<CardHeader>
<AnimatePresence mode="wait">
{formType === "initial" ? (
<motion.div
key="initial-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<CardTitle className="text-2xl">Account Setup</CardTitle>
<CardDescription>Create a new account or migrate an existing one.</CardDescription>
</motion.div>
) : formType === "create" ? (
<motion.div
key="create-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<CardTitle className="text-2xl">Create New Account</CardTitle>
<CardDescription>Set up your new LibreCloud account.</CardDescription>
</motion.div>
) : (
<motion.div
key="migrate-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<CardTitle className="text-2xl">Migrate Account</CardTitle>
<CardDescription>Transfer your p0ntus mail account to LibreCloud.</CardDescription>
</motion.div>
)}
</AnimatePresence>
</CardHeader>
<CardContent>
{errorAlert && (
@ -301,11 +349,11 @@ export default function Signup() {
/>
<Label htmlFor="terms" className="text-sm">
I agree to the{" "}
<Link href="/terms" className="underline">
<Link href="/legal/terms" className="underline">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline">
<Link href="/legal/privacy" className="underline">
Privacy Policy
</Link>
</Label>
@ -378,11 +426,11 @@ export default function Signup() {
/>
<Label htmlFor="migrateTerms" className="text-sm">
I agree to the{" "}
<Link href="/terms" className="underline">
<Link href="/legal/terms" className="underline">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline">
<Link href="/legal/privacy" className="underline">
Privacy Policy
</Link>
</Label>
@ -416,7 +464,7 @@ export default function Signup() {
onClick={handleSubmit}
>
{isSubmitting ? (
<Loader size={30} className="animate-spin" />
<Loader2 size={30} className="animate-spin" />
) : (
getButtonIcon()
)}
@ -446,4 +494,3 @@ export default function Signup() {
</div>
)
}

View File

@ -140,7 +140,7 @@ const WelcomePage = () => {
From here, you can proceed to sign in to your newly created account with Authentik. It will handle all the sign-ins for your account except for Pass (Vaultwarden).
</p>
<Link href="/account/login">
<Button className="mt-8"><CircleArrowRight /> Login</Button>
<Button className="mt-8 cursor-pointer"><CircleArrowRight /> Login</Button>
</Link>
</motion.div>
</div>

View File

@ -1,6 +1,7 @@
import { auth } from "@/auth"
import axios from "axios"
import { NextResponse } from "next/server"
import { validatePassword } from "@/lib/utils"
export async function POST(request: Request) {
try {
@ -14,6 +15,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid password" }, { status: 400 })
}
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
return NextResponse.json({ error: passwordValidation.message }, { status: 400 })
}
// Get user ID from email
const user = await axios.request({
method: "get",

View File

@ -1,5 +1,6 @@
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import { validatePassword } from "@/lib/utils"
export async function POST(request: Request) {
try {
@ -13,6 +14,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid password" }, { status: 400 })
}
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
return NextResponse.json({ error: passwordValidation.message }, { status: 400 })
}
const { email } = session.user
const response = await fetch(`${process.env.MAIL_CONNECT_API_URL}/accounts/update/password`, {

View File

@ -1,6 +1,7 @@
import axios from "axios"
import { NextResponse } from "next/server"
import { validateToken } from "@/lib/utils"
import { validateToken, validateName, validateEmail } from "@/lib/utils"
import { auth } from "@/auth"
// This endpoint has two functions:
// (1) Create a new LibreCloud user (Authentik, Email)
@ -68,15 +69,33 @@ export async function POST(request: Request) {
let atkCreated = false
let userID = ""
const session = await auth()
if (session) {
return NextResponse.json({ success: false, message: "You are already logged in" }, { status: 403 })
}
try {
const body = await request.json()
const { name, email, password, migrate, token } = body
// Validate fields
// Make sure all fields are present
if (!name || !email || !password) {
return NextResponse.json({ success: false, message: "The form you submitted is incomplete" }, { status: 400 })
}
// Validate name again
const nameValidation = validateName(name)
if (!nameValidation.valid) {
return NextResponse.json({ success: false, message: nameValidation.message }, { status: 400 })
}
// and email
const [emailUsername, emailDomain] = email.split('@')
const emailValidation = validateEmail(emailUsername, emailDomain)
if (!emailValidation.valid) {
return NextResponse.json({ success: false, message: emailValidation.message }, { status: 400 })
}
const tokenValidation = await validateToken(token)
if (!tokenValidation.success) {
console.error("Altcha validation failed:", tokenValidation.error)

View File

@ -4,7 +4,7 @@ import React, { useState, useRef, useEffect, useCallback } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { CheckCircleIcon, Key, Loader2, XCircleIcon } from "lucide-react"
import { CheckCircleIcon, Key, Loader2, XCircleIcon, AlertCircle } from "lucide-react"
import Link from "next/link"
import {
Dialog,
@ -17,6 +17,7 @@ import {
} from "@/components/ui/dialog"
import { toast } from "sonner"
import { motion, useAnimationControls } from "framer-motion"
import { validatePassword } from "@/lib/utils"
export function ChangeEmailPassword() {
const [newPassword, setNewPassword] = useState("")
@ -28,6 +29,7 @@ export function ChangeEmailPassword() {
const [isHolding, setIsHolding] = useState(false)
const holdDuration = 10
const [remainingTime, setRemainingTime] = useState(holdDuration)
const [passwordError, setPasswordError] = useState<string | null>(null)
const submitPasswordChange = async () => {
setLoading(true)
@ -86,7 +88,7 @@ export function ChangeEmailPassword() {
},
})
controls.set({ "--progress": "0%" })
} finally {
}
setLoading(false)
setIsHolding(false)
if (holdTimeoutRef.current) {
@ -98,16 +100,28 @@ export function ChangeEmailPassword() {
intervalRef.current = null
}
}
}
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setNewPassword(value)
// Validate password
const validation = validatePassword(value)
if (!validation.valid) {
setPasswordError(validation.message)
} else {
setPasswordError(null)
}
}
const holdDurationMs = holdDuration * 1000
const handleHoldStart = () => {
if (loading || newPassword.length < 8) return
if (loading || newPassword.length < 8 || passwordError) return
setIsHolding(true)
controls.set({ "--progress": "0%" })
@ -205,12 +219,19 @@ export function ChangeEmailPassword() {
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onChange={handlePasswordChange}
className="mt-1.5"
/>
<p className="text-xs text-muted-foreground">
Password must be at least 8 characters long.
{passwordError ? (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle size={14} />
{passwordError}
</p>
) : (
<p className="text-xs text-muted-foreground">
Password must be 8-128 characters long, include letters and digits, and not contain spaces.
</p>
)}
</div>
<DialogFooter>
<motion.div
@ -218,7 +239,7 @@ export function ChangeEmailPassword() {
style={{ "--progress": "0%", "--progress-color": "hsl(var(--primary) / 0.5)" } as React.CSSProperties}
>
<Button
disabled={loading || newPassword.length < 8}
disabled={loading || newPassword.length < 8 || !!passwordError}
onMouseDown={handleHoldStart}
onMouseUp={handleHoldEnd}
onMouseLeave={handleHoldEnd}

View File

@ -5,6 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { validateEmail } from "@/lib/utils"
import { AlertCircle } from "lucide-react"
interface ChangePasswordProps {
gitEmail: string;
@ -14,9 +16,35 @@ export function ChangeEmail({ gitEmail }: ChangePasswordProps) {
const [newEmail, setNewEmail] = useState("")
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [emailError, setEmailError] = useState<string | null>(null);
const handleEmailChange = async (e: React.FormEvent<HTMLFormElement>) => {
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setNewEmail(value);
// Validate email
const [username, domain] = value.split('@');
if (username && domain) {
const validation = validateEmail(username, domain);
if (!validation.valid) {
setEmailError(validation.message);
} else {
setEmailError(null);
}
} else if (value) {
setEmailError("Invalid email format");
} else {
setEmailError(null);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (emailError) {
return;
}
setLoading(true);
setMessage(null);
await new Promise((resolve) => setTimeout(resolve, 5000));
@ -30,7 +58,7 @@ export function ChangeEmail({ gitEmail }: ChangePasswordProps) {
<CardTitle>Change Email</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleEmailChange} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-email">Current Email</Label>
<Input
@ -41,9 +69,23 @@ export function ChangeEmail({ gitEmail }: ChangePasswordProps) {
</div>
<div className="space-y-2">
<Label htmlFor="new-email">New Email</Label>
<Input id="new-email" type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
<Input
id="new-email"
type="email"
value={newEmail}
onChange={handleEmailChange}
/>
{emailError && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle size={14} />
{emailError}
</p>
)}
</div>
<Button type="submit">
<Button
type="submit"
disabled={!!emailError || !newEmail}
>
{loading ? "Changing..." : "Change Email"}
</Button>
{message && <p className="text-sm text-center">{message}</p>}

View File

@ -37,6 +37,11 @@ export function validateEmail(username: string, domain: string) {
return { valid: false, message: "Email is required" }
}
const specialCharsRegex = /[<>()[\]\\,;:{}"']/
if (specialCharsRegex.test(username)) {
return { valid: false, message: "Email contains special characters" }
}
const email = `${username}@${domain}`
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@ -47,6 +52,28 @@ export function validateEmail(username: string, domain: string) {
return { valid: true, message: "" }
}
// name validation
export function validateName(name: string) {
if (!name) {
return { valid: false, message: "Name is required" }
}
if (name.length < 2) {
return { valid: false, message: "Name must be at least 2 characters" }
}
if (name.length > 64) {
return { valid: false, message: "Name must be less than 64 characters" }
}
const specialCharsRegex = /[<>()[\]\\,;:{}"']/
if (specialCharsRegex.test(name)) {
return { valid: false, message: "Name contains special characters" }
}
return { valid: true, message: "" }
}
// Password validation
export function validatePassword(password: string) {
if (!password) {