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", createOrg = "createOrg",
deleteOrg = "deleteOrg", deleteOrg = "deleteOrg",
getOrg = "getOrg", getOrg = "getOrg",
listOrgs = "listOrgs",
updateOrg = "updateOrg", updateOrg = "updateOrg",
createSite = "createSite", createSite = "createSite",
deleteSite = "deleteSite", deleteSite = "deleteSite",

View file

@ -1,26 +1,33 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from "express";
import { db } from '@server/db'; import { db } from "@server/db";
import { userOrgs, orgs } from '@server/db/schema'; import { userOrgs, orgs } from "@server/db/schema";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import createHttpError from 'http-errors'; import createHttpError from "http-errors";
import HttpCode from '@server/types/HttpCode'; 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 const userId = req.user?.userId; // Assuming you have user information in the request
if (!userId) { if (!userId) {
return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated')); return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated"),
);
} }
try { try {
const userOrganizations = await db.select({ const userOrganizations = await db
orgId: userOrgs.orgId, .select({
roleId: userOrgs.roleId, orgId: userOrgs.orgId,
}) roleId: userOrgs.roleId,
})
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)); .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) => { // req.userOrgRoleIds = userOrganizations.reduce((acc, org) => {
// acc[org.orgId] = org.role; // acc[org.orgId] = org.role;
// return acc; // return acc;
@ -28,6 +35,11 @@ export async function getUserOrgs(req: Request, res: Response, next: NextFunctio
next(); next();
} catch (error) { } 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 limit: z
.string() .string()
.optional() .optional()
.default("1000")
.transform(Number) .transform(Number)
.pipe(z.number().int().positive().default(10)), .pipe(z.number().int().positive()),
offset: z offset: z
.string() .string()
.optional() .optional()
.default("0")
.transform(Number) .transform(Number)
.pipe(z.number().int().nonnegative().default(0)), .pipe(z.number().int().nonnegative()),
}); });
export type ListOrgsResponse = { export type ListOrgsResponse = {
organizations: Org[]; orgs: Org[];
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
@ -45,27 +47,13 @@ export async function listOrgs(
const { limit, offset } = parsedQuery.data; 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 // Use the userOrgs passed from the middleware
const userOrgIds = req.userOrgIds; const userOrgIds = req.userOrgIds;
if (!userOrgIds || userOrgIds.length === 0) { if (!userOrgIds || userOrgIds.length === 0) {
return response<ListOrgsResponse>(res, { return response<ListOrgsResponse>(res, {
data: { data: {
organizations: [], orgs: [],
pagination: { pagination: {
total: 0, total: 0,
limit, limit,
@ -94,7 +82,7 @@ export async function listOrgs(
return response<ListOrgsResponse>(res, { return response<ListOrgsResponse>(res, {
data: { data: {
organizations, orgs: organizations,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, limit,

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import api from "@app/api";
import { Avatar, AvatarFallback } from "@app/components/ui/avatar"; import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
@ -19,15 +20,23 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { useToast } from "@app/hooks/use-toast";
import { ListOrgsResponse } from "@server/routers/org";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
type HeaderProps = { type HeaderProps = {
name?: string; name?: string;
email: string; email: string;
orgName: 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() { function getInitials() {
if (name) { if (name) {
const [firstName, lastName] = name.split(" "); const [firstName, lastName] = name.split(" ");
@ -36,6 +45,19 @@ export default function Header({ email, orgName, name }: HeaderProps) {
return email.substring(0, 2).toUpperCase(); 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 ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -72,8 +94,9 @@ export default function Header({ email, orgName, name }: HeaderProps) {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem>Profile</DropdownMenuItem> <DropdownMenuItem onClick={logout}>
<DropdownMenuItem>Log out</DropdownMenuItem> Log out
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -106,9 +129,14 @@ export default function Header({ email, orgName, name }: HeaderProps) {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value={orgName}> {orgs.map((org) => (
{orgName} <SelectItem
</SelectItem> value={org.name}
key={org.orgId}
>
{org.name}
</SelectItem>
))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View file

@ -40,8 +40,8 @@ export function TopbarNav({
className={cn( className={cn(
"px-2 py-3 text-md", "px-2 py-3 text-md",
pathname.startsWith(item.href.replace("{orgId}", orgId)) pathname.startsWith(item.href.replace("{orgId}", orgId))
? "border-b-2 border-primary text-primary font-medium" ? "border-b-2 border-secondary text-secondary font-medium"
: "hover:text-primary text-muted-foreground font-medium", : "hover:secondary-primary text-muted-foreground font-medium",
"whitespace-nowrap", "whitespace-nowrap",
disabled && "cursor-not-allowed", disabled && "cursor-not-allowed",
)} )}

View file

@ -7,7 +7,7 @@ import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import { internal } from "@app/api"; import { internal } from "@app/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -62,11 +62,28 @@ export default async function ConfigurationLaytout({
redirect(`/`); 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 ( return (
<> <>
<div className="w-full bg-muted mb-6 select-none sm:px-0 px-3 pt-3"> <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 "> <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} /> <TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </div>
</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 && ( {error && (
<Alert> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </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 { verifySession } from "@app/lib/auth/verifySession";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page({ export default async function Page({
@ -16,6 +17,13 @@ export default async function Page({
return ( return (
<> <>
<LoginForm redirect={searchParams.redirect as string} /> <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 && ( {error && (
<Alert> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </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 { verifySession } from "@app/lib/auth/verifySession";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page({ export default async function Page({
@ -16,6 +17,13 @@ export default async function Page({
return ( return (
<> <>
<SignupForm redirect={searchParams.redirect as string} /> <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 { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";

View file

@ -1,8 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; 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 { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider"; 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 = { export const metadata: Metadata = {
title: process.env.NEXT_PUBLIC_APP_NAME, title: process.env.NEXT_PUBLIC_APP_NAME,
@ -16,6 +21,23 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; 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 ( return (
<html suppressHydrationWarning> <html suppressHydrationWarning>
<body className={`${font.className} pb-3`}> <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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import Link from 'next/link' import Link from "next/link";
import api from '@app/api' import api from "@app/api";
import { toast } from '@app/hooks/use-toast' import { toast } from "@app/hooks/use-toast";
import { useCallback, useEffect, useState } from 'react'; 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() { export default function StepperForm() {
const [currentStep, setCurrentStep] = useState<Step>('org') const [currentStep, setCurrentStep] = useState<Step>("org");
const [orgName, setOrgName] = useState('') const [orgName, setOrgName] = useState("");
const [orgId, setOrgId] = useState('') const [orgId, setOrgId] = useState("");
const [siteName, setSiteName] = useState('') const [siteName, setSiteName] = useState("");
const [resourceName, setResourceName] = useState('') const [resourceName, setResourceName] = useState("");
const [orgCreated, setOrgCreated] = useState(false) const [orgCreated, setOrgCreated] = useState(false);
const [orgIdTaken, setOrgIdTaken] = useState(false) const [orgIdTaken, setOrgIdTaken] = useState(false);
const checkOrgIdAvailability = useCallback(async (value: string) => { const checkOrgIdAvailability = useCallback(async (value: string) => {
try { try {
const res = await api.get(`/org/checkId`, { const res = await api.get(`/org/checkId`, {
params: { params: {
orgId: value orgId: value,
} },
}) });
setOrgIdTaken(res.status !== 404) setOrgIdTaken(res.status !== 404);
} catch (error) { } catch (error) {
console.error('Error checking org ID availability:', error) console.error("Error checking org ID availability:", error);
setOrgIdTaken(false) setOrgIdTaken(false);
} }
}, []) }, []);
const debouncedCheckOrgIdAvailability = useCallback( const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300), debounce(checkOrgIdAvailability, 300),
[checkOrgIdAvailability] [checkOrgIdAvailability],
) );
useEffect(() => { useEffect(() => {
if (orgId) { if (orgId) {
debouncedCheckOrgIdAvailability(orgId) debouncedCheckOrgIdAvailability(orgId);
} }
}, [orgId, debouncedCheckOrgIdAvailability]) }, [orgId, debouncedCheckOrgIdAvailability]);
const showOrgIdError = () => { const showOrgIdError = () => {
if (orgIdTaken) { if (orgIdTaken) {
@ -56,12 +63,11 @@ export default function StepperForm() {
}; };
const generateId = (name: string) => { const generateId = (name: string) => {
return name.toLowerCase().replace(/\s+/g, '-') return name.toLowerCase().replace(/\s+/g, "-");
} };
const handleNext = async () => { const handleNext = async () => {
if (currentStep === 'org') { if (currentStep === "org") {
const res = await api const res = await api
.put(`/org`, { .put(`/org`, {
orgId: orgId, orgId: orgId,
@ -69,147 +75,190 @@ export default function StepperForm() {
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
title: "Error creating org..." title: "Error creating org...",
}); });
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
setCurrentStep('site') setCurrentStep("site");
setOrgCreated(true) setOrgCreated(true);
} }
} else if (currentStep === "site") setCurrentStep("resources");
} };
else if (currentStep === 'site') setCurrentStep('resources')
}
const handlePrevious = () => { const handlePrevious = () => {
if (currentStep === 'site') setCurrentStep('org') if (currentStep === "site") setCurrentStep("org");
else if (currentStep === 'resources') setCurrentStep('site') else if (currentStep === "resources") setCurrentStep("site");
} };
return ( return (
<div className="w-full max-w-2xl mx-auto p-6"> <>
<h2 className="text-2xl font-bold mb-6">Setup Your Environment</h2> <Card className="w-full max-w-2xl mx-auto">
<div className="mb-8"> <CardHeader>
<div className="flex justify-between mb-2"> <CardTitle>Setup Your Environment</CardTitle>
<div className="flex flex-col items-center"> <CardDescription>
<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'}`}> Create your organization, site, and resources.
1 </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> </div>
<span className={`text-sm font-medium ${currentStep === 'org' ? 'text-primary' : 'text-muted-foreground'}`}>Create Org</span> <div className="flex items-center">
</div> <div className="flex-1 h-px bg-border"></div>
<div className="flex flex-col items-center"> <div className="flex-1 h-px bg-border"></div>
<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> </div>
<span className={`text-sm font-medium ${currentStep === 'site' ? 'text-primary' : 'text-muted-foreground'}`}>Create Site</span>
</div> </div>
<div className="flex flex-col items-center"> {currentStep === "org" && (
<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'}`}> <div className="space-y-4">
3 <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> </div>
<span className={`text-sm font-medium ${currentStep === 'resources' ? 'text-primary' : 'text-muted-foreground'}`}>Create Resources</span> )}
</div> {currentStep === "site" && (
</div> <div className="space-y-6">
<div className="flex items-center"> <div className="space-y-2">
<div className="flex-1 h-px bg-border"></div> <Label htmlFor="siteName">Site Name</Label>
<div className="flex-1 h-px bg-border"></div> <Input
</div> id="siteName"
</div> value={siteName}
{currentStep === 'org' && ( onChange={(e) =>
<div className="space-y-4"> setSiteName(e.target.value)
<div className="space-y-2"> }
<Label htmlFor="orgName">Organization Name</Label> placeholder="Enter site name"
<Input required
id="orgName" />
value={orgName} </div>
onChange={(e) => { setOrgName(e.target.value); setOrgId(generateId(e.target.value)) }} </div>
placeholder="Enter organization name" )}
required {currentStep === "resources" && (
/> <div className="space-y-6">
</div> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="resourceName">
<Label htmlFor="orgId">Organization ID</Label> Resource Name
<Input </Label>
id="orgId" <Input
value={orgId} id="resourceName"
onChange={(e) => setOrgId(e.target.value)} value={resourceName}
/> onChange={(e) =>
{showOrgIdError()} setResourceName(e.target.value)
<p className="text-sm text-muted-foreground"> }
This ID is automatically generated from the organization name and must be unique. placeholder="Enter resource name"
</p> required
</div> />
</div> </div>
)} </div>
{currentStep === 'site' && ( )}
<div className="space-y-6"> <div className="flex justify-between pt-4">
<div className="space-y-2"> <Button
<Label htmlFor="siteName">Site Name</Label> type="button"
<Input variant="outline"
id="siteName" onClick={handlePrevious}
value={siteName} disabled={
onChange={(e) => setSiteName(e.target.value)} currentStep === "org" ||
placeholder="Enter site name" (currentStep === "site" && orgCreated)
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"
> >
Skip for now Previous
</Link> </Button>
) : null} <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> <Button
type="button"
</div> id="button"
onClick={handleNext}
</div> >
</div> Create
) </Button>
</div>
</div>
</CardContent>
</Card>
</>
);
} }
function debounce<T extends (...args: any[]) => any>( function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => { return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout) if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => { timeout = setTimeout(() => {
func(...args) func(...args);
}, wait) }, 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 * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const alertVariants = cva( 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", "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", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-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: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -29,8 +31,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
)) ));
Alert.displayName = "Alert" Alert.displayName = "Alert";
const AlertTitle = React.forwardRef< const AlertTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -38,11 +40,14 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h5 <h5
ref={ref} 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} {...props}
/> />
)) ));
AlertTitle.displayName = "AlertTitle" AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -53,7 +58,7 @@ const AlertDescription = React.forwardRef<
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} {...props}
/> />
)) ));
AlertDescription.displayName = "AlertDescription" AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };