diff --git a/.env.example b/.env.example index a8a04f68..9f9ef8e7 100644 --- a/.env.example +++ b/.env.example @@ -85,9 +85,8 @@ STREAM_SECRET="" # ================================================================= # EMAIL SERVICE # ================================================================= -# NodeMailer Configuration -GMAIL_USER="" # Your Gmail address -GMAIL_APP_PASSWORD="" # Gmail App Password (not regular password) +# Unsend (email) +UNSEND_API_KEY="" # ================================================================= # MAINTENANCE & OPERATIONS diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index d9fdcb01..3b446261 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,10 +1,14 @@ FROM node:latest AS base +ARG UNSEND_API_KEY + FROM base AS builder RUN apt-get update && apt-get install -y git WORKDIR /app RUN npm install -g pnpm@latest turbo@latest +ENV UNSEND_API_KEY=$UNSEND_API_KEY + COPY . . RUN turbo prune --scope=@zephyr/web --docker @@ -13,6 +17,8 @@ RUN apt-get update && apt-get install -y WORKDIR /app +ENV UNSEND_API_KEY=$UNSEND_API_KEY + COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=builder /app/turbo.json ./turbo.json @@ -46,6 +52,7 @@ ENV PATH="/app/node_modules/.bin:${PATH}" ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV TURBO_TELEMETRY_DISABLED=1 +ENV UNSEND_API_KEY=$UNSEND_API_KEY USER nextjs diff --git a/apps/web/package.json b/apps/web/package.json index af0ce772..5f62edcd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@node-rs/argon2": "^2.0.2", "@prisma/client": "^6.1.0", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-icons": "^1.3.2", @@ -66,7 +67,6 @@ "mime-types": "^2.1.35", "next": "15.1.3", "next-themes": "^0.4.4", - "nodemailer": "^6.9.16", "prism-react-renderer": "^2.4.1", "process": "^0.11.10", "qrcode.react": "^4.2.0", @@ -86,6 +86,7 @@ "stream-chat-react": "^12.8.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "unsend": "^1.3.0", "usehooks-ts": "^3.1.0", "util": "^0.12.5", "webpack": "^5.97.1", @@ -96,7 +97,6 @@ "@types/jsonwebtoken": "^9.0.7", "@types/mime-types": "^2.1.4", "@types/node": "^22.10.3", - "@types/nodemailer": "^6.4.17", "@types/react": "19.0.2", "@types/react-dom": "19.0.1", "@types/react-syntax-highlighter": "^15.5.13", diff --git a/apps/web/src/app/(auth)/client/ClientSignUpPage.tsx b/apps/web/src/app/(auth)/client/ClientSignUpPage.tsx index 5003ffb9..ea3bb6bb 100644 --- a/apps/web/src/app/(auth)/client/ClientSignUpPage.tsx +++ b/apps/web/src/app/(auth)/client/ClientSignUpPage.tsx @@ -1,12 +1,11 @@ "use client"; +import signupImage from "@zephyr-assets/signup-image.jpg"; +import SignUpForm from "@zephyr-ui/Auth/SignUpForm"; import { AnimatePresence, motion } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; -import signupImage from "@zephyr-assets/signup-image.jpg"; -import SignUpForm from "@zephyr-ui/Auth/SignUpForm"; - const fadeIn = { hidden: { opacity: 0 }, visible: { diff --git a/apps/web/src/app/(auth)/signup/actions.ts b/apps/web/src/app/(auth)/signup/actions.ts index 4d6da0a6..0edb2f63 100644 --- a/apps/web/src/app/(auth)/signup/actions.ts +++ b/apps/web/src/app/(auth)/signup/actions.ts @@ -1,44 +1,125 @@ "use server"; import { hash } from "@node-rs/argon2"; -import { createStreamUser } from "@zephyr/auth/src"; -import { sendVerificationEmail } from "@zephyr/auth/src/email/service"; +import { createStreamUser, lucia } from "@zephyr/auth/src"; +import { + isDevelopmentMode, + isEmailServiceConfigured, + sendVerificationEmail +} from "@zephyr/auth/src/email/service"; import { EMAIL_ERRORS, isEmailValid } from "@zephyr/auth/src/email/validation"; import { resendVerificationEmail } from "@zephyr/auth/src/verification/resend"; import { type SignUpValues, signUpSchema } from "@zephyr/auth/validation"; import { prisma } from "@zephyr/db"; import jwt from "jsonwebtoken"; import { generateIdFromEntropySize } from "lucia"; +import { cookies } from "next/headers"; + +interface SignUpResponse { + verificationUrl?: string; + error?: string; + success: boolean; + skipVerification?: boolean; + message?: string; +} const USERNAME_ERRORS = { TAKEN: "This username is already taken. Please choose another.", INVALID: "Username can only contain letters, numbers, and underscores.", - REQUIRED: "Username is required" + REQUIRED: "Username is required", + CREATION_FAILED: "Failed to create user account" }; const SYSTEM_ERRORS = { JWT_SECRET: "System configuration error: JWT_SECRET is not configured", USER_ID: "Failed to generate user ID", TOKEN: "Failed to generate verification token", - INVALID_PAYLOAD: "Invalid token payload data" + INVALID_PAYLOAD: "Invalid token payload data", + SESSION_CREATION: "Failed to create user session" }; -if (!process.env.JWT_SECRET) { - throw new Error(SYSTEM_ERRORS.JWT_SECRET); +async function createDevUser( + userId: string, + username: string, + email: string, + passwordHash: string +): Promise { + try { + const newUser = await prisma.user.create({ + data: { + id: userId, + username, + displayName: username, + email, + passwordHash, + emailVerified: true + } + }); + + await createStreamUser(newUser.id, newUser.username, newUser.displayName); + + // @ts-expect-error + const session = await lucia.createSession(userId, {}); + const cookieStore = await cookies(); + const sessionCookie = lucia.createSessionCookie(session.id); + cookieStore.set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + } catch (error) { + console.error("Development user creation error:", error); + try { + await prisma.user.delete({ where: { id: userId } }); + } catch (cleanupError) { + console.error("Cleanup failed:", cleanupError); + } + throw error; + } +} + +async function createProdUser( + userId: string, + username: string, + email: string, + passwordHash: string, + verificationToken: string +): Promise { + await prisma.$transaction(async (tx) => { + const newUser = await tx.user.create({ + data: { + id: userId, + username, + displayName: username, + email, + passwordHash, + emailVerified: false + } + }); + + await tx.emailVerificationToken.create({ + data: { + token: verificationToken, + userId, + expiresAt: new Date(Date.now() + 3600000) + } + }); + + await createStreamUser(newUser.id, newUser.username, newUser.displayName); + }); } export { resendVerificationEmail }; export async function signUp( credentials: SignUpValues -): Promise<{ error?: string; success?: boolean }> { +): Promise { try { if (!credentials) { return { error: "No credentials provided", success: false }; } const validationResult = signUpSchema.safeParse(credentials); - if (!validationResult.success) { const firstError = validationResult.error.errors[0]; return { @@ -84,40 +165,75 @@ export async function signUp( throw new Error(SYSTEM_ERRORS.USER_ID); } - const tokenPayload = { userId, email, timestamp: Date.now() }; - if (!process.env.JWT_SECRET) { - throw new Error(SYSTEM_ERRORS.JWT_SECRET); + const skipEmailVerification = + isDevelopmentMode() && !isEmailServiceConfigured(); + + if (skipEmailVerification) { + try { + await createDevUser(userId, username, email, passwordHash); + return { + success: true, + skipVerification: true, + message: "Development mode: Email verification skipped" + }; + } catch (error) { + console.error("Development signup error:", error); + return { + error: + error instanceof Error + ? error.message + : "Failed to create account in development mode", + success: false + }; + } } - const token = jwt.sign(tokenPayload, process.env.JWT_SECRET, { - expiresIn: "1h" - }); - await prisma.$transaction(async (tx) => { - const newUser = await tx.user.create({ - data: { - id: userId, - username, - displayName: username, - email, - passwordHash, - emailVerified: false - } - }); + if (!process.env.JWT_SECRET) { + return { + error: SYSTEM_ERRORS.JWT_SECRET, + success: false + }; + } - await tx.emailVerificationToken.create({ - data: { - token, - userId, - expiresAt: new Date(Date.now() + 3600000) - } + try { + const tokenPayload = { userId, email, timestamp: Date.now() }; + const verificationToken = jwt.sign(tokenPayload, process.env.JWT_SECRET, { + expiresIn: "1h" }); - await createStreamUser(newUser.id, newUser.username, newUser.displayName); - }); + await createProdUser( + userId, + username, + email, + passwordHash, + verificationToken + ); + + const emailResult = await sendVerificationEmail(email, verificationToken); + if (!emailResult.success) { + await prisma.user.delete({ where: { id: userId } }); + return { + error: emailResult.error || "Failed to send verification email", + success: false + }; + } - await sendVerificationEmail(email, token); - - return { success: true }; + return { + success: true, + skipVerification: false + }; + } catch (error) { + console.error("Production signup error:", error); + try { + await prisma.user.delete({ where: { id: userId } }); + } catch (cleanupError) { + console.error("Cleanup failed:", cleanupError); + } + return { + error: "Failed to create account", + success: false + }; + } } catch (error) { console.error("Signup error:", error); return { diff --git a/apps/web/src/app/api/support/route.ts b/apps/web/src/app/api/support/route.ts index d9014583..b351c31a 100644 --- a/apps/web/src/app/api/support/route.ts +++ b/apps/web/src/app/api/support/route.ts @@ -1,19 +1,32 @@ import { rateLimitMiddleware } from "@/lib/rate-limiter"; import { NextResponse } from "next/server"; -import nodemailer from "nodemailer"; +import { Unsend } from "unsend"; -const transporter = nodemailer.createTransport({ - host: "smtp.gmail.com", - port: 465, - secure: true, - auth: { - user: process.env.GMAIL_USER, - pass: process.env.GMAIL_APP_PASSWORD +let unsend: Unsend; + +const initializeUnsend = () => { + if (!unsend && process.env.UNSEND_API_KEY) { + unsend = new Unsend( + process.env.UNSEND_API_KEY, + "https://mails.zephyyrr.in" + ); + } else if (!process.env.UNSEND_API_KEY) { + console.error("Missing UNSEND_API_KEY environment variable"); } -}); +}; + +const SENDER = "zephyyrr.in"; export async function POST(request: Request) { try { + initializeUnsend(); + if (!unsend) { + return NextResponse.json( + { error: "Email service not initialized" }, + { status: 500 } + ); + } + const forwardedFor = request.headers.get("x-forwarded-for"); const identifier = (forwardedFor?.split(",")[0] || "unknown").trim(); @@ -53,9 +66,16 @@ export async function POST(request: Request) { ` : ""; - await transporter.sendMail({ - from: `"Zephyr Support" <${process.env.GMAIL_USER}>`, - to: process.env.GMAIL_USER, + if (!process.env.SUPPORT_EMAIL) { + return NextResponse.json( + { error: "Support email not configured" }, + { status: 500 } + ); + } + + await unsend.emails.send({ + from: `Zephyr Support `, + to: process.env.SUPPORT_EMAIL, subject: `[Zephyr Support - ${type}] ${subject}`, html: `

