diff --git a/server/auth/actions.ts b/server/auth/actions.ts index dc56ea94..009d5c21 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -32,6 +32,8 @@ export enum ActionsEnum { listRoles = "listRoles", updateRole = "updateRole", inviteUser = "inviteUser", + listInvitations = "listInvitations", + removeInvitation = "removeInvitation", removeUser = "removeUser", listUsers = "listUsers", listSiteRoles = "listSiteRoles", @@ -63,7 +65,7 @@ export enum ActionsEnum { listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", listOrgDomains = "listOrgDomains", - createNewt = "createNewt", + createNewt = "createNewt" } export async function checkUserActionPermission( diff --git a/server/openApi.ts b/server/openApi.ts index 6ffbbdef..8a02e886 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -8,6 +8,7 @@ export enum OpenAPITags { Resource = "Resource", Role = "Role", User = "User", + Invitation = "Invitation", Target = "Target", Rule = "Rule", AccessToken = "Access Token" diff --git a/server/routers/external.ts b/server/routers/external.ts index 91a21995..7ad2db0f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -143,6 +143,27 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/invitations", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listInvitations), + user.listInvitations +); + +authenticated.delete( + "/org/:orgId/invitations/:inviteId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.removeInvitation), + user.removeInvitation +); + +authenticated.get( + "/org/:orgId/users", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listUsers), + user.listUsers +); + authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, @@ -567,7 +588,4 @@ authRouter.post( resource.authWithAccessToken ); -authRouter.post( - "/access-token", - resource.authWithAccessToken -); +authRouter.post("/access-token", resource.authWithAccessToken); diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 5311fc93..8e8fd391 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -7,3 +7,5 @@ export * from "./acceptInvite"; export * from "./getOrgUser"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; +export * from "./listInvitations"; +export * from "./removeInvitation"; diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts new file mode 100644 index 00000000..317a8e72 --- /dev/null +++ b/server/routers/user/listInvitations.ts @@ -0,0 +1,129 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userInvites, roles } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listInvitationsParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listInvitationsQuerySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function queryInvitations(orgId: string, limit: number, offset: number) { + return await db + .select({ + inviteId: userInvites.inviteId, + email: userInvites.email, + expiresAt: userInvites.expiresAt, + roleId: userInvites.roleId, + roleName: roles.name + }) + .from(userInvites) + .leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) + .where(sql`${userInvites.orgId} = ${orgId}`) + .limit(limit) + .offset(offset); +} + +export type ListInvitationsResponse = { + invitations: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/invitations", + description: "List invitations in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Invitation], + request: { + params: listInvitationsParamsSchema, + query: listInvitationsQuerySchema + }, + responses: {} +}); + +export async function listInvitations( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate query parameters + const parsedQuery = listInvitationsQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + // Validate path parameters + const parsedParams = listInvitationsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + // Query invitations + const invitations = await queryInvitations(orgId, limit, offset); + + // Get total count of invitations + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(userInvites) + .where(sql`${userInvites.orgId} = ${orgId}`); + + // Return response + return response(res, { + data: { + invitations, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Invitations retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/removeInvitation.ts b/server/routers/user/removeInvitation.ts new file mode 100644 index 00000000..4b4d361b --- /dev/null +++ b/server/routers/user/removeInvitation.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userInvites } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const removeInvitationParamsSchema = z + .object({ + orgId: z.string(), + inviteId: z.string() + }) + .strict(); + +export async function removeInvitation( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate path parameters + const parsedParams = removeInvitationParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, inviteId } = parsedParams.data; + + // Delete the invitation from the database + const deletedInvitation = await db + .delete(userInvites) + .where( + and( + eq(userInvites.orgId, orgId), + eq(userInvites.inviteId, inviteId) + ) + ) + .returning(); + + // If no rows were deleted, the invitation was not found + if (deletedInvitation.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Invitation with ID ${inviteId} not found in organization ${orgId}` + ) + ); + } + + // Return success response + return response(res, { + data: null, + success: true, + error: false, + message: "Invitation removed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx index 8c1053d6..98c47f96 100644 --- a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx +++ b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx @@ -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 ( diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx b/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx new file mode 100644 index 00000000..75acd777 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; +} + +export function InvitationsDataTable({ + columns, + data +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageSize: 20, + pageIndex: 0 + } + } + }); + + return ( +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No Invitations Found. + + + )} + +
+
+
+ +
+
+ ); +} diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx new file mode 100644 index 00000000..25f17ad7 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx @@ -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(i); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedInvitation, setSelectedInvitation] = + useState(null); + + const api = createApiClient(useEnvContext()); + + const { org } = useOrgContext(); + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const invitation = row.original; + return ( + + + + + + { + setIsDeleteModalOpen(true); + setSelectedInvitation(invitation); + }} + > + + Remove Invitation + + + + + ); + } + }, + { + accessorKey: "email", + header: "Email" + }, + { + accessorKey: "expiresAt", + header: "Expires At", + cell: ({ row }) => { + const expiresAt = new Date(row.original.expiresAt); + const isExpired = expiresAt < new Date(); + + return ( + + {expiresAt.toLocaleString()} + + ); + } + }, + { + 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 ( + <> + { + setIsDeleteModalOpen(val); + setSelectedInvitation(null); + }} + dialog={ +
+

+ Are you sure you want to remove the invitation for{" "} + {selectedInvitation?.email}? +

+

+ Once removed, this invitation will no longer be + valid. You can always re-invite the user later. +

+

+ To confirm, please type the email address of the + invitation below. +

+
+ } + buttonText="Confirm Remove Invitation" + onConfirm={removeInvitation} + string={selectedInvitation?.email ?? ""} + title="Remove Invitation" + /> + + + + ); +} diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx new file mode 100644 index 00000000..02c76a8c --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -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 + >(`/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 ( + <> + + + + + + + + + ); +}