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",
|
||||
deleteOrg = "deleteOrg",
|
||||
getOrg = "getOrg",
|
||||
listOrgs = "listOrgs",
|
||||
updateOrg = "updateOrg",
|
||||
createSite = "createSite",
|
||||
deleteSite = "deleteSite",
|
||||
|
|
|
@ -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({
|
||||
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",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
{orgs.map((org) => (
|
||||
<SelectItem
|
||||
value={org.name}
|
||||
key={org.orgId}
|
||||
>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
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 && (
|
||||
<Alert>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -157,7 +157,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
|
|||
/>
|
||||
|
||||
{error && (
|
||||
<Alert>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</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 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
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 { redirect } from "next/navigation";
|
||||
|
||||
|
|
|
@ -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`}>
|
||||
|
|
|
@ -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
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 { 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,47 +75,69 @@ export default function StepperForm() {
|
|||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: "Error creating org..."
|
||||
title: "Error creating org...",
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
setCurrentStep('site')
|
||||
setOrgCreated(true)
|
||||
}
|
||||
|
||||
}
|
||||
else if (currentStep === 'site') setCurrentStep('resources')
|
||||
setCurrentStep("site");
|
||||
setOrgCreated(true);
|
||||
}
|
||||
} 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>
|
||||
<>
|
||||
<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'}`}>
|
||||
<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>
|
||||
<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'}`}>
|
||||
<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>
|
||||
<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'}`}>
|
||||
<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>
|
||||
<span
|
||||
className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`}
|
||||
>
|
||||
Create Resources
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
@ -117,14 +145,19 @@ export default function StepperForm() {
|
|||
<div className="flex-1 h-px bg-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
{currentStep === 'org' && (
|
||||
{currentStep === "org" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orgName">Organization Name</Label>
|
||||
<Label htmlFor="orgName">
|
||||
Organization Name
|
||||
</Label>
|
||||
<Input
|
||||
id="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"
|
||||
required
|
||||
/>
|
||||
|
@ -138,33 +171,40 @@ export default function StepperForm() {
|
|||
/>
|
||||
{showOrgIdError()}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 'site' && (
|
||||
{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)}
|
||||
onChange={(e) =>
|
||||
setSiteName(e.target.value)
|
||||
}
|
||||
placeholder="Enter site name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 'resources' && (
|
||||
{currentStep === "resources" && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resourceName">Resource Name</Label>
|
||||
<Label htmlFor="resourceName">
|
||||
Resource Name
|
||||
</Label>
|
||||
<Input
|
||||
id="resourceName"
|
||||
value={resourceName}
|
||||
onChange={(e) => setResourceName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setResourceName(e.target.value)
|
||||
}
|
||||
placeholder="Enter resource name"
|
||||
required
|
||||
/>
|
||||
|
@ -176,12 +216,15 @@ export default function StepperForm() {
|
|||
type="button"
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 'org' || (currentStep === 'site' && orgCreated)}
|
||||
disabled={
|
||||
currentStep === "org" ||
|
||||
(currentStep === "site" && orgCreated)
|
||||
}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
{currentStep !== 'org' ? (
|
||||
{currentStep !== "org" ? (
|
||||
<Link
|
||||
href={`/${orgId}/sites`}
|
||||
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>
|
||||
) : null}
|
||||
|
||||
<Button type="button" id="button" onClick={handleNext}>Create</Button>
|
||||
|
||||
</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);
|
||||
};
|
||||
}
|
|
@ -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 { 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 };
|
||||
|
|
Loading…
Add table
Reference in a new issue