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..9c747e01 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -143,6 +143,20 @@ 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.post( "/org/:orgId/create-invite", verifyOrgAccess, @@ -567,7 +581,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/inviteUser.ts b/server/routers/user/inviteUser.ts index 589c5b38..eb9cdb61 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,3 +1,4 @@ +import NodeCache from "node-cache"; import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; @@ -16,6 +17,8 @@ import { sendEmail } from "@server/emails"; import SendInviteLink from "@server/emails/templates/SendInviteLink"; import { OpenAPITags, registry } from "@server/openApi"; +const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 }); + const inviteUserParamsSchema = z .object({ orgId: z.string() @@ -30,7 +33,8 @@ const inviteUserBodySchema = z .transform((v) => v.toLowerCase()), roleId: z.number(), validHours: z.number().gt(0).lte(168), - sendEmail: z.boolean().optional() + sendEmail: z.boolean().optional(), + regenerate: z.boolean().optional() }) .strict(); @@ -41,8 +45,6 @@ export type InviteUserResponse = { expiresAt: number; }; -const inviteTracker: Record = {}; - registry.registerPath({ method: "post", path: "/org/{orgId}/create-invite", @@ -92,31 +94,11 @@ export async function inviteUser( email, validHours, roleId, - sendEmail: doEmail + sendEmail: doEmail, + regenerate } = parsedBody.data; - const currentTime = Date.now(); - const oneHourAgo = currentTime - 3600000; - - if (!inviteTracker[email]) { - inviteTracker[email] = { timestamps: [] }; - } - - inviteTracker[email].timestamps = inviteTracker[ - email - ].timestamps.filter((timestamp) => timestamp > oneHourAgo); // TODO: this could cause memory increase over time if the object is never deleted - - if (inviteTracker[email].timestamps.length >= 3) { - return next( - createHttpError( - HttpCode.TOO_MANY_REQUESTS, - "User has already been invited 3 times in the last hour" - ) - ); - } - - inviteTracker[email].timestamps.push(currentTime); - + // Check if the organization exists const org = await db .select() .from(orgs) @@ -128,21 +110,109 @@ export async function inviteUser( ); } + // Check if the user already exists in the `users` table const existingUser = await db .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .where(eq(users.email, email)) + .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) .limit(1); - if (existingUser.length && existingUser[0].userOrgs?.orgId === orgId) { + + if (existingUser.length) { return next( createHttpError( - HttpCode.BAD_REQUEST, - "User is already a member of this organization" + HttpCode.CONFLICT, + "This user is already a member of the organization." ) ); } + // Check if an invitation already exists + const existingInvite = await db + .select() + .from(userInvites) + .where( + and(eq(userInvites.email, email), eq(userInvites.orgId, orgId)) + ) + .limit(1); + + if (existingInvite.length && !regenerate) { + return next( + createHttpError( + HttpCode.CONFLICT, + "An invitation for this user already exists." + ) + ); + } + + if (existingInvite.length) { + const attempts = regenerateTracker.get(email) || 0; + if (attempts >= 3) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "You have exceeded the limit of 3 regenerations per hour." + ) + ); + } + + regenerateTracker.set(email, attempts + 1); + + const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId + const token = generateRandomString( + 32, + alphabet("a-z", "A-Z", "0-9") + ); + const expiresAt = createDate( + new TimeSpan(validHours, "h") + ).getTime(); + const tokenHash = await hashPassword(token); + + await db + .update(userInvites) + .set({ + tokenHash, + expiresAt + }) + .where( + and( + eq(userInvites.email, email), + eq(userInvites.orgId, orgId) + ) + ); + + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`; + + if (doEmail) { + await sendEmail( + SendInviteLink({ + email, + inviteLink, + expiresInDays: (validHours / 24).toString(), + orgName: org[0].name || orgId, + inviterName: req.user?.email + }), + { + to: email, + from: config.getNoReplyEmail(), + subject: "Your invitation has been regenerated" + } + ); + } + + return response(res, { + data: { + inviteLink, + expiresAt + }, + success: true, + error: false, + message: "Invitation regenerated successfully", + status: HttpCode.OK + }); + } + + // Create a new invite if none exists const inviteId = generateRandomString( 10, alphabet("a-z", "A-Z", "0-9") @@ -153,17 +223,6 @@ export async function inviteUser( const tokenHash = await hashPassword(token); await db.transaction(async (trx) => { - // delete any existing invites for this email - await trx - .delete(userInvites) - .where( - and( - eq(userInvites.email, email), - eq(userInvites.orgId, orgId) - ) - ) - .execute(); - await trx.insert(userInvites).values({ inviteId, orgId, @@ -188,7 +247,7 @@ export async function inviteUser( { to: email, from: config.getNoReplyEmail(), - subject: "You're invited to join a Fossorial organization" + subject: `You're invited to join ${org[0].name || orgId}` } ); } diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts new file mode 100644 index 00000000..76e82db5 --- /dev/null +++ b/server/routers/user/listInvitations.ts @@ -0,0 +1,124 @@ +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 { + const parsedQuery = listInvitationsQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listInvitationsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const invitations = await queryInvitations(orgId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(userInvites) + .where(sql`${userInvites.orgId} = ${orgId}`); + + 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..c825df6d --- /dev/null +++ b/server/routers/user/removeInvitation.ts @@ -0,0 +1,69 @@ +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 { + const parsedParams = removeInvitationParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, inviteId } = parsedParams.data; + + const deletedInvitation = await db + .delete(userInvites) + .where( + and( + eq(userInvites.orgId, orgId), + eq(userInvites.inviteId, inviteId) + ) + ) + .returning(); + + if (deletedInvitation.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Invitation with ID ${inviteId} not found in organization ${orgId}` + ) + ); + } + + 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..a9d3b4a1 100644 --- a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx +++ b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx @@ -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 ( <> 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..9618df14 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx @@ -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(i); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = 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 ( + + + + + + { + setIsRegenerateModalOpen(true); + setSelectedInvitation(invitation); + }} + > + Regenerate Invitation + + { + 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" + /> + { + setInvitations((prev) => + prev.map((inv) => + inv.id === updatedInvitation.id + ? updatedInvitation + : inv + ) + ); + }} + /> + + + + ); +} diff --git a/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx b/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx new file mode 100644 index 00000000..ab34579f --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx @@ -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(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 ( + { + setOpen(isOpen); + if (!isOpen) { + setInviteLink(null); + } + }} + > + + + Regenerate Invitation + + {!inviteLink ? ( +
+

+ Are you sure you want to regenerate the invitation + for {invitation?.email}? This will revoke the + previous invitation. +

+
+ + setSendEmail(e as boolean) + } + /> + +
+
+ + +
+
+ ) : ( +
+

+ The invitation has been regenerated. The user must + access the link below to accept the invitation. +

+ +
+ )} + + {!inviteLink ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +} 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..b26ed551 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -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 + >(`/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 ( + <> + + + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index b0915978..2548257c 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -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 @@ -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 ( <> - + diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx index 0285123a..5ebc34cb 100644 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx @@ -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(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(); + } }} > diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 68832f0e..a39a4e3a 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -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 @@ -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 ( <> - + diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 542327bf..db4cb6c1 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -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 { - 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(getSelectedValue()); + const [selectedValue, setSelectedValue] = + React.useState(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) => ( +
+ e.preventDefault() : undefined} + tabIndex={disabled ? -1 : undefined} + aria-disabled={disabled} + > + {item.icon ? ( +
+ {item.icon} + {item.title} +
+ ) : ( + item.title + )} + + {item.children && ( +
+ {item.children.map((child) => ( +
+ + e.preventDefault() + : undefined + } + tabIndex={disabled ? -1 : undefined} + aria-disabled={disabled} + > + {child.icon ? ( +
+ {child.icon} + {child.title} +
+ ) : ( + child.title + )} + +
+ ))} +
+ )} +
+ )); + } + return (
@@ -75,14 +168,44 @@ export function SidebarNav({ - {items.map((item) => ( - - {item.title} - - ))} + {items.flatMap((item) => { + const topLevelItem = ( + + {item.icon ? ( +
+ {item.icon} + {item.title} +
+ ) : ( + item.title + )} +
+ ); + const childItems = + item.children?.map((child) => ( + +
+ + {child.icon ? ( + <> + {child.icon} + {child.title} + + ) : ( + {child.title} + )} +
+
+ )) || []; + return [topLevelItem, ...childItems]; + })}
@@ -94,35 +217,7 @@ export function SidebarNav({ )} {...props} > - {items.map((item) => ( - e.preventDefault() : undefined - } - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} - > - {item.icon ? ( -
- {item.icon} - {item.title} -
- ) : ( - item.title - )} - - ))} + {renderItems(items)}
);