Add invitation management

This commit is contained in:
grokdesigns
2025-04-08 18:18:57 -07:00
parent 40040af957
commit d7f50bac6a
10 changed files with 574 additions and 9 deletions

View File

@@ -8,17 +8,21 @@ type AccessPageHeaderAndNavProps = {
};
export default function AccessPageHeaderAndNav({
children,
children
}: AccessPageHeaderAndNavProps) {
const sidebarNavItems = [
{
title: "Users",
href: `/{orgId}/settings/access/users`,
href: `/{orgId}/settings/access/users`
},
{
title: "Invitations",
href: `/{orgId}/settings/access/invitations`
},
{
title: "Roles",
href: `/{orgId}/settings/access/roles`,
},
href: `/{orgId}/settings/access/roles`
}
];
return (

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,161 @@
"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 { 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; // ISO string or timestamp
role: string;
};
type InvitationsTableProps = {
invitations: InvitationRow[];
};
export default function InvitationsTable({
invitations: i
}: InvitationsTableProps) {
const [invitations, setInvitations] = useState<InvitationRow[]>(i);
const [isDeleteModalOpen, setIsDeleteModalOpen] = 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={() => {
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"
/>
<InvitationsDataTable columns={columns} data={invitations} />
</>
);
}

View File

@@ -0,0 +1,79 @@
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: string;
roleName?: string;
}[] = [];
const res = await internal
.get<
AxiosResponse<{
invitations: typeof invitations;
}>
>(`/org/${params.orgId}/invitations`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
invitations = res.data.data.invitations;
}
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" // Use roleName if available
};
});
return (
<>
<AccessPageHeaderAndNav>
<UserProvider user={user!}>
<OrgProvider org={org}>
<InvitationsTable invitations={invitationRows} />
</OrgProvider>
</UserProvider>
</AccessPageHeaderAndNav>
</>
);
}