New Support Request

diff --git a/apps/web/src/components/Auth/PasswordInput.tsx b/apps/web/src/components/Auth/PasswordInput.tsx index b9218af7..4d5cb46a 100644 --- a/apps/web/src/components/Auth/PasswordInput.tsx +++ b/apps/web/src/components/Auth/PasswordInput.tsx @@ -1,8 +1,7 @@ -import { Eye, EyeOff } from "lucide-react"; -import React, { useState } from "react"; - import { Input, type InputProps } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { Eye, EyeOff } from "lucide-react"; +import React, { useState } from "react"; const PasswordInput = React.forwardRef( ({ className, type, ...props }, ref) => { diff --git a/apps/web/src/components/Auth/PasswordRecommender.tsx b/apps/web/src/components/Auth/PasswordRecommender.tsx new file mode 100644 index 00000000..857a2ebf --- /dev/null +++ b/apps/web/src/components/Auth/PasswordRecommender.tsx @@ -0,0 +1,276 @@ +"use client"; + +import type { SignUpValues } from "@zephyr/auth/validation"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowRight, Lightbulb, Wand2 } from "lucide-react"; +import { useState } from "react"; +import type { UseFormSetValue } from "react-hook-form"; + +interface Requirement { + text: string; + validator: (password: string) => boolean; +} + +interface PasswordRecommenderProps { + password: string; + requirements: Requirement[]; + setValue: UseFormSetValue; + setPassword: (value: string) => void; +} + +export function PasswordRecommender({ + password, + requirements, + setValue, + setPassword +}: PasswordRecommenderProps) { + const [isGenerating, setIsGenerating] = useState(false); + + const generateRecommendation = (password: string): string => { + if (!password) return ""; + + let recommendation = password; + let failedRequirements = requirements.filter( + (req) => !req.validator(password) + ); + + // Process each failed requirement specifically - @parazeeknova + // biome-ignore lint/complexity/noForEach: + failedRequirements.forEach((req) => { + switch (req.text) { + case "At least 8 characters long": { + while (recommendation.length < 8) { + const missingRequirements = requirements.filter( + (r) => !r.validator(recommendation) + ); + // Add characters that help meet other requirements too - @parazeeknova + if (missingRequirements.some((r) => r.text.includes("uppercase"))) { + recommendation += "K"; + } else if ( + missingRequirements.some((r) => r.text.includes("number")) + ) { + recommendation += Math.floor(Math.random() * 9) + 1; + } else if ( + missingRequirements.some((r) => r.text.includes("special")) + ) { + recommendation += "@$!%*?&#"[Math.floor(Math.random() * 8)]; + } else { + recommendation += "x"; + } + } + break; + } + + case "Contains at least one uppercase letter": + if (!/[A-Z]/.test(recommendation)) { + const letterMatch = recommendation.match(/[a-z]/); + if (letterMatch) { + const pos = recommendation.indexOf(letterMatch[0]); + recommendation = + recommendation.slice(0, pos) + + recommendation.charAt(pos).toUpperCase() + + recommendation.slice(pos + 1); + } else { + recommendation += "K"; + } + } + break; + + case "Contains at least one lowercase letter": + if (!/[a-z]/.test(recommendation)) { + recommendation += "x"; + } + break; + + case "Contains at least one number": { + if (!/[0-9]/.test(recommendation)) { + const numberMappings: Record = { + o: "0", + i: "1", + z: "2", + e: "3", + a: "4", + s: "5", + g: "6", + t: "7", + b: "8", + q: "9" + }; + + let numberAdded = false; + for (const [letter, num] of Object.entries(numberMappings)) { + if (recommendation.toLowerCase().includes(letter)) { + recommendation = recommendation.replace( + new RegExp(letter, "i"), + num + ); + numberAdded = true; + break; + } + } + + if (!numberAdded) { + recommendation += Math.floor(Math.random() * 9) + 1; + } + } + break; + } + + case "Contains at least one special character": { + if (!/[@$!%*?&#]/.test(recommendation)) { + const specialMappings: Record = { + a: "@", + i: "!", + s: "$", + h: "#", + o: "*", + x: "%", + n: "&" + }; + + let specialAdded = false; + for (const [letter, special] of Object.entries(specialMappings)) { + if (recommendation.toLowerCase().includes(letter)) { + recommendation = recommendation.replace( + new RegExp(letter, "i"), + special + ); + specialAdded = true; + break; + } + } + + if (!specialAdded) { + recommendation += "@"; + } + } + break; + } + + case "No repeated characters (3+ times)": + recommendation = recommendation.replace(/(.)\1{2,}/g, (match) => { + const char = match[0]; + // @ts-expect-error - TS doesn't recognize the key exists + const alternatives: Record = { + a: ["@", "4"], + e: ["3"], + i: ["1", "!"], + o: ["0", "*"], + s: ["$", "5"], + t: ["7", "+"] + }[char?.toLowerCase() as keyof typeof alternatives] || [char]; + + return ( + char + + (Array.isArray(alternatives) && alternatives.length > 0 + ? alternatives[Math.floor(Math.random() * alternatives.length)] + : char) + ); + }); + break; + + case "No common sequences (123, abc)": + recommendation = recommendation + .replace(/123/g, "1#3") + .replace(/abc/gi, "@bc") + .replace(/xyz/gi, "x*z") + .replace(/qwe/gi, "q$e") + .replace(/password/gi, "p@$$w0rd") + .replace(/admin/gi, "@dm!n") + .replace(/user/gi, "u$er") + .replace(/login/gi, "l0g!n"); + break; + } + + // Recheck failed requirements after each modification + failedRequirements = requirements.filter( + (req) => !req.validator(recommendation) + ); + }); + + if (failedRequirements.length > 0) { + // If still not meeting all requirements, create a guaranteed valid password + const base = recommendation.slice(0, 4); + recommendation = `${base}K7@x${Math.floor(Math.random() * 100)}`; + } + + return recommendation; + }; + + const recommendedPassword = generateRecommendation(password); + const shouldShowRecommendation = password && recommendedPassword !== password; + + const handleUseRecommendation = () => { + setIsGenerating(true); + if (setValue) { + setValue("password", recommendedPassword, { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true + }); + setPassword(recommendedPassword); + } + setTimeout(() => setIsGenerating(false), 500); + }; + + return ( + + {shouldShowRecommendation && ( + +
+ +
+

+ Suggested stronger password: +

+
+ + {recommendedPassword} + + + {isGenerating ? ( + + + + ) : ( + <> + Use this + + + )} + +
+

+ This suggestion maintains similarity to your input while meeting + security requirements. +

+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/Auth/PasswordStrengthChecker.tsx b/apps/web/src/components/Auth/PasswordStrengthChecker.tsx index b93e37b9..c92ff9e5 100644 --- a/apps/web/src/components/Auth/PasswordStrengthChecker.tsx +++ b/apps/web/src/components/Auth/PasswordStrengthChecker.tsx @@ -1,47 +1,62 @@ "use client"; +import type { SignUpValues } from "@zephyr/auth/src"; import { AnimatePresence, motion } from "framer-motion"; import { Check, X } from "lucide-react"; +import type { UseFormSetValue } from "react-hook-form"; +import { PasswordRecommender } from "./PasswordRecommender"; interface Requirement { text: string; - regex: RegExp; + validator: (password: string) => boolean; } const requirements: Requirement[] = [ { text: "At least 8 characters long", - regex: /.{8,}/ + validator: (password) => password.length >= 8 }, { text: "Contains at least one uppercase letter", - regex: /[A-Z]/ + validator: (password) => /[A-Z]/.test(password) }, { text: "Contains at least one lowercase letter", - regex: /[a-z]/ + validator: (password) => /[a-z]/.test(password) }, { text: "Contains at least one number", - regex: /\d/ + validator: (password) => /\d/.test(password) }, { text: "Contains at least one special character", - regex: /[@$!%*?&]/ + validator: (password) => /[@$!%*?&#]/.test(password) + }, + { + text: "No repeated characters (3+ times)", + validator: (password) => !/(.)\1{2,}/.test(password) + }, + { + text: "No common sequences (123, abc)", + validator: (password) => !/(?:abc|123|qwe|xyz)/i.test(password) } ]; interface PasswordStrengthCheckerProps { password: string; + setValue: UseFormSetValue; + setPassword: (value: string) => void; } export function PasswordStrengthChecker({ - password + password, + setValue, + setPassword }: PasswordStrengthCheckerProps) { const getStrengthPercent = () => { if (!password) return 0; const matchedRequirements = requirements.filter((req) => - req.regex.test(password) + req.validator(password) ).length; return (matchedRequirements / requirements.length) * 100; }; @@ -62,21 +77,37 @@ export function PasswordStrengthChecker({ return "Strong"; }; + const strengthVariants = { + container: { + initial: { opacity: 0, height: 0 }, + animate: { opacity: 1, height: "auto" }, + exit: { opacity: 0, height: 0, transition: { duration: 0.2 } } + }, + indicator: { + initial: { width: 0, opacity: 0 }, + animate: { width: `${strengthPercent}%`, opacity: 1 }, + exit: { width: 0, opacity: 0 } + }, + requirement: { + initial: { opacity: 0, x: -10 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: 10 } + } + }; + return ( - + {password.length > 0 && (
Password Strength: + +

Password Requirements:

+
{requirements.map((req, index) => (
- {req.regex.test(password) ? ( + {req.validator(password) ? ( @@ -128,7 +164,6 @@ export function PasswordStrengthChecker({ initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} - // @ts-expect-error className="text-muted-foreground" > @@ -137,7 +172,7 @@ export function PasswordStrengthChecker({ ).message === "string" + ); +} + +function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { + if (isErrorWithMessage(maybeError)) return maybeError; + try { + return new Error(JSON.stringify(maybeError)); + } catch { + return new Error(String(maybeError)); + } +} + export default function SignUpForm() { const { toast } = useToast(); const { setIsVerifying } = useVerification(); + const [error, setError] = useState(); + const [password, setPassword] = useState(""); + const [isPending, startTransition] = useTransition(); + const [isLoading, setIsLoading] = useState(false); + const [isResending, setIsResending] = useState(false); + const [isVerificationEmailSent, setIsVerificationEmailSent] = useState(false); const verificationChannel = new BroadcastChannel("email-verification"); + const [isAgeVerified, setIsAgeVerified] = useState(false); + const [acceptedTerms, setAcceptedTerms] = useState(false); + const [count, { startCountdown, stopCountdown, resetCountdown }] = useCountdown({ countStart: 60, intervalMs: 1000 }); + const form = useForm({ + resolver: zodResolver(signUpSchema), + defaultValues: { + email: "", + username: "", + password: "" + }, + mode: "onBlur" + }); + useEffect(() => { if (count === 0) { stopCountdown(); resetCountdown(); } - }, [count]); + }, [count, stopCountdown, resetCountdown]); useEffect(() => { const handleVerificationSuccess = () => { @@ -57,81 +114,135 @@ export default function SignUpForm() { return () => { verificationChannel.close(); }; - }, []); + }, [setIsVerifying]); - const texts = [ - "Elevate your ideas, accelerate your impact.", - "Transform thoughts into action.", - "Your journey to greatness starts here.", - "Start Your Adventure", - "Dive In!" - ]; + const handleInvalidSubmit: SubmitErrorHandler = useCallback( + (errors) => { + const firstError = Object.values(errors)[0]; + const errorMessage = + (firstError?.message as string) || "Please check your input"; - const [error, setError] = useState(); - const [password, setPassword] = useState(""); - const [isPending, startTransition] = useTransition(); - const [_isVerificationEmailSent, setIsVerificationEmailSent] = - useState(false); + toast({ + variant: "destructive", + title: "Invalid Input", + description: errorMessage, + duration: 3000 + }); - const form = useForm({ - resolver: zodResolver(signUpSchema), - defaultValues: { - email: "", - username: "", - password: "" - } - }); + const firstErrorField = Object.keys(errors)[0]; + if (firstErrorField) { + scrollToError(firstErrorField); + } + }, + [toast] + ); - const handleInvalidSubmit = () => { - const errors = form.formState.errors; - if (Object.keys(errors).length > 0) { - const firstError = Object.values(errors)[0]; + const onSubmit = async (values: SignUpValues) => { + setError(undefined); + if (!isAgeVerified || !acceptedTerms) { toast({ variant: "destructive", - title: "Validation Error", - description: firstError?.message ?? "Unknown error" + title: "Required Agreements", + description: "Please accept the age verification and terms of service.", + duration: 3000 }); + return; } - }; - - async function onSubmit(values: SignUpValues) { - setError(undefined); startTransition(async () => { - const { error, success } = await signUp(values); - if (error) { - setError(error); + try { + setIsLoading(true); + const result = await signUp(values); + + if (result.success) { + if (result.skipVerification) { + toast({ + title: "Account Created", + description: isDevelopmentMode() + ? "Development mode: Email verification skipped" + : "Account created successfully", + duration: 3000 + }); + + form.reset(); + + if (isDevelopmentMode()) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + window.location.href = "/"; + } else { + setIsVerifying(true); + setIsVerificationEmailSent(true); + startCountdown(); + toast({ + title: "Verification Required", + description: "Please check your email to verify your account." + }); + + if (isDevelopmentMode() && result.verificationUrl) { + console.info( + "Development Mode - Verification URL:", + result.verificationUrl + ); + } + } + } else if (result.error) { + setError(result.error); + toast({ + variant: "destructive", + title: "Signup Failed", + description: result.error + }); + } + } catch (error) { + const errorMessage = toErrorWithMessage(error).message; + console.error("Signup error:", error); + setError(errorMessage); toast({ variant: "destructive", - title: "Signup Failed", - description: error - }); - } else if (success) { - setIsVerifying(true); - setIsVerificationEmailSent(true); - startCountdown(); - toast({ - title: "Verification Email Sent", - description: "Please check your email to verify your account." + title: "Error", + description: isDevelopmentMode() + ? errorMessage + : "An unexpected error occurred" }); + } finally { + setIsLoading(false); } }); - } + }; const onResendVerificationEmail = async () => { - const email = form.getValues("email"); - const res = await resendVerificationEmail(email); + if (count > 0 && count < 60) return; + + try { + setIsResending(true); + const email = form.getValues("email"); + const result = await resendVerificationEmail(email); - if (res.error) { + if (result.success) { + startCountdown(); + toast({ + title: "Email Sent", + description: "A new verification email has been sent.", + duration: 3000 + }); + } else { + toast({ + variant: "destructive", + title: "Failed to Resend", + description: result.error || "Failed to resend verification email", + duration: 5000 + }); + } + } catch (error) { + console.error("Error resending verification email:", error); toast({ variant: "destructive", - description: res.error - }); - } else if (res.success) { - startCountdown(); - toast({ - title: "Email Resent", - description: "Verification email has been resent to your inbox." + title: "Error", + description: "Failed to resend verification email. Please try again." }); + } finally { + setIsResending(false); } }; @@ -145,10 +256,9 @@ export default function SignUpForm() {
- {/* Signup Form */}
Username
- +
@@ -190,7 +300,11 @@ export default function SignUpForm() { Email
- +
@@ -206,7 +320,7 @@ export default function SignUpForm() { Password { field.onChange(e); @@ -214,39 +328,101 @@ export default function SignUpForm() { }} /> - + )} /> + Create account + +
+
+ + setIsAgeVerified(checked as boolean) + } + className="border-primary/20 data-[state=checked]:border-primary/80 data-[state=checked]:bg-primary/80" + /> + +
+ +
+ + setAcceptedTerms(checked as boolean) + } + className="mt-1 border-primary/20 data-[state=checked]:border-primary/80 data-[state=checked]:bg-primary/80" + /> + +
+
+ +
+
+ +
+
+ + or continue with + +
+
- {/* Verification Message */}
- {/* Background Effects */}
- {/* Icon Container */}
@@ -254,7 +430,6 @@ export default function SignUpForm() {
- {/* Title with gradient */}

Check Your Email @@ -262,7 +437,6 @@ export default function SignUpForm() {
- {/* Email Info */}

We've sent a verification link to @@ -275,7 +449,6 @@ export default function SignUpForm() {

- {/* Resend Section */}
@@ -290,11 +463,15 @@ export default function SignUpForm() {