basic invite user functional

This commit is contained in:
Milo Schwartz 2024-11-02 23:46:08 -04:00
parent a6bb8f5bb1
commit a6baebb216
No known key found for this signature in database
15 changed files with 684 additions and 137 deletions

View file

@ -210,13 +210,13 @@ export const userInvites = sqliteTable("userInvites", {
inviteId: text("inviteId").primaryKey(), inviteId: text("inviteId").primaryKey(),
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId), .references(() => orgs.orgId, { onDelete: "cascade" }),
email: text("email").notNull(), email: text("email").notNull(),
expiresAt: integer("expiresAt").notNull(), expiresAt: integer("expiresAt").notNull(),
tokenHash: text("token").notNull(), tokenHash: text("token").notNull(),
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
.references(() => roles.roleId), .references(() => roles.roleId, { onDelete: "cascade" }),
}); });
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;

View file

@ -72,8 +72,8 @@ authenticated.post(
"/org/:orgId/create-invite", "/org/:orgId/create-invite",
verifyOrgAccess, verifyOrgAccess,
user.inviteUser user.inviteUser
); ); // maybe make this /invite/create instead
authenticated.post("/org/:orgId/accept-invite", user.acceptInvite); authenticated.post("/invite/accept", user.acceptInvite);
authenticated.get( authenticated.get(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",

View file

@ -9,13 +9,17 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { isWithinExpirationDate } from "oslo";
const acceptInviteBodySchema = z.object({ const acceptInviteBodySchema = z.object({
token: z.string(), token: z.string(),
inviteId: z.string(), inviteId: z.string(),
}); });
export type AcceptInviteResponse = {}; export type AcceptInviteResponse = {
accepted: boolean;
orgId: string;
};
export async function acceptInvite( export async function acceptInvite(
req: Request, req: Request,
@ -50,6 +54,12 @@ export async function acceptInvite(
); );
} }
if (!isWithinExpirationDate(new Date(existingInvite[0].expiresAt))) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invite has expired")
);
}
const validToken = await verify(existingInvite[0].tokenHash, token, { const validToken = await verify(existingInvite[0].tokenHash, token, {
memoryCost: 19456, memoryCost: 19456,
timeCost: 2, timeCost: 2,
@ -79,6 +89,15 @@ export async function acceptInvite(
); );
} }
if (existingUser[0].email !== existingInvite[0].email) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invite is not for this user"
)
);
}
let roleId: number; let roleId: number;
// get the role to make sure it exists // get the role to make sure it exists
const existingRole = await db const existingRole = await db
@ -109,7 +128,7 @@ export async function acceptInvite(
await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId)); await db.delete(userInvites).where(eq(userInvites.inviteId, inviteId));
return response<AcceptInviteResponse>(res, { return response<AcceptInviteResponse>(res, {
data: {}, data: { accepted: true, orgId: existingInvite[0].orgId },
success: true, success: true,
error: false, error: false,
message: "Invite accepted", message: "Invite accepted",

View file

@ -24,6 +24,8 @@ const inviteUserBodySchema = z.object({
validHours: z.number().gt(0).lte(168), validHours: z.number().gt(0).lte(168),
}); });
export type InviteUserBody = z.infer<typeof inviteUserBodySchema>;
export type InviteUserResponse = { export type InviteUserResponse = {
inviteLink: string; inviteLink: string;
expiresAt: number; expiresAt: number;
@ -112,7 +114,7 @@ export async function inviteUser(
roleId, roleId,
}); });
const inviteLink = `${config.app.base_url}/invite/${inviteId}-${token}`; const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
return response<InviteUserResponse>(res, { return response<InviteUserResponse>(res, {
data: { data: {

View file

@ -0,0 +1,224 @@
"use client";
import api from "@app/api";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/use-toast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useParams } from "next/navigation";
import CopyTextBox from "@app/components/CopyTextBox";
const formSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string(),
roleId: z.string(),
});
export default function InviteUserForm() {
const { toast } = useToast();
const { orgId } = useParams();
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const roles = [
{ roleId: 1, name: "Super User" },
{ roleId: 2, name: "Admin" },
{ roleId: 3, name: "Power User" },
{ roleId: 4, name: "User" },
{ roleId: 5, name: "Guest" },
];
const validFor = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" },
];
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
validForHours: "24",
roleId: "4",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleId: parseInt(values.roleId),
validHours: parseInt(values.validForHours),
} as InviteUserBody
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to invite user",
description:
e.response?.data?.message ||
"An error occurred while inviting the user.",
});
});
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: "User invited",
description: "The user has been successfully invited.",
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
setLoading(false);
}
return (
<>
{!inviteLink && (
<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="Enter an email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>Valid For</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map((option) => (
<SelectItem
key={option.hours}
value={option.hours.toString()}
>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-center">
<Button
type="submit"
loading={loading}
disabled={inviteLink !== null}
>
Invite User
</Button>
</div>
</form>
</Form>
)}
{inviteLink && (
<div className="max-w-md">
<p className="mb-4">
The user has been successfully invited. They must access
the link below to accept the invitation.
</p>
<p className="mb-4">
The invite will expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1 ? "day" : "days"}
</b>
.
</p>
{/* <CopyTextBox text={inviteLink} wrapText={false} /> */}
</div>
)}
</>
);
}

