This commit is contained in:
Owen Schwartz 2024-10-19 17:30:15 -04:00
commit 5d1db5413b
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
23 changed files with 695 additions and 451 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
.select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
roleId: userOrgs.roleId, 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
value={org.name}
key={org.orgId}
>
{org.name}
</SelectItem> </SelectItem>
))}
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View file

@ -51,9 +51,7 @@ export function TopbarNav({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.icon && ( {item.icon && (
<div className="hidden md:block"> <div className="hidden md:block">{item.icon}</div>
{item.icon}
</div>
)} )}
{item.title} {item.title}
</div> </div>

View file

@ -7,11 +7,11 @@ 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 = {
title: "Configuration", title: `Configuration - ${process.env.NEXT_PUBLIC_APP_NAME}`,
description: "", description: "",
}; };
@ -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>

View file

@ -20,6 +20,21 @@ export const metadata: Metadata = {
description: "Advanced form example using react-hook-form and Zod.", description: "Advanced form example using react-hook-form and Zod.",
}; };
const sidebarNavItems = [
{
title: "Profile",
href: "/{orgId}/resources/{resourceId}",
},
// {
// title: "Appearance",
// href: "/{orgId}/resources/{resourceId}/appearance",
// },
// {
// title: "Notifications",
// href: "/{orgId}/resources/{resourceId}/notifications",
// },
]
interface SettingsLayoutProps { interface SettingsLayoutProps {
children: React.ReactNode; children: React.ReactNode;
params: { resourceId: string; orgId: string }; params: { resourceId: string; orgId: string };

View file

@ -17,6 +17,8 @@ export type ResourceRow = {
id: string; id: string;
name: string; name: string;
orgId: string; orgId: string;
domain: string;
site: string;
}; };
export const columns: ColumnDef<ResourceRow>[] = [ export const columns: ColumnDef<ResourceRow>[] = [
@ -36,6 +38,26 @@ export const columns: ColumnDef<ResourceRow>[] = [
); );
}, },
}, },
{
accessorKey: "site",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Site
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "domain",
header: "Domain",
},
{ {
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {

View file

@ -25,6 +25,8 @@ export default async function Page({ params }: ResourcesPageProps) {
id: resource.resourceId.toString(), id: resource.resourceId.toString(),
name: resource.name, name: resource.name,
orgId: params.orgId, orgId: params.orgId,
domain: resource.subdomain || "",
site: resource.siteName || "None",
}; };
}); });

View file

