From 57ba84eb023d6d2aab0ba57b384ea23c25b14360 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 19 Oct 2024 16:37:40 -0400 Subject: [PATCH] more validation and redirects --- server/auth/actions.ts | 1 - server/routers/auth/getUserOrgs.ts | 40 +- server/routers/org/listOrgs.ts | 26 +- src/app/[orgId]/components/Header.tsx | 40 +- src/app/[orgId]/components/TopbarNav.tsx | 4 +- src/app/[orgId]/layout.tsx | 21 +- src/app/auth/layout.tsx | 13 + .../auth => app/auth/login}/LoginForm.tsx | 2 +- src/app/auth/login/page.tsx | 10 +- .../auth => app/auth/signup}/SignupForm.tsx | 2 +- src/app/auth/signup/page.tsx | 10 +- src/app/auth/verify-email/VerifyEmailForm.tsx | 250 +++++++++++++ src/app/auth/verify-email/page.tsx | 2 +- src/app/layout.tsx | 24 +- src/app/setup/layout.tsx | 7 + src/app/setup/page.tsx | 351 ++++++++++-------- src/components/auth/VerifyEmailForm.tsx | 229 ------------ src/components/ui/alert.tsx | 31 +- 18 files changed, 620 insertions(+), 443 deletions(-) create mode 100644 src/app/auth/layout.tsx rename src/{components/auth => app/auth/login}/LoginForm.tsx (98%) rename src/{components/auth => app/auth/signup}/SignupForm.tsx (99%) create mode 100644 src/app/auth/verify-email/VerifyEmailForm.tsx create mode 100644 src/app/setup/layout.tsx delete mode 100644 src/components/auth/VerifyEmailForm.tsx diff --git a/server/auth/actions.ts b/server/auth/actions.ts index df84ecd6..0f041653 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -9,7 +9,6 @@ export enum ActionsEnum { createOrg = "createOrg", deleteOrg = "deleteOrg", getOrg = "getOrg", - listOrgs = "listOrgs", updateOrg = "updateOrg", createSite = "createSite", deleteSite = "deleteSite", diff --git a/server/routers/auth/getUserOrgs.ts b/server/routers/auth/getUserOrgs.ts index 2481f1df..875492a4 100644 --- a/server/routers/auth/getUserOrgs.ts +++ b/server/routers/auth/getUserOrgs.ts @@ -1,26 +1,33 @@ -import { Request, Response, NextFunction } from 'express'; -import { db } from '@server/db'; -import { userOrgs, orgs } from '@server/db/schema'; -import { eq } from 'drizzle-orm'; -import createHttpError from 'http-errors'; -import HttpCode from '@server/types/HttpCode'; +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs, orgs } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; -export async function getUserOrgs(req: Request, res: Response, next: NextFunction) { +export async function getUserOrgs( + req: Request, + res: Response, + next: NextFunction, +) { const userId = req.user?.userId; // Assuming you have user information in the request if (!userId) { - return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated')); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated"), + ); } try { - const userOrganizations = await db.select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId, - }) + const userOrganizations = await db + .select({ + orgId: userOrgs.orgId, + roleId: userOrgs.roleId, + }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); - req.userOrgIds = userOrganizations.map(org => org.orgId); + req.userOrgIds = userOrganizations.map((org) => org.orgId); // req.userOrgRoleIds = userOrganizations.reduce((acc, org) => { // acc[org.orgId] = org.role; // return acc; @@ -28,6 +35,11 @@ export async function getUserOrgs(req: Request, res: Response, next: NextFunctio next(); } catch (error) { - next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error retrieving user organizations')); + next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error retrieving user organizations", + ), + ); } } diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index dd538797..66937683 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -13,17 +13,19 @@ const listOrgsSchema = z.object({ limit: z .string() .optional() + .default("1000") .transform(Number) - .pipe(z.number().int().positive().default(10)), + .pipe(z.number().int().positive()), offset: z .string() .optional() + .default("0") .transform(Number) - .pipe(z.number().int().nonnegative().default(0)), + .pipe(z.number().int().nonnegative()), }); export type ListOrgsResponse = { - organizations: Org[]; + orgs: Org[]; pagination: { total: number; limit: number; offset: number }; }; @@ -45,27 +47,13 @@ export async function listOrgs( const { limit, offset } = parsedQuery.data; - // Check if the user has permission to list sites - const hasPermission = await checkUserActionPermission( - ActionsEnum.listOrgs, - req, - ); - if (!hasPermission) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "User does not have permission to perform this action", - ), - ); - } - // Use the userOrgs passed from the middleware const userOrgIds = req.userOrgIds; if (!userOrgIds || userOrgIds.length === 0) { return response(res, { data: { - organizations: [], + orgs: [], pagination: { total: 0, limit, @@ -94,7 +82,7 @@ export async function listOrgs( return response(res, { data: { - organizations, + orgs: organizations, pagination: { total: totalCount, limit, diff --git a/src/app/[orgId]/components/Header.tsx b/src/app/[orgId]/components/Header.tsx index 5a2d521f..8ffe6827 100644 --- a/src/app/[orgId]/components/Header.tsx +++ b/src/app/[orgId]/components/Header.tsx @@ -1,5 +1,6 @@ "use client"; +import api from "@app/api"; import { Avatar, AvatarFallback } from "@app/components/ui/avatar"; import { Button } from "@app/components/ui/button"; import { @@ -19,15 +20,23 @@ import { SelectTrigger, SelectValue, } from "@app/components/ui/select"; +import { useToast } from "@app/hooks/use-toast"; +import { ListOrgsResponse } from "@server/routers/org"; import Link from "next/link"; +import { useRouter } from "next/navigation"; type HeaderProps = { name?: string; email: string; orgName: string; + orgs: ListOrgsResponse["orgs"]; }; -export default function Header({ email, orgName, name }: HeaderProps) { +export default function Header({ email, orgName, name, orgs }: HeaderProps) { + const { toast } = useToast(); + + const router = useRouter(); + function getInitials() { if (name) { const [firstName, lastName] = name.split(" "); @@ -36,6 +45,19 @@ export default function Header({ email, orgName, name }: HeaderProps) { return email.substring(0, 2).toUpperCase(); } + function logout() { + api.post("/auth/logout") + .catch((e) => { + console.error("Error logging out", e); + toast({ + title: "Error logging out", + }); + }) + .then(() => { + router.push("/auth/login"); + }); + } + return ( <>
@@ -72,8 +94,9 @@ export default function Header({ email, orgName, name }: HeaderProps) { - Profile - Log out + + Log out + @@ -106,9 +129,14 @@ export default function Header({ email, orgName, name }: HeaderProps) { - - {orgName} - + {orgs.map((org) => ( + + {org.name} + + ))} diff --git a/src/app/[orgId]/components/TopbarNav.tsx b/src/app/[orgId]/components/TopbarNav.tsx index 9698011c..a1952d6d 100644 --- a/src/app/[orgId]/components/TopbarNav.tsx +++ b/src/app/[orgId]/components/TopbarNav.tsx @@ -40,8 +40,8 @@ export function TopbarNav({ className={cn( "px-2 py-3 text-md", pathname.startsWith(item.href.replace("{orgId}", orgId)) - ? "border-b-2 border-primary text-primary font-medium" - : "hover:text-primary text-muted-foreground font-medium", + ? "border-b-2 border-secondary text-secondary font-medium" + : "hover:secondary-primary text-muted-foreground font-medium", "whitespace-nowrap", disabled && "cursor-not-allowed", )} diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 802e883c..52a437aa 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -7,7 +7,7 @@ import { redirect } from "next/navigation"; import { cache } from "react"; import { internal } from "@app/api"; import { AxiosResponse } from "axios"; -import { GetOrgResponse } from "@server/routers/org"; +import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/api/cookies"; export const metadata: Metadata = { @@ -62,11 +62,28 @@ export default async function ConfigurationLaytout({ redirect(`/`); } + let orgs: ListOrgsResponse["orgs"] = []; + try { + const res = await internal.get>( + `/orgs`, + authCookieHeader(), + ); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) { + console.error("Error fetching orgs", e); + } + return ( <>
-
+
diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx new file mode 100644 index 00000000..83b9062d --- /dev/null +++ b/src/app/auth/layout.tsx @@ -0,0 +1,13 @@ +type AuthLayoutProps = { + children: React.ReactNode; +}; + +export default async function AuthLayout({ children }: AuthLayoutProps) { + return ( + <> +
+ {children} +
+ + ); +} diff --git a/src/components/auth/LoginForm.tsx b/src/app/auth/login/LoginForm.tsx similarity index 98% rename from src/components/auth/LoginForm.tsx rename to src/app/auth/login/LoginForm.tsx index 319165db..55dafe33 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/app/auth/login/LoginForm.tsx @@ -136,7 +136,7 @@ export default function LoginForm({ redirect }: LoginFormProps) { )} /> {error && ( - + {error} )} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 622b0b03..7153a7fc 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -1,5 +1,6 @@ -import LoginForm from "@app/components/auth/LoginForm"; +import LoginForm from "@app/app/auth/login/LoginForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import Link from "next/link"; import { redirect } from "next/navigation"; export default async function Page({ @@ -16,6 +17,13 @@ export default async function Page({ return ( <> + +

+ Don't have an account?{" "} + + Sign up + +

); } diff --git a/src/components/auth/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx similarity index 99% rename from src/components/auth/SignupForm.tsx rename to src/app/auth/signup/SignupForm.tsx index 156ec595..2991183a 100644 --- a/src/components/auth/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -157,7 +157,7 @@ export default function SignupForm({ redirect }: SignupFormProps) { /> {error && ( - + {error} )} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 94a88a06..90c4dc09 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,5 +1,6 @@ -import SignupForm from "@app/components/auth/SignupForm"; +import SignupForm from "@app/app/auth/signup/SignupForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import Link from "next/link"; import { redirect } from "next/navigation"; export default async function Page({ @@ -16,6 +17,13 @@ export default async function Page({ return ( <> + +

+ Already have an account?{" "} + + Log in + +

); } diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx new file mode 100644 index 00000000..becf4dc9 --- /dev/null +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -0,0 +1,250 @@ +"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 "../../../components/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; + redirect?: string; +}; + +export default function VerifyEmailForm({ + email, + redirect, +}: 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(() => { + if (redirect && redirect.includes("http")) { + window.location.href = redirect; + } + if (redirect) { + router.push(redirect); + } else { + router.push("/"); + } + setIsSubmitting(false); + }, 1500); + } + } + + 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/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 556dab11..dd62d5de 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,4 +1,4 @@ -import VerifyEmailForm from "@app/components/auth/VerifyEmailForm"; +import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cb9e309b..92b3ae6b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,13 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Inter, Manrope, Open_Sans, Roboto } from "next/font/google"; +import { Inter } from "next/font/google"; import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; +import { ListOrgsResponse } from "@server/routers/org"; +import { internal } from "@app/api"; +import { AxiosResponse } from "axios"; +import { authCookieHeader } from "@app/api/cookies"; +import { redirect } from "next/navigation"; export const metadata: Metadata = { title: process.env.NEXT_PUBLIC_APP_NAME, @@ -16,6 +21,23 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + let orgs: ListOrgsResponse["orgs"] = []; + try { + const res = await internal.get>( + `/orgs`, + authCookieHeader(), + ); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + + if (!orgs.length) { + redirect(`/setup`); + } + } catch (e) { + console.error("Error fetching orgs", e); + } + return ( diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx new file mode 100644 index 00000000..0a861d7d --- /dev/null +++ b/src/app/setup/layout.tsx @@ -0,0 +1,7 @@ +export default async function SetupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 4c053f97..d0bf9406 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -1,48 +1,55 @@ -'use client' +"use client"; -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import Link from 'next/link' -import api from '@app/api' -import { toast } from '@app/hooks/use-toast' -import { useCallback, useEffect, useState } from 'react'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import api from "@app/api"; +import { toast } from "@app/hooks/use-toast"; +import { useCallback, useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@app/components/ui/card"; -type Step = 'org' | 'site' | 'resources' +type Step = "org" | "site" | "resources"; export default function StepperForm() { - const [currentStep, setCurrentStep] = useState('org') - const [orgName, setOrgName] = useState('') - const [orgId, setOrgId] = useState('') - const [siteName, setSiteName] = useState('') - const [resourceName, setResourceName] = useState('') - const [orgCreated, setOrgCreated] = useState(false) - const [orgIdTaken, setOrgIdTaken] = useState(false) + const [currentStep, setCurrentStep] = useState("org"); + const [orgName, setOrgName] = useState(""); + const [orgId, setOrgId] = useState(""); + const [siteName, setSiteName] = useState(""); + const [resourceName, setResourceName] = useState(""); + const [orgCreated, setOrgCreated] = useState(false); + const [orgIdTaken, setOrgIdTaken] = useState(false); const checkOrgIdAvailability = useCallback(async (value: string) => { try { const res = await api.get(`/org/checkId`, { params: { - orgId: value - } - }) - setOrgIdTaken(res.status !== 404) + orgId: value, + }, + }); + setOrgIdTaken(res.status !== 404); } catch (error) { - console.error('Error checking org ID availability:', error) - setOrgIdTaken(false) + console.error("Error checking org ID availability:", error); + setOrgIdTaken(false); } - }, []) + }, []); const debouncedCheckOrgIdAvailability = useCallback( debounce(checkOrgIdAvailability, 300), - [checkOrgIdAvailability] - ) + [checkOrgIdAvailability], + ); useEffect(() => { if (orgId) { - debouncedCheckOrgIdAvailability(orgId) + debouncedCheckOrgIdAvailability(orgId); } - }, [orgId, debouncedCheckOrgIdAvailability]) + }, [orgId, debouncedCheckOrgIdAvailability]); const showOrgIdError = () => { if (orgIdTaken) { @@ -56,12 +63,11 @@ export default function StepperForm() { }; const generateId = (name: string) => { - return name.toLowerCase().replace(/\s+/g, '-') - } + return name.toLowerCase().replace(/\s+/g, "-"); + }; const handleNext = async () => { - if (currentStep === 'org') { - + if (currentStep === "org") { const res = await api .put(`/org`, { orgId: orgId, @@ -69,147 +75,190 @@ export default function StepperForm() { }) .catch((e) => { toast({ - title: "Error creating org..." + title: "Error creating org...", }); }); if (res && res.status === 201) { - setCurrentStep('site') - setOrgCreated(true) + setCurrentStep("site"); + setOrgCreated(true); } - - } - else if (currentStep === 'site') setCurrentStep('resources') - } + } else if (currentStep === "site") setCurrentStep("resources"); + }; const handlePrevious = () => { - if (currentStep === 'site') setCurrentStep('org') - else if (currentStep === 'resources') setCurrentStep('site') - } - + if (currentStep === "site") setCurrentStep("org"); + else if (currentStep === "resources") setCurrentStep("site"); + }; return ( -
-

Setup Your Environment

-
-
-
-
- 1 + <> + + + Setup Your Environment + + Create your organization, site, and resources. + + + +
+
+
+
+ 1 +
+ + Create Org + +
+
+
+ 2 +
+ + Create Site + +
+
+
+ 3 +
+ + Create Resources + +
- Create Org -
-
-
- 2 +
+
+
- Create Site
-
-
- 3 + {currentStep === "org" && ( +
+
+ + { + setOrgName(e.target.value); + setOrgId(generateId(e.target.value)); + }} + placeholder="Enter organization name" + required + /> +
+
+ + setOrgId(e.target.value)} + /> + {showOrgIdError()} +

+ This ID is automatically generated from the + organization name and must be unique. +

+
- Create Resources -
-
-
-
-
-
-
- {currentStep === 'org' && ( -
-
- - { setOrgName(e.target.value); setOrgId(generateId(e.target.value)) }} - placeholder="Enter organization name" - required - /> -
-
- - setOrgId(e.target.value)} - /> - {showOrgIdError()} -

- This ID is automatically generated from the organization name and must be unique. -

-
-
- )} - {currentStep === 'site' && ( -
-
- - setSiteName(e.target.value)} - placeholder="Enter site name" - required - /> -
-
- )} - {currentStep === 'resources' && ( -
-
- - setResourceName(e.target.value)} - placeholder="Enter resource name" - required - /> -
-
- )} -
- -
- {currentStep !== 'org' ? ( - +
+ + + setSiteName(e.target.value) + } + placeholder="Enter site name" + required + /> +
+
+ )} + {currentStep === "resources" && ( +
+
+ + + setResourceName(e.target.value) + } + placeholder="Enter resource name" + required + /> +
+
+ )} +
+ +
+ {currentStep !== "org" ? ( + + Skip for now + + ) : null} - - -
- -
-
- ) + +
+
+ + + + ); } function debounce any>( func: T, - wait: number + wait: number, ): (...args: Parameters) => void { - let timeout: NodeJS.Timeout | null = null + let timeout: NodeJS.Timeout | null = null; return (...args: Parameters) => { - if (timeout) clearTimeout(timeout) + if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { - func(...args) - }, wait) - } -} \ No newline at end of file + func(...args); + }, wait); + }; +} diff --git a/src/components/auth/VerifyEmailForm.tsx b/src/components/auth/VerifyEmailForm.tsx deleted file mode 100644 index 3404294a..00000000 --- a/src/components/auth/VerifyEmailForm.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"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; - redirect?: string; -}; - -export default function VerifyEmailForm({ - email, - redirect, -}: 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(() => { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } - if (redirect) { - router.push(redirect); - } else { - 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/alert.tsx b/src/components/ui/alert.tsx index 81e4f01a..80737796 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -1,7 +1,7 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const alertVariants = cva( "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", @@ -11,13 +11,15 @@ const alertVariants = cva( default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + success: + "border-green-500/50 text-green-500 dark:border-success [&>svg]:text-green-500", }, }, defaultVariants: { variant: "default", }, - } -) + }, +); const Alert = React.forwardRef< HTMLDivElement, @@ -29,8 +31,8 @@ const Alert = React.forwardRef< className={cn(alertVariants({ variant }), className)} {...props} /> -)) -Alert.displayName = "Alert" +)); +Alert.displayName = "Alert"; const AlertTitle = React.forwardRef< HTMLParagraphElement, @@ -38,11 +40,14 @@ const AlertTitle = React.forwardRef< >(({ className, ...props }, ref) => (
-)) -AlertTitle.displayName = "AlertTitle" +)); +AlertTitle.displayName = "AlertTitle"; const AlertDescription = React.forwardRef< HTMLParagraphElement, @@ -53,7 +58,7 @@ const AlertDescription = React.forwardRef< className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> -)) -AlertDescription.displayName = "AlertDescription" +)); +AlertDescription.displayName = "AlertDescription"; -export { Alert, AlertTitle, AlertDescription } +export { Alert, AlertTitle, AlertDescription };