Merge pull request #496 from grokdesigns/add-invitation-management

Add invitation management
This commit is contained in:
Milo Schwartz 2025-04-12 12:12:03 -04:00 committed by GitHub
commit bc8cd5c941
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1156 additions and 119 deletions

View file

@ -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(

View file

@ -8,6 +8,7 @@ export enum OpenAPITags {
Resource = "Resource",
Role = "Role",
User = "User",
Invitation = "Invitation",
Target = "Target",
Rule = "Rule",
AccessToken = "Access Token"

View file

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

View file

@ -7,3 +7,5 @@ export * from "./acceptInvite";
export * from "./getOrgUser";
export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./listInvitations";
export * from "./removeInvitation";

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

@ -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<Awaited<ReturnType<typeof queryInvitations>>>;
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<any> {
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<number>`count(*)` })
.from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`);
return response<ListInvitationsResponse>(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")
);
}
}

View file

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

View file

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

View file

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

View 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} />
</>
);
}

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

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

View file

@ -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>

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

@ -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} />

View file

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