@ -15,10 +15,10 @@ import { useEffect, useState } from "react";
import { toast } from "@app/hooks/use-toast"; import { toast } from "@app/hooks/use-toast";
import { ClientLayout } from "./components/ClientLayout"; import { ClientLayout } from "./components/ClientLayout";
export const metadata: Metadata = { // export const metadata: Metadata = {
title: "Forms", // title: "Forms",
description: "Advanced form example using react-hook-form and Zod.", // description: "Advanced form example using react-hook-form and Zod.",
}; // };
interface SettingsLayoutProps { interface SettingsLayoutProps {
children: React.ReactNode; children: React.ReactNode;

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="p-3 md: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,11 +1,16 @@
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: `Dashboard - ${process.env.NEXT_PUBLIC_APP_NAME}`,
description: "", description: "",
}; };
@ -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`}>

View file

@ -1,5 +1,11 @@
import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { LandingProvider } from "@app/providers/LandingProvider"; import { LandingProvider } from "@app/providers/LandingProvider";
import { ListOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { ArrowUpLeft, ArrowUpRight } from "lucide-react";
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() {
@ -9,11 +15,35 @@ export default async function Page() {
redirect("/auth/login"); redirect("/auth/login");
} }
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 (
<> <>
<LandingProvider user={user}> <LandingProvider user={user}>
<p>Logged in as {user.email}</p> <p>Logged in as {user.email}</p>
</LandingProvider> </LandingProvider>
<div className="mt-4">
{orgs.map((org) => (
<Link key={org.orgId} href={`/${org.orgId}`} className="text-primary underline">
<div className="flex items-center">
{org.name}
<ArrowUpRight className="w-4 h-4"/>
</div>
</Link>
))}
</div>
</> </>
); );
} }

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,47 +75,69 @@ 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">
<CardHeader>
<CardTitle>Setup Your Environment</CardTitle>
<CardDescription>
Create your organization, site, and resources.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-8"> <div className="mb-8">
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">
<div className="flex flex-col items-center"> <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'}`}> <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 1
</div> </div>
<span className={`text-sm font-medium ${currentStep === 'org' ? 'text-primary' : 'text-muted-foreground'}`}>Create Org</span> <span
className={`text-sm font-medium ${currentStep === "org" ? "text-primary" : "text-muted-foreground"}`}
>
Create Org
</span>
</div> </div>
<div className="flex flex-col items-center"> <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'}`}> <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 2
</div> </div>
<span className={`text-sm font-medium ${currentStep === 'site' ? 'text-primary' : 'text-muted-foreground'}`}>Create Site</span> <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"> <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'}`}> <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 3
</div> </div>
<span className={`text-sm font-medium ${currentStep === 'resources' ? 'text-primary' : 'text-muted-foreground'}`}>Create Resources</span> <span
className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`}
>
Create Resources
</span>
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
@ -117,14 +145,19 @@ export default function StepperForm() {
<div className="flex-1 h-px bg-border"></div> <div className="flex-1 h-px bg-border"></div>
</div> </div>
</div> </div>
{currentStep === 'org' && ( {currentStep === "org" && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="orgName">Organization Name</Label> <Label htmlFor="orgName">
Organization Name
</Label>
<Input <Input
id="orgName" id="orgName"
value={orgName} value={orgName}
onChange={(e) => { setOrgName(e.target.value); setOrgId(generateId(e.target.value)) }} onChange={(e) => {
setOrgName(e.target.value);
setOrgId(generateId(e.target.value));
}}
placeholder="Enter organization name" placeholder="Enter organization name"
required required
/> />
@ -138,33 +171,40 @@ export default function StepperForm() {
/> />
{showOrgIdError()} {showOrgIdError()}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This ID is automatically generated from the organization name and must be unique. This ID is automatically generated from the
organization name and must be unique.
</p> </p>
</div> </div>
</div> </div>
)} )}
{currentStep === 'site' && ( {currentStep === "site" && (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="siteName">Site Name</Label> <Label htmlFor="siteName">Site Name</Label>
<Input <Input
id="siteName" id="siteName"
value={siteName} value={siteName}
onChange={(e) => setSiteName(e.target.value)} onChange={(e) =>
setSiteName(e.target.value)
}
placeholder="Enter site name" placeholder="Enter site name"
required required
/> />
</div> </div>
</div> </div>
)} )}
{currentStep === 'resources' && ( {currentStep === "resources" && (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="resourceName">Resource Name</Label> <Label htmlFor="resourceName">
Resource Name
</Label>
<Input <Input
id="resourceName" id="resourceName"
value={resourceName} value={resourceName}
onChange={(e) => setResourceName(e.target.value)} onChange={(e) =>
setResourceName(e.target.value)
}
placeholder="Enter resource name" placeholder="Enter resource name"
required required
/> />
@ -176,12 +216,15 @@ export default function StepperForm() {
type="button" type="button"
variant="outline" variant="outline"
onClick={handlePrevious} onClick={handlePrevious}
disabled={currentStep === 'org' || (currentStep === 'site' && orgCreated)} disabled={
currentStep === "org" ||
(currentStep === "site" && orgCreated)
}
> >
Previous Previous
</Button> </Button>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{currentStep !== 'org' ? ( {currentStep !== "org" ? (
<Link <Link
href={`/${orgId}/sites`} href={`/${orgId}/sites`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
@ -190,26 +233,32 @@ export default function StepperForm() {
</Link> </Link>
) : null} ) : null}
<Button type="button" id="button" onClick={handleNext}>Create</Button> <Button
type="button"
</div> id="button"
onClick={handleNext}
>
Create
</Button>
</div> </div>
</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 };