more validation and redirects

This commit is contained in:
Milo Schwartz 2024-10-19 16:37:40 -04:00
parent 0ff183796c
commit 57ba84eb02
No known key found for this signature in database
18 changed files with 620 additions and 443 deletions

View file

@ -9,7 +9,6 @@ export enum ActionsEnum {
createOrg = "createOrg",
deleteOrg = "deleteOrg",
getOrg = "getOrg",
listOrgs = "listOrgs",
updateOrg = "updateOrg",
createSite = "createSite",
deleteSite = "deleteSite",

View file

@ -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",
),
);
}
}

View file

@ -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<ListOrgsResponse>(res, {
data: {
organizations: [],
orgs: [],
pagination: {
total: 0,
limit,
@ -94,7 +82,7 @@ export async function listOrgs(
return response<ListOrgsResponse>(res, {
data: {
organizations,
orgs: organizations,
pagination: {
total: totalCount,
limit,

View file

@ -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 (
<>
<div className="flex items-center justify-between">
@ -72,8 +94,9 @@ export default function Header({ email, orgName, name }: HeaderProps) {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Log out</DropdownMenuItem>
<DropdownMenuItem onClick={logout}>
Log out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
@ -106,9 +129,14 @@ export default function Header({ email, orgName, name }: HeaderProps) {
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={orgName}>
{orgName}
</SelectItem>
{orgs.map((org) => (
<SelectItem
value={org.name}
key={org.orgId}
>
{org.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>

View file

@ -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",
)}

View file

@ -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<AxiosResponse<ListOrgsResponse>>(
`/orgs`,
authCookieHeader(),
);
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {
console.error("Error fetching orgs", e);
}
return (
<>
<div className="w-full bg-muted mb-6 select-none sm:px-0 px-3 pt-3">
<div className="container mx-auto flex flex-col content-between gap-4 ">
<Header email={user.email} orgName={params.orgId} />
<Header
email={user.email}
orgName={params.orgId}
orgs={orgs}
/>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>

13
src/app/auth/layout.tsx Normal file
View file

@ -0,0 +1,13 @@
type AuthLayoutProps = {
children: React.ReactNode;
};
export default async function AuthLayout({ children }: AuthLayoutProps) {
return (
<>
<div className="mt-32">
{children}
</div>
</>
);
}

View file

@ -136,7 +136,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
)}
/>
{error && (
<Alert>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}

View file

@ -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 (
<>
<LoginForm redirect={searchParams.redirect as string} />
<p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "}
<Link href="/auth/signup" className="underline">
Sign up
</Link>
</p>
</>
);
}

View file

@ -157,7 +157,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
/>
{error && (
<Alert>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}

View file

@ -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 (
<>
<SignupForm redirect={searchParams.redirect as string} />
<p className="text-center text-muted-foreground mt-4">
Already have an account?{" "}
<Link href="/auth/login" className="underline">
Log in
</Link>
</p>
</>
);
}

View file

@ -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<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isResending, setIsResending] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: email,
pin: "",
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
setIsSubmitting(true);
const res = await api
.post<AxiosResponse<VerifyEmailResponse>>("/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 (
<div>
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Verify Your Email</CardTitle>
<CardDescription>
Enter the verification code sent to your email address.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>Verification Code</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={8}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
<InputOTPSlot
index={6}
/>
<InputOTPSlot
index={7}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormDescription>
We sent a verification code to your
email address. Please enter the code
to verify your email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert variant="success">
<AlertDescription>
{successMessage}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit
</Button>
</form>
</Form>
</CardContent>
</Card>
<div className="text-center text-muted-foreground mt-4">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={isResending}
>
{isResending
? "Resending..."
: "Didn't receive a code? Click here to resend"}
</Button>
</div>
</div>
);
}

View file

@ -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";

View file

@ -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<AxiosResponse<ListOrgsResponse>>(
`/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 (
<html suppressHydrationWarning>
<body className={`${font.className} pb-3`}>

7
src/app/setup/layout.tsx Normal file
View file

@ -0,0 +1,7 @@
export default async function SetupLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="mt-32">{children}</div>;
}

View file

@ -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<Step>('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<Step>("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 (
<div className="w-full max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-6">Setup Your Environment</h2>
<div className="mb-8">
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === 'org' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
1
<>
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle>Setup Your Environment</CardTitle>
<CardDescription>
Create your organization, site, and resources.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-8">
<div className="flex justify-between mb-2">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "org" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
1
</div>
<span
className={`text-sm font-medium ${currentStep === "org" ? "text-primary" : "text-muted-foreground"}`}
>
Create Org
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "site" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
2
</div>
<span
className={`text-sm font-medium ${currentStep === "site" ? "text-primary" : "text-muted-foreground"}`}
>
Create Site
</span>
</div>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "resources" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
3
</div>
<span
className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`}
>
Create Resources
</span>
</div>
</div>
<span className={`text-sm font-medium ${currentStep === 'org' ? 'text-primary' : 'text-muted-foreground'}`}>Create Org</span>
</div>
<div className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === 'site' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
2
<div className="flex items-center">
<div className="flex-1 h-px bg-border"></div>
<div className="flex-1 h-px bg-border"></div>
</div>
<span className={`text-sm font-medium ${currentStep === 'site' ? 'text-primary' : 'text-muted-foreground'}`}>Create Site</span>
</div>
<div className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === 'resources' ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
3
{currentStep === "org" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="orgName">
Organization Name
</Label>
<Input
id="orgName"
value={orgName}
onChange={(e) => {
setOrgName(e.target.value);
setOrgId(generateId(e.target.value));
}}
placeholder="Enter organization name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="orgId">Organization ID</Label>
<Input
id="orgId"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
/>
{showOrgIdError()}
<p className="text-sm text-muted-foreground">
This ID is automatically generated from the
organization name and must be unique.
</p>
</div>
</div>
<span className={`text-sm font-medium ${currentStep === 'resources' ? 'text-primary' : 'text-muted-foreground'}`}>Create Resources</span>
</div>
</div>
<div className="flex items-center">
<div className="flex-1 h-px bg-border"></div>
<div className="flex-1 h-px bg-border"></div>
</div>
</div>
{currentStep === 'org' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="orgName">Organization Name</Label>
<Input
id="orgName"
value={orgName}
onChange={(e) => { setOrgName(e.target.value); setOrgId(generateId(e.target.value)) }}
placeholder="Enter organization name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="orgId">Organization ID</Label>
<Input
id="orgId"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
/>
{showOrgIdError()}
<p className="text-sm text-muted-foreground">
This ID is automatically generated from the organization name and must be unique.
</p>
</div>
</div>
)}
{currentStep === 'site' && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="siteName">Site Name</Label>
<Input
id="siteName"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="Enter site name"
required
/>
</div>
</div>
)}
{currentStep === 'resources' && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="resourceName">Resource Name</Label>
<Input
id="resourceName"
value={resourceName}
onChange={(e) => setResourceName(e.target.value)}
placeholder="Enter resource name"
required
/>
</div>
</div>
)}
<div className="flex justify-between pt-4">
<Button
type="button"
variant="outline"
onClick={handlePrevious}
disabled={currentStep === 'org' || (currentStep === 'site' && orgCreated)}
>
Previous
</Button>
<div className="flex items-center space-x-2">
{currentStep !== 'org' ? (
<Link
href={`/${orgId}/sites`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)}
{currentStep === "site" && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="siteName">Site Name</Label>
<Input
id="siteName"
value={siteName}
onChange={(e) =>
setSiteName(e.target.value)
}
placeholder="Enter site name"
required
/>
</div>
</div>
)}
{currentStep === "resources" && (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="resourceName">
Resource Name
</Label>
<Input
id="resourceName"
value={resourceName}
onChange={(e) =>
setResourceName(e.target.value)
}
placeholder="Enter resource name"
required
/>
</div>
</div>
)}
<div className="flex justify-between pt-4">
<Button
type="button"
variant="outline"
onClick={handlePrevious}
disabled={
currentStep === "org" ||
(currentStep === "site" && orgCreated)
}
>
Skip for now
</Link>
) : null}
Previous
</Button>
<div className="flex items-center space-x-2">
{currentStep !== "org" ? (
<Link
href={`/${orgId}/sites`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Skip for now
</Link>
) : null}
<Button type="button" id="button" onClick={handleNext}>Create</Button>
</div>
</div>
</div>
)
<Button
type="button"
id="button"
onClick={handleNext}
>
Create
</Button>
</div>
</div>
</CardContent>
</Card>
</>
);
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout)
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args)
}, wait)
}
func(...args);
}, wait);
};
}

View file

@ -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<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isResending, setIsResending] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: email,
pin: "",
},
});
async function onSubmit(data: z.infer<typeof FormSchema>) {
setIsSubmitting(true);
const res = await api
.post<AxiosResponse<VerifyEmailResponse>>("/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 (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Verify Your Email</CardTitle>
<CardDescription>
Enter the verification code sent to your email address.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>Verification Code</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP maxLength={8} {...field}>
<InputOTPGroup className="flex">
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormDescription>
We sent a verification code to your
email address. Please enter the code to
verify your email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert>
<AlertDescription>
{successMessage}
</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit
</Button>
<div className="flex justify-center">
<Button
type="button"
variant="link"
onClick={handleResendCode}
disabled={isResending}
>
{isResending
? "Resending..."
: "Didn't receive a code? Click here to resend"}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
}

View file

@ -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) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
className={cn(
"mb-1 font-medium leading-none tracking-tight",
className,
)}
{...props}
/>
))
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 };