From d9e6d0c71aeb2e63e103581455475795eeb12535 Mon Sep 17 00:00:00 2001 From: grokdesigns Date: Wed, 9 Apr 2025 20:32:21 -0700 Subject: [PATCH] Add regenerate to invitation functionality, see pull request details --- server/routers/user/inviteUser.ts | 143 +++++++--- .../access/AccessPageHeaderAndNav.tsx | 2 +- .../access/invitations/InvitationsTable.tsx | 26 +- .../invitations/RegenerateInvitationForm.tsx | 254 ++++++++++++++++++ .../settings/access/invitations/page.tsx | 5 +- .../settings/access/users/InviteUserForm.tsx | 52 ++-- src/components/SidebarNav.tsx | 39 ++- 7 files changed, 453 insertions(+), 68 deletions(-) create mode 100644 src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx 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/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx index 84cb659d..a9d3b4a1 100644 --- a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx +++ b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx @@ -19,7 +19,7 @@ export default function AccessPageHeaderAndNav({ children: hasInvitations ? [ { - title: "• Invitations", + title: "Invitations", href: `/{orgId}/settings/access/invitations` } ] diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx index 74f9aef6..9618df14 100644 --- a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx +++ b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx @@ -12,6 +12,7 @@ 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"; @@ -22,6 +23,7 @@ export type InvitationRow = { email: string; expiresAt: string; role: string; + roleId: number; }; type InvitationsTableProps = { @@ -33,11 +35,11 @@ export default function InvitationsTable({ }: 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[] = [ @@ -54,6 +56,14 @@ export default function InvitationsTable({ + { + setIsRegenerateModalOpen(true); + setSelectedInvitation(invitation); + }} + > + Regenerate Invitation + { setIsDeleteModalOpen(true); @@ -154,6 +164,20 @@ export default function InvitationsTable({ 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 index 55952773..b26ed551 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -25,7 +25,7 @@ export default async function InvitationsPage(props: InvitationsPageProps) { inviteId: string; email: string; expiresAt: string; - roleId: string; + roleId: number; roleName?: string; }[] = []; let hasInvitations = false; @@ -65,7 +65,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) { id: invite.inviteId, email: invite.email, expiresAt: new Date(Number(invite.expiresAt)).toISOString(), - role: invite.roleName || "Unknown Role" + role: invite.roleName || "Unknown Role", + roleId: invite.roleId }; }); 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/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 83824243..024be4c6 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue } from "@/components/ui/select"; +import { CornerDownRight } from "lucide-react"; interface SidebarNavItem { href: string; @@ -95,8 +96,42 @@ export function SidebarNav({ {item.children && (
- {renderItems(item.children)}{" "} - {/* Recursively render children */} + {item.children.map((child) => ( +
+ + e.preventDefault() + : undefined + } + tabIndex={disabled ? -1 : undefined} + aria-disabled={disabled} + > + {child.icon ? ( +
+ {child.icon} + {child.title} +
+ ) : ( + child.title + )} + +
+ ))}
)}