mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-14 06:39:08 +02:00
Merge pull request #496 from grokdesigns/add-invitation-management
Add invitation management
This commit is contained in:
commit
bc8cd5c941
16 changed files with 1156 additions and 119 deletions
|
@ -32,6 +32,8 @@ export enum ActionsEnum {
|
||||||
listRoles = "listRoles",
|
listRoles = "listRoles",
|
||||||
updateRole = "updateRole",
|
updateRole = "updateRole",
|
||||||
inviteUser = "inviteUser",
|
inviteUser = "inviteUser",
|
||||||
|
listInvitations = "listInvitations",
|
||||||
|
removeInvitation = "removeInvitation",
|
||||||
removeUser = "removeUser",
|
removeUser = "removeUser",
|
||||||
listUsers = "listUsers",
|
listUsers = "listUsers",
|
||||||
listSiteRoles = "listSiteRoles",
|
listSiteRoles = "listSiteRoles",
|
||||||
|
@ -63,7 +65,7 @@ export enum ActionsEnum {
|
||||||
listResourceRules = "listResourceRules",
|
listResourceRules = "listResourceRules",
|
||||||
updateResourceRule = "updateResourceRule",
|
updateResourceRule = "updateResourceRule",
|
||||||
listOrgDomains = "listOrgDomains",
|
listOrgDomains = "listOrgDomains",
|
||||||
createNewt = "createNewt",
|
createNewt = "createNewt"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -8,6 +8,7 @@ export enum OpenAPITags {
|
||||||
Resource = "Resource",
|
Resource = "Resource",
|
||||||
Role = "Role",
|
Role = "Role",
|
||||||
User = "User",
|
User = "User",
|
||||||
|
Invitation = "Invitation",
|
||||||
Target = "Target",
|
Target = "Target",
|
||||||
Rule = "Rule",
|
Rule = "Rule",
|
||||||
AccessToken = "Access Token"
|
AccessToken = "Access Token"
|
||||||
|
|
|
@ -143,6 +143,20 @@ authenticated.get(
|
||||||
domain.listDomains
|
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(
|
authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -567,7 +581,4 @@ authRouter.post(
|
||||||
resource.authWithAccessToken
|
resource.authWithAccessToken
|
||||||
);
|
);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post("/access-token", resource.authWithAccessToken);
|
||||||
"/access-token",
|
|
||||||
resource.authWithAccessToken
|
|
||||||
);
|
|
||||||
|
|
|
@ -7,3 +7,5 @@ export * from "./acceptInvite";
|
||||||
export * from "./getOrgUser";
|
export * from "./getOrgUser";
|
||||||
export * from "./adminListUsers";
|
export * from "./adminListUsers";
|
||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
|
export * from "./listInvitations";
|
||||||
|
export * from "./removeInvitation";
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import NodeCache from "node-cache";
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
|
@ -16,6 +17,8 @@ import { sendEmail } from "@server/emails";
|
||||||
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
import SendInviteLink from "@server/emails/templates/SendInviteLink";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 });
|
||||||
|
|
||||||
const inviteUserParamsSchema = z
|
const inviteUserParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
|
@ -30,7 +33,8 @@ const inviteUserBodySchema = z
|
||||||
.transform((v) => v.toLowerCase()),
|
.transform((v) => v.toLowerCase()),
|
||||||
roleId: z.number(),
|
roleId: z.number(),
|
||||||
validHours: z.number().gt(0).lte(168),
|
validHours: z.number().gt(0).lte(168),
|
||||||
sendEmail: z.boolean().optional()
|
sendEmail: z.boolean().optional(),
|
||||||
|
regenerate: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -41,8 +45,6 @@ export type InviteUserResponse = {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteTracker: Record<string, { timestamps: number[] }> = {};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/org/{orgId}/create-invite",
|
path: "/org/{orgId}/create-invite",
|
||||||
|
@ -92,31 +94,11 @@ export async function inviteUser(
|
||||||
email,
|
email,
|
||||||
validHours,
|
validHours,
|
||||||
roleId,
|
roleId,
|
||||||
sendEmail: doEmail
|
sendEmail: doEmail,
|
||||||
|
regenerate
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const currentTime = Date.now();
|
// Check if the organization exists
|
||||||
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);
|
|
||||||
|
|
||||||
const org = await db
|
const org = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
|
@ -128,21 +110,109 @@ export async function inviteUser(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the user already exists in the `users` table
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
.where(eq(users.email, email))
|
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existingUser.length && existingUser[0].userOrgs?.orgId === orgId) {
|
|
||||||
|
if (existingUser.length) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.CONFLICT,
|
||||||
"User is already a member of this organization"
|
"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(
|
const inviteId = generateRandomString(
|
||||||
10,
|
10,
|
||||||
alphabet("a-z", "A-Z", "0-9")
|
alphabet("a-z", "A-Z", "0-9")
|
||||||
|
@ -153,17 +223,6 @@ export async function inviteUser(
|
||||||
const tokenHash = await hashPassword(token);
|
const tokenHash = await hashPassword(token);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
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({
|
await trx.insert(userInvites).values({
|
||||||
inviteId,
|
inviteId,
|
||||||
orgId,
|
orgId,
|
||||||
|
@ -188,7 +247,7 @@ export async function inviteUser(
|
||||||
{
|
{
|
||||||
to: email,
|
to: email,
|
||||||
from: config.getNoReplyEmail(),
|
from: config.getNoReplyEmail(),
|
||||||
subject: "You're invited to join a Fossorial organization"
|
subject: `You're invited to join ${org[0].name || orgId}`
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
124
server/routers/user/listInvitations.ts
Normal file
124
server/routers/user/listInvitations.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
69
server/routers/user/removeInvitation.ts
Normal file
69
server/routers/user/removeInvitation.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,28 +5,37 @@ import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||||
|
|
||||||
type AccessPageHeaderAndNavProps = {
|
type AccessPageHeaderAndNavProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
hasInvitations: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AccessPageHeaderAndNav({
|
export default function AccessPageHeaderAndNav({
|
||||||
children,
|
children,
|
||||||
|
hasInvitations
|
||||||
}: AccessPageHeaderAndNavProps) {
|
}: AccessPageHeaderAndNavProps) {
|
||||||
const sidebarNavItems = [
|
const sidebarNavItems = [
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
href: `/{orgId}/settings/access/users`,
|
href: `/{orgId}/settings/access/users`,
|
||||||
|
children: hasInvitations
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "Invitations",
|
||||||
|
href: `/{orgId}/settings/access/invitations`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Roles",
|
title: "Roles",
|
||||||
href: `/{orgId}/settings/access/roles`,
|
href: `/{orgId}/settings/access/roles`
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Manage Users & Roles"
|
title="Manage Users & Roles"
|
||||||
description="Invite users and add them to roles to manage access to your
|
description="Invite users and add them to roles to manage access to your organization"
|
||||||
organization"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
<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;
|
const params = await props.params;
|
||||||
|
|
||||||
let roles: ListRolesResponse["roles"] = [];
|
let roles: ListRolesResponse["roles"] = [];
|
||||||
|
let hasInvitations = false;
|
||||||
|
|
||||||
const res = await internal
|
const res = await internal
|
||||||
.get<
|
.get<
|
||||||
AxiosResponse<ListRolesResponse>
|
AxiosResponse<ListRolesResponse>
|
||||||
|
@ -29,6 +31,21 @@ export default async function RolesPage(props: RolesPageProps) {
|
||||||
roles = res.data.data.roles;
|
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;
|
let org: GetOrgResponse | null = null;
|
||||||
const getOrg = cache(async () =>
|
const getOrg = cache(async () =>
|
||||||
internal
|
internal
|
||||||
|
@ -47,7 +64,7 @@ export default async function RolesPage(props: RolesPageProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AccessPageHeaderAndNav>
|
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<RolesTable roles={roleRows} />
|
<RolesTable roles={roleRows} />
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
|
|
|
@ -55,17 +55,13 @@ const formSchema = z.object({
|
||||||
|
|
||||||
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||||
|
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||||
|
|
||||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||||
|
|
||||||
const validFor = [
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
|
@ -111,10 +116,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
setRoles(res.data.data.roles);
|
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
|
} as InviteUserBody
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
if (e.response?.status === 409) {
|
||||||
variant: "destructive",
|
toast({
|
||||||
title: "Failed to invite user",
|
variant: "destructive",
|
||||||
description: formatAxiosError(
|
title: "User Already Exists",
|
||||||
e,
|
description:
|
||||||
"An error occurred while inviting the user"
|
"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) {
|
if (res && res.status === 200) {
|
||||||
|
@ -165,10 +175,12 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
setOpen(val);
|
setOpen(val);
|
||||||
setInviteLink(null);
|
if (!val) {
|
||||||
setLoading(false);
|
setInviteLink(null);
|
||||||
setExpiresInDays(1);
|
setLoading(false);
|
||||||
form.reset();
|
setExpiresInDays(1);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
|
|
|
@ -23,6 +23,8 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
let users: ListUsersResponse["users"] = [];
|
let users: ListUsersResponse["users"] = [];
|
||||||
|
let hasInvitations = false;
|
||||||
|
|
||||||
const res = await internal
|
const res = await internal
|
||||||
.get<
|
.get<
|
||||||
AxiosResponse<ListUsersResponse>
|
AxiosResponse<ListUsersResponse>
|
||||||
|
@ -33,6 +35,21 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
users = res.data.data.users;
|
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;
|
let org: GetOrgResponse | null = null;
|
||||||
const getOrg = cache(async () =>
|
const getOrg = cache(async () =>
|
||||||
internal
|
internal
|
||||||
|
@ -61,7 +78,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AccessPageHeaderAndNav>
|
<AccessPageHeaderAndNav hasInvitations={hasInvitations}>
|
||||||
<UserProvider user={user!}>
|
<UserProvider user={user!}>
|
||||||
<OrgProvider org={org}>
|
<OrgProvider org={org}>
|
||||||
<UsersTable users={userRows} />
|
<UsersTable users={userRows} />
|
||||||
|
|
|
@ -10,15 +10,19 @@ import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} 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> {
|
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
items: {
|
items: SidebarNavItem[];
|
||||||
href: string;
|
|
||||||
title: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}[];
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,7 +39,8 @@ export function SidebarNav({
|
||||||
const resourceId = params.resourceId as string;
|
const resourceId = params.resourceId as string;
|
||||||
const userId = params.userId as string;
|
const userId = params.userId as string;
|
||||||
|
|
||||||
const [selectedValue, setSelectedValue] = React.useState<string>(getSelectedValue());
|
const [selectedValue, setSelectedValue] =
|
||||||
|
React.useState<string>(getSelectedValue());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedValue(getSelectedValue());
|
setSelectedValue(getSelectedValue());
|
||||||
|
@ -50,8 +55,25 @@ export function SidebarNav({
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSelectedValue() {
|
function getSelectedValue() {
|
||||||
const item = items.find((item) => hydrateHref(item.href) === pathname);
|
let foundHref = "";
|
||||||
return hydrateHref(item?.href || "");
|
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 {
|
function hydrateHref(val: string): string {
|
||||||
|
@ -62,6 +84,77 @@ export function SidebarNav({
|
||||||
.replace("{userId}", userId);
|
.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="block lg:hidden">
|
<div className="block lg:hidden">
|
||||||
|
@ -75,14 +168,44 @@ export function SidebarNav({
|
||||||
<SelectValue placeholder="Select an option" />
|
<SelectValue placeholder="Select an option" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{items.map((item) => (
|
{items.flatMap((item) => {
|
||||||
<SelectItem
|
const topLevelItem = (
|
||||||
key={hydrateHref(item.href)}
|
<SelectItem
|
||||||
value={hydrateHref(item.href)}
|
key={hydrateHref(item.href)}
|
||||||
>
|
value={hydrateHref(item.href)}
|
||||||
{item.title}
|
>
|
||||||
</SelectItem>
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -94,35 +217,7 @@ export function SidebarNav({
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{renderItems(items)}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue