Add invitation management

This commit is contained in:
grokdesigns 2025-04-08 18:18:57 -07:00
parent 40040af957
commit d7f50bac6a
No known key found for this signature in database
GPG key ID: 1084CD111FEE75DD
10 changed files with 574 additions and 9 deletions

View file

@ -32,6 +32,8 @@ export enum ActionsEnum {
listRoles = "listRoles", listRoles = "listRoles",
updateRole = "updateRole", updateRole = "updateRole",
inviteUser = "inviteUser", inviteUser = "inviteUser",
listInvitations = "listInvitations",
removeInvitation = "removeInvitation",
removeUser = "removeUser", removeUser = "removeUser",
listUsers = "listUsers", listUsers = "listUsers",
listSiteRoles = "listSiteRoles", listSiteRoles = "listSiteRoles",
@ -63,7 +65,7 @@ export enum ActionsEnum {
listResourceRules = "listResourceRules", listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule", updateResourceRule = "updateResourceRule",
listOrgDomains = "listOrgDomains", listOrgDomains = "listOrgDomains",
createNewt = "createNewt", createNewt = "createNewt"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(

View file

@ -8,6 +8,7 @@ export enum OpenAPITags {
Resource = "Resource", Resource = "Resource",
Role = "Role", Role = "Role",
User = "User", User = "User",
Invitation = "Invitation",
Target = "Target", Target = "Target",
Rule = "Rule", Rule = "Rule",
AccessToken = "Access Token" AccessToken = "Access Token"

View file

@ -143,6 +143,27 @@ authenticated.get(
domain.listDomains 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( authenticated.post(
"/org/:orgId/create-invite", "/org/:orgId/create-invite",
verifyOrgAccess, verifyOrgAccess,
@ -567,7 +588,4 @@ authRouter.post(
resource.authWithAccessToken resource.authWithAccessToken
); );
authRouter.post( authRouter.post("/access-token", resource.authWithAccessToken);
"/access-token",
resource.authWithAccessToken
);

View file

@ -7,3 +7,5 @@ export * from "./acceptInvite";
export * from "./getOrgUser"; export * from "./getOrgUser";
export * from "./adminListUsers"; export * from "./adminListUsers";
export * from "./adminRemoveUser"; export * from "./adminRemoveUser";
export * from "./listInvitations";
export * from "./removeInvitation";

View file

@ -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<Awaited<ReturnType<typeof queryInvitations>>>;
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<any> {
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<number>`count(*)` })
.from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`);
// Return response
return response<ListInvitationsResponse>(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")
);
}
}

View file

@ -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<any> {
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")
);
}
}

View file

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