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
7 changed files with 453 additions and 68 deletions

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>