Merge branch 'main' of https://github.com/fosrl/pangolin
This commit is contained in:
commit
5d1db5413b
23 changed files with 695 additions and 451 deletions
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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
13
src/app/auth/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -136,7 +136,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert>
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,7 +157,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert>
|
<Alert variant="destructive">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
250
src/app/auth/verify-email/VerifyEmailForm.tsx
Normal file
250
src/app/auth/verify-email/VerifyEmailForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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`}>
|
||||||
|
|
|
@ -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
7
src/app/setup/layout.tsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export default async function SetupLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="mt-32">{children}</div>;
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
};
|
||||||
}
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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 };
|
||||||
|
|
Loading…
Add table
Reference in a new issue