View file

@ -10,6 +10,15 @@ import {
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "./UsersDataTable"; import { UsersDataTable } from "./UsersDataTable";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@app/components/ui/dialog";
import { useState } from "react";
import InviteUserForm from "./InviteUserForm";
export type UserRow = { export type UserRow = {
id: string; id: string;
@ -60,13 +69,29 @@ type UsersTableProps = {
}; };
export default function UsersTable({ users }: UsersTableProps) { export default function UsersTable({ users }: UsersTableProps) {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
return ( return (
<>
<Dialog
open={isInviteModalOpen}
onOpenChange={setIsInviteModalOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
</DialogHeader>
<InviteUserForm />
</DialogContent>
</Dialog>
<UsersDataTable <UsersDataTable
columns={columns} columns={columns}
data={users} data={users}
inviteUser={() => { inviteUser={() => {
console.log("Invite user"); setIsInviteModalOpen(true);
}} }}
/> />
</>
); );
} }

View file

@ -3,11 +3,9 @@ import { verifySession } from "@app/lib/auth/verifySession";
import Link from "next/link"; 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(props: {
props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
} }) {
) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const user = await verifySession(); const user = await verifySession();
@ -21,7 +19,14 @@ export default async function Page(
<p className="text-center text-muted-foreground mt-4"> <p className="text-center text-muted-foreground mt-4">
Don't have an account?{" "} Don't have an account?{" "}
<Link href="/auth/signup" className="underline"> <Link
href={
!searchParams.redirect
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
}
className="underline"
>
Sign up Sign up
</Link> </Link>
</p> </p>

View file

@ -3,11 +3,9 @@ import { verifySession } from "@app/lib/auth/verifySession";
import Link from "next/link"; 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(props: {
props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
} }) {
) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const user = await verifySession(); const user = await verifySession();
@ -21,7 +19,14 @@ export default async function Page(
<p className="text-center text-muted-foreground mt-4"> <p className="text-center text-muted-foreground mt-4">
Already have an account?{" "} Already have an account?{" "}
<Link href="/auth/login" className="underline"> <Link
href={
!searchParams.redirect
? `/auth/login`
: `/auth/login?redirect=${searchParams.redirect}`
}
className="underline"
>
Log in Log in
</Link> </Link>
</p> </p>

View file

@ -0,0 +1,120 @@
"use client";
import api from "@app/api";
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@app/components/ui/card";
import { XCircle } from "lucide-react";
import { useRouter } from "next/navigation";
type InviteStatusCardProps = {
type: "rejected" | "wrong_user" | "user_does_not_exist";
token: string;
};
export default function InviteStatusCard({
type,
token,
}: InviteStatusCardProps) {
const router = useRouter();
async function goToLogin() {
await api.post("/auth/logout", {});
router.push(`/auth/login?redirect=/invite?token=${token}`);
}
async function goToSignup() {
await api.post("/auth/logout", {});
router.push(`/auth/signup?redirect=/invite?token=${token}`);
}
function renderBody() {
if (type === "rejected") {
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access has not been accepted or is no longer valid.
</p>
<ul className="list-disc list-inside text-sm space-y-2">
<li>The invite may have expired</li>
<li>The invite might have been revoked</li>
<li>There could be a typo in the invite link</li>
</ul>
</div>
);
} else if (type === "wrong_user") {
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access is not for this user.
</p>
<p className="text-center">
Please make sure you're logged in as the correct user.
</p>
</div>
);
} else if (type === "user_does_not_exist") {
return (
<div>
<p className="text-center mb-4">
We're sorry, but it looks like the invite you're trying
to access is not for a user that exists.
</p>
<p className="text-center">
Please create an account first.
</p>
</div>
);
}
}
function renderFooter() {
if (type === "rejected") {
return (
<Button
onClick={() => {
router.push("/");
}}
>
Go home
</Button>
);
} else if (type === "wrong_user") {
return (
<Button onClick={goToLogin}>Login in as different user</Button>
);
} else if (type === "user_does_not_exist") {
return <Button onClick={goToSignup}>Create an account</Button>;
}
}
return (
<div className="p-3 md:mt-32 flex items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-red-100 mx-auto mb-4">
<XCircle
className="w-10 h-10 text-red-600"
aria-hidden="true"
/>
</div>
<CardTitle className="text-center text-2xl font-bold">
Invite Not Accepted
</CardTitle>
</CardHeader>
<CardContent>{renderBody()}</CardContent>
<CardFooter className="flex justify-center space-x-4">
{renderFooter()}
</CardFooter>
</Card>
</div>
);
}

77
src/app/invite/page.tsx Normal file
View file

@ -0,0 +1,77 @@
import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { AcceptInviteResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import InviteStatusCard from "./InviteStatusCard";
export default async function InvitePage(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const params = await props.searchParams;
const tokenParam = params.token as string;
if (!tokenParam) {
redirect("/");
}
const user = await verifySession();
if (!user) {
redirect(`/auth/login?redirect=/invite?token=${params.token}`);
}
const parts = tokenParam.split("-");
if (parts.length !== 2) {
return (
<>
<h1>Invalid Invite</h1>
<p>The invite link is invalid.</p>
</>
);
}
const inviteId = parts[0];
const token = parts[1];
let error = "";
const res = await internal
.post<AxiosResponse<AcceptInviteResponse>>(
`/invite/accept`,
{
inviteId,
token,
},
await authCookieHeader()
)
.catch((e) => {
error = e.response?.data?.message;
console.log(error);
});
if (res && res.status === 200) {
redirect(`/${res.data.data.orgId}`);
}
function cardType() {
if (error.includes("Invite is not for this user")) {
return "wrong_user";
} else if (
error.includes(
"User does not exist. Please create an account first."
)
) {
return "user_does_not_exist";
} else {
return "rejected";
}
}
return (
<>
<InviteStatusCard type={cardType()} token={tokenParam} />
</>
);
}

View file

@ -14,6 +14,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox";
type Step = "org" | "site" | "resources"; type Step = "org" | "site" | "resources";

View file

View file

@ -0,0 +1,52 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
export default function CopyTextBox({ text = "", wrapText = false }) {
const [isCopied, setIsCopied] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
const copyToClipboard = async () => {
if (textRef.current) {
try {
await navigator.clipboard.writeText(
textRef.current.textContent || ""
);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
}
};
return (
<div className="relative w-full border rounded-md">
<pre
ref={textRef}
className={`p-4 pr-16 text-sm w-full ${
wrapText
? "whitespace-pre-wrap break-words"
: "overflow-x-auto"
}`}
>
<code className="block w-full">{text}</code>
</pre>
<Button
variant="outline"
size="icon"
className="absolute top-1 right-1 z-10"
onClick={copyToClipboard}
aria-label="Copy to clipboard"
>
{isCopied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
);
}

View file

@ -1,15 +1,17 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
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";
import { Loader2 } from "lucide-react";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: outline:
@ -31,26 +33,41 @@ const buttonVariants = cva(
size: "default", size: "default",
}, },
} }
) );
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
loading?: boolean; // Add loading prop
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { (
const Comp = asChild ? Slot : "button" {
className,
variant,
size,
asChild = false,
loading = false,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
disabled={loading || props.disabled} // Disable button when loading
{...props} {...props}
/> >
) {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{props.children}
</Comp>
);
} }
) );
Button.displayName = "Button" Button.displayName = "Button";
export { Button, buttonVariants } export { Button, buttonVariants };

View file

@ -1,18 +1,18 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react" import { X } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@ -64,8 +64,8 @@ const DialogHeader = ({
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -78,8 +78,8 @@ const DialogFooter = ({
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@ -119,4 +119,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };