mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-10 23:16:38 +00:00
Merge pull request #496 from grokdesigns/add-invitation-management
Add invitation management
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Users & Roles"
|
||||
description="Invite users and add them to roles to manage access to your
|
||||
organization"
|
||||
description="Invite users and add them to roles to manage access to your organization"
|
||||
/>
|
||||
|
||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
185
src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx
Normal file
185
src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx
Normal file
@@ -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<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>[] = [
|
||||
{
|
||||
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={() => {
|
||||
setIsRegenerateModalOpen(true);
|
||||
setSelectedInvitation(invitation);
|
||||
}}
|
||||
>
|
||||
<span>Regenerate Invitation</span>
|
||||
</DropdownMenuItem>
|
||||
<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"
|
||||
/>
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
84
src/app/[orgId]/settings/access/invitations/page.tsx
Normal file
84
src/app/[orgId]/settings/access/invitations/page.tsx
Normal file
@@ -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<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",
|
||||
roleId: invite.roleId
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
|
||||
<UserProvider user={user!}>
|
||||
<OrgProvider org={org}>
|
||||
<InvitationsTable invitations={invitationRows} />
|
||||
</OrgProvider>
|
||||
</UserProvider>
|
||||
</AccessPageHeaderAndNav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<ListRolesResponse>
|
||||
@@ -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 (
|
||||
<>
|
||||
<AccessPageHeaderAndNav>
|
||||
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
|
||||
<OrgProvider org={org}>
|
||||
<RolesTable roles={roleRows} />
|
||||
</OrgProvider>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<ListUsersResponse>
|
||||
@@ -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 (
|
||||
<>
|
||||
<AccessPageHeaderAndNav>
|
||||
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
|
||||
<UserProvider user={user!}>
|
||||
<OrgProvider org={org}>
|
||||
<UsersTable users={userRows} />
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
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<string>(getSelectedValue());
|
||||
const [selectedValue, setSelectedValue] =
|
||||
React.useState<string>(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) => (
|
||||
<div key={hydrateHref(item.href)}>
|
||||
<Link
|
||||
href={hydrateHref(item.href)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
pathname === hydrateHref(item.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}
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</Link>
|
||||
{item.children && (
|
||||
<div className="ml-4 space-y-2">
|
||||
{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>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="block lg:hidden">
|
||||
@@ -75,14 +168,44 @@ export function SidebarNav({
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem
|
||||
key={hydrateHref(item.href)}
|
||||
value={hydrateHref(item.href)}
|
||||
>
|
||||
{item.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
{items.flatMap((item) => {
|
||||
const topLevelItem = (
|
||||
<SelectItem
|
||||
key={hydrateHref(item.href)}
|
||||
value={hydrateHref(item.href)}
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
const childItems =
|
||||
item.children?.map((child) => (
|
||||
<SelectItem
|
||||
key={hydrateHref(child.href)}
|
||||
value={hydrateHref(child.href)}
|
||||
className="pl-8"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CornerDownRight className="h-4 w-4 text-gray-500" />
|
||||
{child.icon ? (
|
||||
<>
|
||||
{child.icon}
|
||||
<span>{child.title}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{child.title}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)) || [];
|
||||
return [topLevelItem, ...childItems];
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -94,35 +217,7 @@ export function SidebarNav({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={hydrateHref(item.href)}
|
||||
href={hydrateHref(item.href)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
pathname === hydrateHref(item.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}
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{renderItems(items)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user