From f3eb76fd5e63ecb0f2a49e1ed3caa3a1954c38ce Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 12 Oct 2024 23:00:36 -0400 Subject: [PATCH] added signup and verify email forms --- package.json | 2 + server/auth/passwordSchema.ts | 8 +- server/routers/auth/verifyEmail.ts | 9 +- server/routers/external.ts | 2 +- src/app/auth/signup/page.tsx | 17 +++ src/app/auth/verify-email/page.tsx | 22 +++ src/app/layout.tsx | 2 + src/components/LoginForm.tsx | 17 ++- src/components/SignupForm.tsx | 167 ++++++++++++++++++++++ src/components/VerifyEmailForm.tsx | 218 +++++++++++++++++++++++++++++ src/components/ui/input-otp.tsx | 71 ++++++++++ src/components/ui/toast.tsx | 129 +++++++++++++++++ src/components/ui/toaster.tsx | 35 +++++ src/hooks/use-toast.ts | 194 +++++++++++++++++++++++++ 14 files changed, 882 insertions(+), 11 deletions(-) create mode 100644 src/app/auth/signup/page.tsx create mode 100644 src/app/auth/verify-email/page.tsx create mode 100644 src/components/SignupForm.tsx create mode 100644 src/components/VerifyEmailForm.tsx create mode 100644 src/components/ui/input-otp.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/hooks/use-toast.ts diff --git a/package.json b/package.json index 68a85f36..6af26881 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-label": "2.1.0", "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-toast": "1.2.2", "@react-email/components": "0.0.25", "@react-email/tailwind": "0.1.0", "axios": "1.7.7", @@ -37,6 +38,7 @@ "glob": "11.0.0", "helmet": "7.1.0", "http-errors": "2.0.0", + "input-otp": "1.2.4", "js-yaml": "4.1.0", "lucia": "3.2.0", "lucide-react": "0.447.0", diff --git a/server/auth/passwordSchema.ts b/server/auth/passwordSchema.ts index d1d4cc5b..f4030dee 100644 --- a/server/auth/passwordSchema.ts +++ b/server/auth/passwordSchema.ts @@ -6,8 +6,8 @@ export const passwordSchema = z .max(64, { message: "Password must be at most 64 characters long" }) .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, { message: `Your password must meet the following conditions: -- At least one uppercase English letter. -- At least one lowercase English letter. -- At least one digit. -- At least one special character.`, +at least one uppercase English letter, +at least one lowercase English letter, +at least one digit, +at least one special character.` }); diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index fd7da582..55ab146f 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -59,12 +59,19 @@ export async function verifyEmail( emailVerified: true, }) .where(eq(users.id, user.id)); + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid verification code", + ), + ); } return response(res, { success: true, error: false, - message: valid ? "Code is valid" : "Code is invalid", + message: "Email verified", status: HttpCode.OK, data: { valid, diff --git a/server/routers/external.ts b/server/routers/external.ts index 38744805..4989fa3f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -93,7 +93,7 @@ authenticated.delete( authenticated.get("/users", user.listUsers); // authenticated.get("/org/:orgId/users", user.???); // TODO: Implement this -authenticated.get("/user", user.getUser); +unauthenticated.get("/user", verifySessionMiddleware, user.getUser); // authenticated.get("/user/:userId", user.getUser); authenticated.delete("/user/:userId", user.deleteUser); diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx new file mode 100644 index 00000000..3f27a6ee --- /dev/null +++ b/src/app/auth/signup/page.tsx @@ -0,0 +1,17 @@ +import SignupForm from "@app/components/SignupForm"; +import { verifySession } from "@app/lib/verifySession"; +import { redirect } from "next/navigation"; + +export default async function Page() { + const user = await verifySession(); + + if (user) { + redirect("/"); + } + + return ( + <> + + + ); +} diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx new file mode 100644 index 00000000..3745a78b --- /dev/null +++ b/src/app/auth/verify-email/page.tsx @@ -0,0 +1,22 @@ +import VerifyEmailForm from "@app/components/VerifyEmailForm"; +import { verifySession } from "@app/lib/verifySession"; +import { redirect } from "next/navigation"; + +export default async function Page() { + const user = await verifySession(); + console.log(user) + + if (!user) { + redirect("/"); + } + + if (user.emailVerified) { + redirect("/"); + } + + return ( + <> + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5bb52ea2..74ca7b02 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { Roboto } from "next/font/google"; +import { Toaster } from "@/components/ui/toaster" export const metadata: Metadata = { title: process.env.NEXT_PUBLIC_APP_NAME, @@ -18,6 +19,7 @@ export default async function RootLayout({
{children}
+ ); diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 112c9fc5..55f2fde3 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -22,10 +22,10 @@ import { CardTitle, } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { LoginResponse } from "@server/routers/auth"; import { api } from "@app/api"; import { useRouter } from "next/navigation"; +import { AxiosResponse } from "axios"; type LoginFormProps = { redirect?: string; @@ -54,7 +54,7 @@ export default function LoginForm({ redirect }: LoginFormProps) { async function onSubmit(values: z.infer) { const { email, password } = values; const res = await api - .post("/auth/login", { + .post>("/auth/login", { email, password, }) @@ -68,6 +68,14 @@ export default function LoginForm({ redirect }: LoginFormProps) { if (res && res.status === 200) { setError(null); + + console.log(res) + + if (res.data?.data?.emailVerificationRequired) { + router.push("/auth/verify-email"); + return; + } + if (redirect && typeof redirect === "string") { window.location.href = redirect; } else { @@ -79,7 +87,7 @@ export default function LoginForm({ redirect }: LoginFormProps) { return ( - Secure Login + Login Enter your credentials to access your dashboard @@ -124,8 +132,7 @@ export default function LoginForm({ redirect }: LoginFormProps) { )} /> {error && ( - - + {error} )} diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx new file mode 100644 index 00000000..af477393 --- /dev/null +++ b/src/components/SignupForm.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { SignUpResponse } from "@server/routers/auth"; +import { api } from "@app/api"; +import { useRouter } from "next/navigation"; +import { passwordSchema } from "@server/auth/passwordSchema"; +import { AxiosResponse } from "axios"; + +type SignupFormProps = { + redirect?: string; +}; + +const formSchema = z + .object({ + email: z.string().email({ message: "Invalid email address" }), + password: passwordSchema, + confirmPassword: passwordSchema, + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }); + +export default function SignupForm({ redirect }: SignupFormProps) { + const router = useRouter(); + + const [error, setError] = useState(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + confirmPassword: "", + }, + }); + + async function onSubmit(values: z.infer) { + const { email, password } = values; + const res = await api + .put>("/auth/signup", { + email, + password, + }) + .catch((e) => { + console.error(e); + setError( + e.response?.data?.message || + "An error occurred while signing up", + ); + }); + + if (res && res.status === 200) { + setError(null); + + if (res.data.data.emailVerificationRequired) { + router.push("/auth/verify-email"); + return; + } + + if (redirect && typeof redirect === "string") { + window.location.href = redirect; + } + + router.push("/"); + } + } + + return ( + + + Create Account + + Enter your details to create an account + + + +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + {error && ( + + {error} + + )} + + + + +
+
+ ); +} diff --git a/src/components/VerifyEmailForm.tsx b/src/components/VerifyEmailForm.tsx new file mode 100644 index 00000000..d399b49a --- /dev/null +++ b/src/components/VerifyEmailForm.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import api from "@app/api"; +import { AxiosResponse } from "axios"; +import { VerifyEmailResponse } from "@server/routers/auth"; +import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "./ui/alert"; +import { useToast } from "@app/hooks/use-toast"; +import { useRouter } from "next/navigation"; + +const FormSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + pin: z.string().min(8, { + message: "Your verification code must be 8 characters.", + }), +}); + +export type VerifyEmailFormProps = { + email: string; +}; + +export default function VerifyEmailForm({ email }: VerifyEmailFormProps) { + const router = useRouter(); + + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [isResending, setIsResending] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { toast } = useToast(); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + email: email, + pin: "", + }, + }); + + async function onSubmit(data: z.infer) { + setIsSubmitting(true); + + const res = await api + .post>("/auth/verify-email", { + code: data.pin, + }) + .catch((e) => { + setError(e.response?.data?.message || "An error occurred"); + console.error("Failed to verify email:", e); + }); + + if (res && res.data?.data?.valid) { + setError(null); + setSuccessMessage( + "Email successfully verified! Redirecting you...", + ); + setTimeout(() => { + router.push("/"); + setIsSubmitting(false); + }, 3000); + } + } + + async function handleResendCode() { + setIsResending(true); + + const res = await api.post("/auth/verify-email/request").catch((e) => { + setError(e.response?.data?.message || "An error occurred"); + console.error("Failed to resend verification code:", e); + }); + + if (res) { + setError(null); + toast({ + variant: "default", + title: "Verification code resent", + description: + "We've resent a verification code to your email address. Please check your inbox.", + }); + } + + setIsResending(false); + } + + return ( + + + Verify Your Email + + Enter the verification code sent to your email address. + + + +
+ + ( + + Email + + + + + + )} + /> + + ( + + Verification Code + +
+ + + + + + + + + + + + +
+
+ + We sent a verification code to your + email address. Please enter the code to + verify your email address. + + +
+ )} + /> + + {error && ( + + {error} + + )} + + {successMessage && ( + + + {successMessage} + + + )} + + + +
+ +
+ + +
+
+ ); +} diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx new file mode 100644 index 00000000..f66fcfa0 --- /dev/null +++ b/src/components/ui/input-otp.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Dot } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 00000000..521b94b0 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 00000000..171beb46 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 00000000..02e111d8 --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast }