Quellcode durchsuchen

Merge branch 'main' of https://github.com/fosrl/pangolin

Owen Schwartz vor 8 Monaten
Ursprung
Commit
5d1db5413b

+ 0 - 1
server/auth/actions.ts

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

+ 26 - 14
server/routers/auth/getUserOrgs.ts

@@ -1,26 +1,33 @@
-import { Request, Response, NextFunction } from 'express';
-import { db } from '@server/db';
-import { userOrgs, orgs } from '@server/db/schema';
-import { eq } from 'drizzle-orm';
-import createHttpError from 'http-errors';
-import HttpCode from '@server/types/HttpCode';
+import { Request, Response, NextFunction } from "express";
+import { db } from "@server/db";
+import { userOrgs, orgs } from "@server/db/schema";
+import { eq } from "drizzle-orm";
+import createHttpError from "http-errors";
+import HttpCode from "@server/types/HttpCode";
 
-export async function getUserOrgs(req: Request, res: Response, next: NextFunction) {
+export async function getUserOrgs(
+    req: Request,
+    res: Response,
+    next: NextFunction,
+) {
     const userId = req.user?.userId; // Assuming you have user information in the request
 
     if (!userId) {
-        return next(createHttpError(HttpCode.UNAUTHORIZED, 'User not authenticated'));
+        return next(
+            createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated"),
+        );
     }
 
     try {
-        const userOrganizations = await db.select({
-            orgId: userOrgs.orgId,
-            roleId: userOrgs.roleId,
-        })
+        const userOrganizations = await db
+            .select({
+                orgId: userOrgs.orgId,
+                roleId: userOrgs.roleId,
+            })
             .from(userOrgs)
             .where(eq(userOrgs.userId, userId));
 
-        req.userOrgIds = userOrganizations.map(org => org.orgId);
+        req.userOrgIds = userOrganizations.map((org) => org.orgId);
         // req.userOrgRoleIds = userOrganizations.reduce((acc, org) => {
         //   acc[org.orgId] = org.role;
         //   return acc;
@@ -28,6 +35,11 @@ export async function getUserOrgs(req: Request, res: Response, next: NextFunctio
 
         next();
     } catch (error) {
-        next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Error retrieving user organizations'));
+        next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "Error retrieving user organizations",
+            ),
+        );
     }
 }

+ 7 - 19
server/routers/org/listOrgs.ts

@@ -13,17 +13,19 @@ const listOrgsSchema = z.object({
     limit: z
         .string()
         .optional()
+        .default("1000")
         .transform(Number)
-        .pipe(z.number().int().positive().default(10)),
+        .pipe(z.number().int().positive()),
     offset: z
         .string()
         .optional()
+        .default("0")
         .transform(Number)
-        .pipe(z.number().int().nonnegative().default(0)),
+        .pipe(z.number().int().nonnegative()),
 });
 
 export type ListOrgsResponse = {
-    organizations: Org[];
+    orgs: Org[];
     pagination: { total: number; limit: number; offset: number };
 };
 
@@ -45,27 +47,13 @@ export async function listOrgs(
 
         const { limit, offset } = parsedQuery.data;
 
-        // Check if the user has permission to list sites
-        const hasPermission = await checkUserActionPermission(
-            ActionsEnum.listOrgs,
-            req,
-        );
-        if (!hasPermission) {
-            return next(
-                createHttpError(
-                    HttpCode.FORBIDDEN,
-                    "User does not have permission to perform this action",
-                ),
-            );
-        }
-
         // Use the userOrgs passed from the middleware
         const userOrgIds = req.userOrgIds;
 
         if (!userOrgIds || userOrgIds.length === 0) {
             return response<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,

+ 34 - 6
src/app/[orgId]/components/Header.tsx

@@ -1,5 +1,6 @@
 "use client";
 
+import api from "@app/api";
 import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
 import { Button } from "@app/components/ui/button";
 import {
@@ -19,15 +20,23 @@ import {
     SelectTrigger,
     SelectValue,
 } from "@app/components/ui/select";
+import { useToast } from "@app/hooks/use-toast";
+import { ListOrgsResponse } from "@server/routers/org";
 import Link from "next/link";
+import { useRouter } from "next/navigation";
 
 type HeaderProps = {
     name?: string;
     email: string;
     orgName: string;
+    orgs: ListOrgsResponse["orgs"];
 };
 
-export default function Header({ email, orgName, name }: HeaderProps) {
+export default function Header({ email, orgName, name, orgs }: HeaderProps) {
+    const { toast } = useToast();
+
+    const router = useRouter();
+
     function getInitials() {
         if (name) {
             const [firstName, lastName] = name.split(" ");
@@ -36,6 +45,19 @@ export default function Header({ email, orgName, name }: HeaderProps) {
         return email.substring(0, 2).toUpperCase();
     }
 
+    function logout() {
+        api.post("/auth/logout")
+            .catch((e) => {
+                console.error("Error logging out", e);
+                toast({
+                    title: "Error logging out",
+                });
+            })
+            .then(() => {
+                router.push("/auth/login");
+            });
+    }
+
     return (
         <>
             <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>

+ 1 - 3
src/app/[orgId]/components/TopbarNav.tsx

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

+ 20 - 3
src/app/[orgId]/layout.tsx

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

+ 15 - 0
src/app/[orgId]/resources/[resourceId]/layout.tsx

@@ -20,6 +20,21 @@ export const metadata: Metadata = {
     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 {
     children: React.ReactNode;
     params: { resourceId: string; orgId: string };

+ 22 - 0
src/app/[orgId]/resources/components/ResourcesTable.tsx

@@ -17,6 +17,8 @@ export type ResourceRow = {
     id: string;
     name: string;
     orgId: string;
+    domain: string;
+    site: string;
 };
 
 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",
         cell: ({ row }) => {

+ 2 - 0
src/app/[orgId]/resources/page.tsx

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

+ 5 - 5
src/app/[orgId]/sites/[niceId]/layout.tsx

@@ -15,10 +15,10 @@ import { useEffect, useState } from "react";
 import { toast } from "@app/hooks/use-toast";
 import { ClientLayout } from "./components/ClientLayout";
 
-export const metadata: Metadata = {
-    title: "Forms",
-    description: "Advanced form example using react-hook-form and Zod.",
-};
+// export const metadata: Metadata = {
+//     title: "Forms",
+//     description: "Advanced form example using react-hook-form and Zod.",
+// };
 
 interface SettingsLayoutProps {
     children: React.ReactNode;
@@ -70,7 +70,7 @@ export default async function SettingsLayout({
                 </Link>
             </div>
 
-            <SiteProvider site={site}>                
+            <SiteProvider site={site}>
                 <ClientLayout
                 isCreate={params.niceId === "create"}
             >

+ 13 - 0
src/app/auth/layout.tsx

@@ -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>
+        </>
+    );
+}

+ 1 - 1
src/components/auth/LoginForm.tsx → src/app/auth/login/LoginForm.tsx

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

+ 9 - 1
src/app/auth/login/page.tsx

@@ -1,5 +1,6 @@
-import LoginForm from "@app/components/auth/LoginForm";
+import LoginForm from "@app/app/auth/login/LoginForm";
 import { verifySession } from "@app/lib/auth/verifySession";
+import Link from "next/link";
 import { redirect } from "next/navigation";
 
 export default async function Page({
@@ -16,6 +17,13 @@ export default async function Page({
     return (
         <>
             <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>
         </>
     );
 }

+ 1 - 1
src/components/auth/SignupForm.tsx → src/app/auth/signup/SignupForm.tsx

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

+ 9 - 1
src/app/auth/signup/page.tsx

@@ -1,5 +1,6 @@
-import SignupForm from "@app/components/auth/SignupForm";
+import SignupForm from "@app/app/auth/signup/SignupForm";
 import { verifySession } from "@app/lib/auth/verifySession";
+import Link from "next/link";
 import { redirect } from "next/navigation";
 
 export default async function Page({
@@ -16,6 +17,13 @@ export default async function Page({
     return (
         <>
             <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>
         </>
     );
 }

+ 250 - 0
src/app/auth/verify-email/VerifyEmailForm.tsx

@@ -0,0 +1,250 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+    Card,
+    CardContent,
+    CardDescription,
+    CardHeader,
+    CardTitle,
+} from "@/components/ui/card";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+    InputOTP,
+    InputOTPGroup,
+    InputOTPSlot,
+} from "@/components/ui/input-otp";
+import api from "@app/api";
+import { AxiosResponse } from "axios";
+import { VerifyEmailResponse } from "@server/routers/auth";
+import { Loader2 } from "lucide-react";
+import { Alert, AlertDescription } from "../../../components/ui/alert";
+import { useToast } from "@app/hooks/use-toast";
+import { useRouter } from "next/navigation";
+
+const FormSchema = z.object({
+    email: z.string().email({ message: "Invalid email address" }),
+    pin: z.string().min(8, {
+        message: "Your verification code must be 8 characters.",
+    }),
+});
+
+export type VerifyEmailFormProps = {
+    email: string;
+    redirect?: string;
+};
+
+export default function VerifyEmailForm({
+    email,
+    redirect,
+}: VerifyEmailFormProps) {
+    const router = useRouter();
+
+    const [error, setError] = useState<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>
+    );
+}

+ 1 - 1
src/app/auth/verify-email/page.tsx

@@ -1,4 +1,4 @@
-import VerifyEmailForm from "@app/components/auth/VerifyEmailForm";
+import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
 import { verifySession } from "@app/lib/auth/verifySession";
 import { redirect } from "next/navigation";
 

+ 24 - 2
src/app/layout.tsx

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

+ 30 - 0
src/app/page.tsx

@@ -1,5 +1,11 @@
+import { internal } from "@app/api";
+import { authCookieHeader } from "@app/api/cookies";
 import { verifySession } from "@app/lib/auth/verifySession";
 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";
 
 export default async function Page() {
@@ -9,11 +15,35 @@ export default async function Page() {
         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 (
         <>
             <LandingProvider user={user}>
                 <p>Logged in as {user.email}</p>
             </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 - 0
src/app/setup/layout.tsx

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

+ 200 - 151
src/app/setup/page.tsx

@@ -1,48 +1,55 @@
-'use client'
+"use client";
 
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import Link from 'next/link'
-import api from '@app/api'
-import { toast } from '@app/hooks/use-toast'
-import { useCallback, useEffect, useState } from 'react';
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import Link from "next/link";
+import api from "@app/api";
+import { toast } from "@app/hooks/use-toast";
+import { useCallback, useEffect, useState } from "react";
+import {
+    Card,
+    CardContent,
+    CardDescription,
+    CardHeader,
+    CardTitle,
+} from "@app/components/ui/card";
 
-type Step = 'org' | 'site' | 'resources'
+type Step = "org" | "site" | "resources";
 
 export default function StepperForm() {
-    const [currentStep, setCurrentStep] = useState<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}
-
-                    <Button type="button" id="button" onClick={handleNext}>Create</Button>
+                            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}
 
-                </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);
+    };
+}

+ 0 - 229
src/components/auth/VerifyEmailForm.tsx

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

+ 18 - 13
src/components/ui/alert.tsx

@@ -1,7 +1,7 @@
-import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
 
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
 
 const alertVariants = cva(
     "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
@@ -11,13 +11,15 @@ const alertVariants = cva(
                 default: "bg-background text-foreground",
                 destructive:
                     "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+                success:
+                    "border-green-500/50 text-green-500 dark:border-success [&>svg]:text-green-500",
             },
         },
         defaultVariants: {
             variant: "default",
         },
-    }
-)
+    },
+);
 
 const Alert = React.forwardRef<
     HTMLDivElement,
@@ -29,8 +31,8 @@ const Alert = React.forwardRef<
         className={cn(alertVariants({ variant }), className)}
         {...props}
     />
-))
-Alert.displayName = "Alert"
+));
+Alert.displayName = "Alert";
 
 const AlertTitle = React.forwardRef<
     HTMLParagraphElement,
@@ -38,11 +40,14 @@ const AlertTitle = React.forwardRef<
 >(({ className, ...props }, ref) => (
     <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 };