Add regenerate to invitation functionality, see pull request details

This commit is contained in:
grokdesigns 2025-04-09 20:32:21 -07:00
parent 7a55c9ad03
commit d9e6d0c71a
No known key found for this signature in database
GPG key ID: 1084CD111FEE75DD
7 changed files with 453 additions and 68 deletions

View file

@ -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<string, { timestamps: number[] }> = {};
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<number>(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<InviteUserResponse>(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}`
}
);
}

View file

@ -19,7 +19,7 @@ export default function AccessPageHeaderAndNav({
children: hasInvitations
? [
{
title: "Invitations",
title: "Invitations",
href: `/{orgId}/settings/access/invitations`
}
]

View file

@ -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<InvitationRow[]>(i);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
const [selectedInvitation, setSelectedInvitation] =
useState<InvitationRow | null>(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
const columns: ColumnDef<InvitationRow>[] = [
@ -54,6 +56,14 @@ export default function InvitationsTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>Regenerate Invitation</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
@ -154,6 +164,20 @@ export default function InvitationsTable({
string={selectedInvitation?.email ?? ""}
title="Remove Invitation"
/>
<RegenerateInvitationForm
open={isRegenerateModalOpen}
setOpen={setIsRegenerateModalOpen}
invitation={selectedInvitation}
onRegenerate={(updatedInvitation) => {
setInvitations((prev) =>
prev.map((inv) =>
inv.id === updatedInvitation.id
? updatedInvitation
: inv
)
);
}}
/>
<InvitationsDataTable columns={columns} data={invitations} />
</>

View file

@ -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<string | null>(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 (
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setInviteLink(null);
}
}}
>
<DialogContent aria-describedby="regenerate-invite-description">
<DialogHeader>
<DialogTitle>Regenerate Invitation</DialogTitle>
</DialogHeader>
{!inviteLink ? (
<div>
<p>
Are you sure you want to regenerate the invitation
for <b>{invitation?.email}</b>? This will revoke the
previous invitation.
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label htmlFor="send-email">
Send email notification to the user
</label>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
Validity Period
</label>
<Select
value={validHours.toString()}
onValueChange={(value) =>
setValidHours(parseInt(value))
}
>
<SelectTrigger>
<SelectValue placeholder="Select validity period" />
</SelectTrigger>
<SelectContent>
{validForOptions.map((option) => (
<SelectItem
key={option.hours}
value={option.hours.toString()}
>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="space-y-4 max-w-md">
<p>
The invitation has been regenerated. The user must
access the link below to accept the invitation.
</p>
<CopyTextBox text={inviteLink} wrapText={false} />
</div>
)}
<DialogFooter>
{!inviteLink ? (
<>
<Button
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleRegenerate}
loading={loading}
>
Regenerate
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => {
setOpen(false);
setInviteLink(null);
}}
>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

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

View file

@ -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<string | null>(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();
}
}}
>
<CredenzaContent>

View file

@ -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({
</Link>
{item.children && (
<div className="ml-4 space-y-2">
{renderItems(item.children)}{" "}
{/* Recursively render children */}
{item.children.map((child) => (
<div
key={hydrateHref(child.href)}
className="flex items-center space-x-2"
>
<CornerDownRight className="h-4 w-4 text-gray-500" />
<Link
href={hydrateHref(child.href)}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === hydrateHref(child.href) &&
!pathname.includes("create")
? "bg-accent hover:bg-accent dark:bg-border dark:hover:bg-border"
: "hover:bg-transparent hover:underline",
"justify-start",
disabled && "cursor-not-allowed"
)}
onClick={
disabled
? (e) => e.preventDefault()
: undefined
}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
>
{child.icon ? (
<div className="flex items-center space-x-2">
{child.icon}
<span>{child.title}</span>
</div>
) : (
child.title
)}
</Link>
</div>
))}
</div>
)}
</div>