Merge pull request #496 from grokdesigns/add-invitation-management

Add invitation management
This commit is contained in:
Milo Schwartz
2025-04-12 12:12:03 -04:00
committed by GitHub
16 changed files with 1156 additions and 119 deletions

View File

@@ -5,28 +5,37 @@ import { SidebarSettings } from "@app/components/SidebarSettings";
type AccessPageHeaderAndNavProps = {
children: React.ReactNode;
hasInvitations: boolean;
};
export default function AccessPageHeaderAndNav({
children,
hasInvitations
}: AccessPageHeaderAndNavProps) {
const sidebarNavItems = [
{
title: "Users",
href: `/{orgId}/settings/access/users`,
children: hasInvitations
? [
{
title: "Invitations",
href: `/{orgId}/settings/access/invitations`
}
]
: []
},
{
title: "Roles",
href: `/{orgId}/settings/access/roles`,
},
href: `/{orgId}/settings/access/roles`
}
];
return (
<>
<SettingsSectionTitle
title="Manage Users & Roles"
description="Invite users and add them to roles to manage access to your
organization"
description="Invite users and add them to roles to manage access to your organization"
/>
<SidebarSettings sidebarNavItems={sidebarNavItems}>

View File

@@ -0,0 +1,96 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "@app/components/DataTablePagination";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function InvitationsDataTable<TData, TValue>({
columns,
data
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 20,
pageIndex: 0
}
}
});
return (
<div>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No Invitations Found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<div className="mt-4">
<DataTablePagination table={table} />
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { MoreHorizontal } from "lucide-react";
import { InvitationsDataTable } from "./InvitationsDataTable";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import RegenerateInvitationForm from "./RegenerateInvitationForm";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type InvitationRow = {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
};
type InvitationsTableProps = {
invitations: InvitationRow[];
};
export default function InvitationsTable({
invitations: i
}: InvitationsTableProps) {
const [invitations, setInvitations] = useState<InvitationRow[]>(i);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
const [selectedInvitation, setSelectedInvitation] =
useState<InvitationRow | null>(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const columns: ColumnDef<InvitationRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const invitation = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>Regenerate Invitation</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span className="text-red-500">
Remove Invitation
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "email",
header: "Email"
},
{
accessorKey: "expiresAt",
header: "Expires At",
cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date();
return (
<span className={isExpired ? "text-red-500" : ""}>
{expiresAt.toLocaleString()}
</span>
);
}
},
{
accessorKey: "role",
header: "Role"
}
];
async function removeInvitation() {
if (selectedInvitation) {
const res = await api
.delete(
`/org/${org?.org.orgId}/invitations/${selectedInvitation.id}`
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove invitation",
description:
"An error occurred while removing the invitation."
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "Invitation removed",
description: `The invitation for ${selectedInvitation.email} has been removed.`
});
setInvitations((prev) =>
prev.filter(
(invitation) => invitation.id !== selectedInvitation.id
)
);
}
}
setIsDeleteModalOpen(false);
}
return (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInvitation(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the invitation for{" "}
<b>{selectedInvitation?.email}</b>?
</p>
<p>
Once removed, this invitation will no longer be
valid. You can always re-invite the user later.
</p>
<p>
To confirm, please type the email address of the
invitation below.
</p>
</div>
}
buttonText="Confirm Remove Invitation"
onConfirm={removeInvitation}
string={selectedInvitation?.email ?? ""}
title="Remove Invitation"
/>
<RegenerateInvitationForm
open={isRegenerateModalOpen}
setOpen={setIsRegenerateModalOpen}
invitation={selectedInvitation}
onRegenerate={(updatedInvitation) => {
setInvitations((prev) =>
prev.map((inv) =>
inv.id === updatedInvitation.id
? updatedInvitation
: inv
)
);
}}
/>
<InvitationsDataTable columns={columns} data={invitations} />
</>
);
}

View File

@@ -0,0 +1,254 @@
import { Button } from "@app/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from "@app/components/ui/dialog";
import { useState, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import CopyTextBox from "@app/components/CopyTextBox";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
type RegenerateInvitationFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
invitation: {
id: string;
email: string;
roleId: number;
role: string;
} | null;
onRegenerate: (updatedInvitation: {
id: string;
email: string;
expiresAt: string;
role: string;
roleId: number;
}) => void;
};
export default function RegenerateInvitationForm({
open,
setOpen,
invitation,
onRegenerate
}: RegenerateInvitationFormProps) {
const [loading, setLoading] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [sendEmail, setSendEmail] = useState(true);
const [validHours, setValidHours] = useState(72);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const validForOptions = [
{ 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" }
];
useEffect(() => {
if (open) {
setSendEmail(true);
setValidHours(72);
}
}, [open]);
async function handleRegenerate() {
if (!invitation) return;
if (!org?.org.orgId) {
toast({
variant: "destructive",
title: "Organization ID Missing",
description:
"Unable to regenerate invitation without an organization ID.",
duration: 5000
});
return;
}
setLoading(true);
try {
const res = await api.post(`/org/${org.org.orgId}/create-invite`, {
email: invitation.email,
roleId: invitation.roleId,
validHours,
sendEmail,
regenerate: true
});
if (res.status === 200) {
const link = res.data.data.inviteLink;
setInviteLink(link);
if (sendEmail) {
toast({
variant: "default",
title: "Invitation Regenerated",
description: `A new invitation has been sent to ${invitation.email}.`,
duration: 5000
});
} else {
toast({
variant: "default",
title: "Invitation Regenerated",
description: `A new invitation has been generated for ${invitation.email}.`,
duration: 5000
});
}
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: res.data.data.expiresAt,
role: invitation.role,
roleId: invitation.roleId
});
}
} catch (error: any) {
if (error.response?.status === 409) {
toast({
variant: "destructive",
title: "Duplicate Invite",
description: "An invitation for this user already exists.",
duration: 5000
});
} else if (error.response?.status === 429) {
toast({
variant: "destructive",
title: "Rate Limit Exceeded",
description:
"You have exceeded the limit of 3 regenerations per hour. Please try again later.",
duration: 5000
});
} else {
toast({
variant: "destructive",
title: "Failed to Regenerate Invitation",
description:
"An error occurred while regenerating the invitation.",
duration: 5000
});
}
} finally {
setLoading(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setInviteLink(null);
}
}}
>
<DialogContent aria-describedby="regenerate-invite-description">
<DialogHeader>
<DialogTitle>Regenerate Invitation</DialogTitle>
</DialogHeader>
{!inviteLink ? (
<div>
<p>
Are you sure you want to regenerate the invitation
for <b>{invitation?.email}</b>? This will revoke the
previous invitation.
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label htmlFor="send-email">
Send email notification to the user
</label>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
Validity Period
</label>
<Select
value={validHours.toString()}
onValueChange={(value) =>
setValidHours(parseInt(value))
}
>
<SelectTrigger>
<SelectValue placeholder="Select validity period" />
</SelectTrigger>
<SelectContent>
{validForOptions.map((option) => (
<SelectItem
key={option.hours}
value={option.hours.toString()}
>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="space-y-4 max-w-md">
<p>
The invitation has been regenerated. The user must
access the link below to accept the invitation.
</p>
<CopyTextBox text={inviteLink} wrapText={false} />
</div>
)}
<DialogFooter>
{!inviteLink ? (
<>
<Button
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleRegenerate}
loading={loading}
>
Regenerate
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => {
setOpen(false);
setInviteLink(null);
}}
>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,84 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import InvitationsTable, { InvitationRow } from "./InvitationsTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
type InvitationsPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function InvitationsPage(props: InvitationsPageProps) {
const params = await props.params;
const getUser = cache(verifySession);
const user = await getUser();
let invitations: {
inviteId: string;
email: string;
expiresAt: string;
roleId: number;
roleName?: string;
}[] = [];
let hasInvitations = false;
const res = await internal
.get<
AxiosResponse<{
invitations: typeof invitations;
pagination: { total: number };
}>
>(`/org/${params.orgId}/invitations`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
invitations = res.data.data.invitations;
hasInvitations = res.data.data.pagination.total > 0;
}
let org: GetOrgResponse | null = null;
const getOrg = cache(async () =>
internal
.get<
AxiosResponse<GetOrgResponse>
>(`/org/${params.orgId}`, await authCookieHeader())
.catch((e) => {
console.error(e);
})
);
const orgRes = await getOrg();
if (orgRes && orgRes.status === 200) {
org = orgRes.data.data;
}
const invitationRows: InvitationRow[] = invitations.map((invite) => {
return {
id: invite.inviteId,
email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || "Unknown Role",
roleId: invite.roleId
};
});
return (
<>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<UserProvider user={user!}>
<OrgProvider org={org}>
<InvitationsTable invitations={invitationRows} />
</OrgProvider>
</UserProvider>
</AccessPageHeaderAndNav>
</>
);
}

View File

@@ -19,6 +19,8 @@ export default async function RolesPage(props: RolesPageProps) {
const params = await props.params;
let roles: ListRolesResponse["roles"] = [];
let hasInvitations = false;
const res = await internal
.get<
AxiosResponse<ListRolesResponse>
@@ -29,6 +31,21 @@ export default async function RolesPage(props: RolesPageProps) {
roles = res.data.data.roles;
}
const invitationsRes = await internal
.get<
AxiosResponse<{
pagination: { total: number };
}>
>(
`/org/${params.orgId}/invitations?limit=1&offset=0`,
await authCookieHeader()
)
.catch((e) => {});
if (invitationsRes && invitationsRes.status === 200) {
hasInvitations = invitationsRes.data.data.pagination.total > 0;
}
let org: GetOrgResponse | null = null;
const getOrg = cache(async () =>
internal
@@ -47,7 +64,7 @@ export default async function RolesPage(props: RolesPageProps) {
return (
<>
<AccessPageHeaderAndNav>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<OrgProvider org={org}>
<RolesTable roles={roleRows} />
</OrgProvider>

View File

@@ -55,17 +55,13 @@ const formSchema = z.object({
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const validFor = [
@@ -87,6 +83,15 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
}
});
useEffect(() => {
if (open) {
setSendEmail(env.email.emailEnabled);
form.reset();
setInviteLink(null);
setExpiresInDays(1);
}
}, [open, env.email.emailEnabled, form]);
useEffect(() => {
if (!open) {
return;
@@ -111,10 +116,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
if (res?.status === 200) {
setRoles(res.data.data.roles);
// form.setValue(
// "roleId",
// res.data.data.roles[0].roleId.toString()
// );
}
}
@@ -135,14 +136,23 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
} as InviteUserBody
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to invite user",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
)
});
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: "User Already Exists",
description:
"This user is already a member of the organization."
});
} else {
toast({
variant: "destructive",
title: "Failed to invite user",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
)
});
}
});
if (res && res.status === 200) {
@@ -165,10 +175,12 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
open={open}
onOpenChange={(val) => {
setOpen(val);
setInviteLink(null);
setLoading(false);
setExpiresInDays(1);
form.reset();
if (!val) {
setInviteLink(null);
setLoading(false);
setExpiresInDays(1);
form.reset();
}
}}
>
<CredenzaContent>

View File

@@ -23,6 +23,8 @@ export default async function UsersPage(props: UsersPageProps) {
const user = await getUser();
let users: ListUsersResponse["users"] = [];
let hasInvitations = false;
const res = await internal
.get<
AxiosResponse<ListUsersResponse>
@@ -33,6 +35,21 @@ export default async function UsersPage(props: UsersPageProps) {
users = res.data.data.users;
}
const invitationsRes = await internal
.get<
AxiosResponse<{
pagination: { total: number };
}>
>(
`/org/${params.orgId}/invitations?limit=1&offset=0`,
await authCookieHeader()
)
.catch((e) => {});
if (invitationsRes && invitationsRes.status === 200) {
hasInvitations = invitationsRes.data.data.pagination.total > 0;
}
let org: GetOrgResponse | null = null;
const getOrg = cache(async () =>
internal
@@ -61,7 +78,7 @@ export default async function UsersPage(props: UsersPageProps) {
return (
<>
<AccessPageHeaderAndNav>
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
<UserProvider user={user!}>
<OrgProvider org={org}>
<UsersTable users={userRows} />

View File

@@ -10,15 +10,19 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@/components/ui/select";
import { CornerDownRight } from "lucide-react";
interface SidebarNavItem {
href: string;
title: string;
icon?: React.ReactNode;
children?: SidebarNavItem[];
}
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string;
title: string;
icon?: React.ReactNode;
}[];
items: SidebarNavItem[];
disabled?: boolean;
}
@@ -35,7 +39,8 @@ export function SidebarNav({
const resourceId = params.resourceId as string;
const userId = params.userId as string;
const [selectedValue, setSelectedValue] = React.useState<string>(getSelectedValue());
const [selectedValue, setSelectedValue] =
React.useState<string>(getSelectedValue());
useEffect(() => {
setSelectedValue(getSelectedValue());
@@ -50,8 +55,25 @@ export function SidebarNav({
};
function getSelectedValue() {
const item = items.find((item) => hydrateHref(item.href) === pathname);
return hydrateHref(item?.href || "");
let foundHref = "";
for (const item of items) {
const hydratedHref = hydrateHref(item.href);
if (hydratedHref === pathname) {
foundHref = hydratedHref;
break;
}
if (item.children) {
for (const child of item.children) {
const hydratedChildHref = hydrateHref(child.href);
if (hydratedChildHref === pathname) {
foundHref = hydratedChildHref;
break;
}
}
}
if (foundHref) break;
}
return foundHref;
}
function hydrateHref(val: string): string {
@@ -62,6 +84,77 @@ export function SidebarNav({
.replace("{userId}", userId);
}
function renderItems(items: SidebarNavItem[]) {
return items.map((item) => (
<div key={hydrateHref(item.href)}>
<Link
href={hydrateHref(item.href)}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(item.href) &&
!pathname.includes("create")
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"
)}
onClick={disabled ? (e) => e.preventDefault() : undefined}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
</Link>
{item.children && (
<div className="ml-4 space-y-2">
{item.children.map((child) => (
<div
key={hydrateHref(child.href)}
className="flex items-center space-x-2"
>
<CornerDownRight className="h-4 w-4 text-gray-500" />
<Link
href={hydrateHref(child.href)}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(child.href) &&
!pathname.includes("create")
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"
)}
onClick={
disabled
? (e) => e.preventDefault()
: undefined
}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{child.icon ? (
<div className="flex items-center space-x-2">
{child.icon}
<span>{child.title}</span>
</div>
) : (
child.title
)}
</Link>
</div>
))}
</div>
)}
</div>
));
}
return (
<div>
<div className="block lg:hidden">
@@ -75,14 +168,44 @@ export function SidebarNav({
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem
key={hydrateHref(item.href)}
value={hydrateHref(item.href)}
>
{item.title}
</SelectItem>
))}
{items.flatMap((item) => {
const topLevelItem = (
<SelectItem
key={hydrateHref(item.href)}
value={hydrateHref(item.href)}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
</SelectItem>
);
const childItems =
item.children?.map((child) => (
<SelectItem
key={hydrateHref(child.href)}
value={hydrateHref(child.href)}
className="pl-8"
>
<div className="flex items-center space-x-2">
<CornerDownRight className="h-4 w-4 text-gray-500" />
{child.icon ? (
<>
{child.icon}
<span>{child.title}</span>
</>
) : (
<span>{child.title}</span>
)}
</div>
</SelectItem>
)) || [];
return [topLevelItem, ...childItems];
})}
</SelectContent>
</Select>
</div>
@@ -94,35 +217,7 @@ export function SidebarNav({
)}
{...props}
>
{items.map((item) => (
<Link
key={hydrateHref(item.href)}
href={hydrateHref(item.href)}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(item.href) &&
!pathname.includes("create")
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"
)}
onClick={
disabled ? (e) => e.preventDefault() : undefined
}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
</Link>
))}
{renderItems(items)}
</nav>
</div>
);