feat(rbac): added new permission check for workspace in frontend

This commit is contained in:
Akhil Mohan
2023-09-08 21:22:36 +05:30
parent 549121f44e
commit 6671699867
48 changed files with 3446 additions and 2804 deletions
@@ -4,7 +4,7 @@ import { IUser, Key, Membership, MembershipOrg, User } from "../../models";
import { EventType } from "../../ee/models"; import { EventType } from "../../ee/models";
import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership"; import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership";
import { sendMail } from "../../helpers/nodemailer"; import { sendMail } from "../../helpers/nodemailer";
import { ACCEPTED, ADMIN, MEMBER } from "../../variables"; import { ACCEPTED, ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables";
import { getSiteURL } from "../../config"; import { getSiteURL } from "../../config";
import { EEAuditLogService } from "../../ee/services"; import { EEAuditLogService } from "../../ee/services";
import { validateRequest } from "../../helpers/validation"; import { validateRequest } from "../../helpers/validation";
@@ -15,6 +15,8 @@ import {
getUserProjectPermissions getUserProjectPermissions
} from "../../services/ProjectRoleService"; } from "../../services/ProjectRoleService";
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import Role from "../../models/role";
import { BadRequestError } from "../../utils/errors";
/** /**
* Check that user is a member of workspace with id [workspaceId] * Check that user is a member of workspace with id [workspaceId]
@@ -107,10 +109,6 @@ export const changeMembershipRole = async (req: Request, res: Response) => {
params: { membershipId } params: { membershipId }
} = await validateRequest(reqValidator.ChangeMembershipRoleV1, req); } = await validateRequest(reqValidator.ChangeMembershipRoleV1, req);
if (![ADMIN, MEMBER].includes(role)) {
throw new Error("Failed to validate role");
}
// validate target membership // validate target membership
const membershipToChangeRole = await Membership.findById(membershipId).populate<{ user: IUser }>( const membershipToChangeRole = await Membership.findById(membershipId).populate<{ user: IUser }>(
"user" "user"
@@ -129,9 +127,32 @@ export const changeMembershipRole = async (req: Request, res: Response) => {
ProjectPermissionSub.Member ProjectPermissionSub.Member
); );
const oldRole = membershipToChangeRole.role; const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role);
membershipToChangeRole.role = role; if (isCustomRole) {
await membershipToChangeRole.save(); const wsRole = await Role.findOne({
slug: role,
isOrgRole: false,
workspace: membershipToChangeRole.workspace
});
if (!wsRole) throw BadRequestError({ message: "Role not found" });
const membership = await Membership.findByIdAndUpdate(membershipId, {
role: CUSTOM,
customRole: wsRole
});
return res.status(200).send({
membership
});
}
const membership = await Membership.findByIdAndUpdate(
membershipId,
{
role
},
{
new: true
}
);
await EEAuditLogService.createAuditLog( await EEAuditLogService.createAuditLog(
req.authData, req.authData,
@@ -140,8 +161,8 @@ export const changeMembershipRole = async (req: Request, res: Response) => {
metadata: { metadata: {
userId: membershipToChangeRole.user._id.toString(), userId: membershipToChangeRole.user._id.toString(),
email: membershipToChangeRole.user.email, email: membershipToChangeRole.user.email,
oldRole, oldRole: membershipToChangeRole.role,
newRole: membershipToChangeRole.role newRole: role
} }
}, },
{ {
@@ -150,7 +171,7 @@ export const changeMembershipRole = async (req: Request, res: Response) => {
); );
return res.status(200).send({ return res.status(200).send({
membership: membershipToChangeRole membership
}); });
}; };
@@ -25,7 +25,6 @@ import {
userHasWriteOnlyAbility userHasWriteOnlyAbility
} from "../../ee/helpers/checkMembershipPermissions"; } from "../../ee/helpers/checkMembershipPermissions";
import _ from "lodash"; import _ from "lodash";
import { BatchSecret } from "../../types/secret";
import { import {
getFolderByPath, getFolderByPath,
getFolderIdFromServiceToken, getFolderIdFromServiceToken,
@@ -56,8 +55,8 @@ export const batchSecrets = async (req: Request, res: Response) => {
body: { secretPath, folderId } body: { secretPath, folderId }
} = validatedData; } = validatedData;
const createSecrets: BatchSecret[] = []; const createSecrets: any[] = [];
const updateSecrets: BatchSecret[] = []; const updateSecrets: any[] = [];
const deleteSecrets: { _id: Types.ObjectId; secretName: string }[] = []; const deleteSecrets: { _id: Types.ObjectId; secretName: string }[] = [];
const actions: IAction[] = []; const actions: IAction[] = [];
@@ -111,7 +110,7 @@ export const batchSecrets = async (req: Request, res: Response) => {
version: 1, version: 1,
user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, user: request.secret.type === SECRET_PERSONAL ? req.user : undefined,
environment, environment,
workspace: new Types.ObjectId(workspaceId), workspace: workspaceId,
folder: folderId, folder: folderId,
secretBlindIndex, secretBlindIndex,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
@@ -126,7 +125,7 @@ export const batchSecrets = async (req: Request, res: Response) => {
updateSecrets.push({ updateSecrets.push({
...request.secret, ...request.secret,
_id: new Types.ObjectId(request.secret._id), _id: request.secret._id,
secretBlindIndex, secretBlindIndex,
folder: folderId, folder: folderId,
algorithm: ALGORITHM_AES_256_GCM, algorithm: ALGORITHM_AES_256_GCM,
@@ -145,7 +144,7 @@ export const batchSecrets = async (req: Request, res: Response) => {
// handle create secrets // handle create secrets
let createdSecrets: ISecret[] = []; let createdSecrets: ISecret[] = [];
if (createSecrets.length > 0) { if (createSecrets.length > 0) {
createdSecrets = await Secret.insertMany(createSecrets); createdSecrets = (await Secret.insertMany(createSecrets)) as any;
// (EE) add secret versions for new secrets // (EE) add secret versions for new secrets
await EESecretService.addSecretVersions({ await EESecretService.addSecretVersions({
secretVersions: createdSecrets.map((n: any) => { secretVersions: createdSecrets.map((n: any) => {
+68 -67
View File
@@ -1,7 +1,4 @@
import { import { IUser, User } from "../models";
IUser,
User,
} from "../models";
import { sendMail } from "./nodemailer"; import { sendMail } from "./nodemailer";
/** /**
@@ -12,10 +9,10 @@ import { sendMail } from "./nodemailer";
*/ */
export const setupAccount = async ({ email }: { email: string }) => { export const setupAccount = async ({ email }: { email: string }) => {
const user = await new User({ const user = await new User({
email, email
}).save(); }).save();
return user; return user;
}; };
/** /**
@@ -37,36 +34,36 @@ export const setupAccount = async ({ email }: { email: string }) => {
* @returns {Object} user - the completed user * @returns {Object} user - the completed user
*/ */
export const completeAccount = async ({ export const completeAccount = async ({
userId, userId,
firstName, firstName,
lastName, lastName,
encryptionVersion, encryptionVersion,
protectedKey, protectedKey,
protectedKeyIV, protectedKeyIV,
protectedKeyTag, protectedKeyTag,
publicKey, publicKey,
encryptedPrivateKey, encryptedPrivateKey,
encryptedPrivateKeyIV, encryptedPrivateKeyIV,
encryptedPrivateKeyTag, encryptedPrivateKeyTag,
salt, salt,
verifier, verifier
}: { }: {
userId: string; userId: string;
firstName: string; firstName: string;
lastName: string; lastName?: string;
encryptionVersion: number; encryptionVersion: number;
protectedKey: string; protectedKey: string;
protectedKeyIV: string; protectedKeyIV: string;
protectedKeyTag: string; protectedKeyTag: string;
publicKey: string; publicKey: string;
encryptedPrivateKey: string; encryptedPrivateKey: string;
encryptedPrivateKeyIV: string; encryptedPrivateKeyIV: string;
encryptedPrivateKeyTag: string; encryptedPrivateKeyTag: string;
salt: string; salt: string;
verifier: string; verifier: string;
}) => { }) => {
const options = { const options = {
new: true, new: true
}; };
const user = await User.findByIdAndUpdate( const user = await User.findByIdAndUpdate(
userId, userId,
@@ -82,12 +79,12 @@ export const completeAccount = async ({
iv: encryptedPrivateKeyIV, iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag, tag: encryptedPrivateKeyTag,
salt, salt,
verifier, verifier
}, },
options options
); );
return user; return user;
}; };
/** /**
@@ -98,38 +95,42 @@ export const completeAccount = async ({
* @param {String} obj.userAgent - login user-agent * @param {String} obj.userAgent - login user-agent
*/ */
export const checkUserDevice = async ({ export const checkUserDevice = async ({
user, user,
ip, ip,
userAgent, userAgent
}: { }: {
user: IUser; user: IUser;
ip: string; ip: string;
userAgent: string; userAgent: string;
}) => { }) => {
const isDeviceSeen = user.devices.some((device) => device.ip === ip && device.userAgent === userAgent); const isDeviceSeen = user.devices.some(
(device) => device.ip === ip && device.userAgent === userAgent
if (!isDeviceSeen) { );
// case: unseen login ip detected for user
// -> notify user about the sign-in from new ip
user.devices = user.devices.concat([{
ip: String(ip),
userAgent,
}]);
await user.save();
// send MFA code [code] to [email] if (!isDeviceSeen) {
await sendMail({ // case: unseen login ip detected for user
template: "newDevice.handlebars", // -> notify user about the sign-in from new ip
subjectLine: "Successful login from new device",
recipients: [user.email], user.devices = user.devices.concat([
substitutions: { {
email: user.email, ip: String(ip),
timestamp: new Date().toString(), userAgent
ip, }
userAgent, ]);
},
}); await user.save();
}
} // send MFA code [code] to [email]
await sendMail({
template: "newDevice.handlebars",
subjectLine: "Successful login from new device",
recipients: [user.email],
substitutions: {
email: user.email,
timestamp: new Date().toString(),
ip,
userAgent
}
});
}
};
+1 -8
View File
@@ -1,7 +1,6 @@
import express from "express"; import express from "express";
const router = express.Router(); const router = express.Router();
import { body, param } from "express-validator"; import { requireAuth } from "../../middleware";
import { requireAuth, validateRequest } from "../../middleware";
import { membershipController } from "../../controllers/v1"; import { membershipController } from "../../controllers/v1";
import { AuthMode } from "../../variables"; import { AuthMode } from "../../variables";
@@ -14,8 +13,6 @@ router.get(
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT] acceptedAuthModes: [AuthMode.JWT]
}), }),
param("workspaceId").exists().trim(),
validateRequest,
membershipController.validateMembership membershipController.validateMembership
); );
@@ -25,8 +22,6 @@ router.delete(
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT] acceptedAuthModes: [AuthMode.JWT]
}), }),
param("membershipId").exists().trim(),
validateRequest,
membershipController.deleteMembership membershipController.deleteMembership
); );
@@ -36,8 +31,6 @@ router.post(
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT] acceptedAuthModes: [AuthMode.JWT]
}), }),
body("role").exists().trim(),
validateRequest,
membershipController.changeMembershipRole membershipController.changeMembershipRole
); );
+4 -4
View File
@@ -34,7 +34,7 @@ export enum ProjectPermissionSub {
Folders = "folders" Folders = "folders"
} }
type GenericFields = { type SubjectFields = {
environment: string; environment: string;
secretPath?: string; secretPath?: string;
}; };
@@ -42,17 +42,17 @@ type GenericFields = {
export type ProjectPermissionSet = export type ProjectPermissionSet =
| [ | [
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub.Secrets | (ForcedSubject<ProjectPermissionSub.Secrets> & GenericFields) ProjectPermissionSub.Secrets | (ForcedSubject<ProjectPermissionSub.Secrets> & SubjectFields)
] ]
| [ | [
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub.Folders | (ForcedSubject<ProjectPermissionSub.Folders> & GenericFields) ProjectPermissionSub.Folders | (ForcedSubject<ProjectPermissionSub.Folders> & SubjectFields)
] ]
| [ | [
ProjectPermissionActions, ProjectPermissionActions,
( (
| ProjectPermissionSub.SecretImports | ProjectPermissionSub.SecretImports
| (ForcedSubject<ProjectPermissionSub.SecretImports> & GenericFields) | (ForcedSubject<ProjectPermissionSub.SecretImports> & SubjectFields)
) )
] ]
| [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Role]
+8 -2
View File
@@ -28,7 +28,13 @@ export interface BatchSecretRequest {
} }
export interface BatchSecret { export interface BatchSecret {
_id: string; version?: number;
_id?: string;
user?: string;
environment: string;
workspace?: string;
algorithm?: string;
keyEncoding?: string;
type: "shared" | "personal"; type: "shared" | "personal";
secretName: string; secretName: string;
secretBlindIndex: string; secretBlindIndex: string;
@@ -42,5 +48,5 @@ export interface BatchSecret {
secretCommentIV: string; secretCommentIV: string;
secretCommentTag: string; secretCommentTag: string;
tags: string[]; tags: string[];
folder: string folder: string;
} }
+1 -1
View File
@@ -117,7 +117,7 @@ export const CompletedAccountSignupV3 = z.object({
body: z.object({ body: z.object({
email: z.string().email().trim(), email: z.string().email().trim(),
firstName: z.string().trim(), firstName: z.string().trim(),
lastName: z.string().trim().optional().nullish(), lastName: z.string().trim().optional(),
protectedKey: z.string().trim(), protectedKey: z.string().trim(),
protectedKeyIV: z.string().trim(), protectedKeyIV: z.string().trim(),
protectedKeyTag: z.string().trim(), protectedKeyTag: z.string().trim(),
+10 -8
View File
@@ -115,8 +115,8 @@ export const GetSecretVersionsV1 = z.object({
secretId: z.string().trim() secretId: z.string().trim()
}), }),
query: z.object({ query: z.object({
offset: z.number(), offset: z.coerce.number(),
limit: z.number() limit: z.coerce.number()
}) })
}); });
@@ -175,17 +175,19 @@ const batchUpdateRequestV2 = z.object({
secretCommentCiphertext: z.string().trim().optional(), secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(),
tags: z.object({ tags: z
_id: z.string().trim(), .object({
name: z.string().trim(), _id: z.string().trim(),
slug: z.string().trim() name: z.string().trim(),
}) slug: z.string().trim()
})
.array()
}); });
export const BatchSecretsV2 = z.object({ export const BatchSecretsV2 = z.object({
body: z.object({ body: z.object({
workspaceId: z.string().trim(), workspaceId: z.string().trim(),
folderId: z.string().trim(), folderId: z.string().trim().default("root"),
environment: z.string().trim(), environment: z.string().trim(),
secretPath: z.string().trim().optional(), secretPath: z.string().trim().optional(),
requests: z requests: z
+6 -6
View File
@@ -130,8 +130,8 @@ export const GetWorkspaceSecretSnapshotsV1 = z.object({
query: z.object({ query: z.object({
environment: z.string().trim(), environment: z.string().trim(),
folderId: z.string().trim().default("root"), folderId: z.string().trim().default("root"),
offset: z.number(), offset: z.coerce.number(),
limit: z.number() limit: z.coerce.number()
}) })
}); });
@@ -161,8 +161,8 @@ export const GetWorkspaceLogsV1 = z.object({
workspaceId: z.string().trim() workspaceId: z.string().trim()
}), }),
query: z.object({ query: z.object({
offset: z.number(), offset: z.coerce.number(),
limit: z.number(), limit: z.coerce.number(),
sortBy: z.string().trim().optional(), sortBy: z.string().trim().optional(),
userId: z.string().trim().optional(), userId: z.string().trim().optional(),
actionNames: z.string().trim().optional() actionNames: z.string().trim().optional()
@@ -178,8 +178,8 @@ export const GetWorkspaceAuditLogsV1 = z.object({
userAgentType: z.nativeEnum(UserAgentType).nullable().optional(), userAgentType: z.nativeEnum(UserAgentType).nullable().optional(),
startDate: z.string().datetime().nullable().optional(), startDate: z.string().datetime().nullable().optional(),
endDate: z.string().datetime().nullable().optional(), endDate: z.string().datetime().nullable().optional(),
offset: z.number(), offset: z.coerce.number(),
limit: z.number(), limit: z.coerce.number(),
actor: z.string().nullish().optional() actor: z.string().nullish().optional()
}) })
}); });
@@ -16,6 +16,15 @@ export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => {
const workspaceId = currentWorkspace?._id || ""; const workspaceId = currentWorkspace?._id || "";
const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId }); const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId });
console.log(workspaceId);
if (!permission && currentWorkspace) {
return (
<div className="flex items-center justify-center w-screen h-screen bg-bunker-800">
Failed to load user permissions
</div>
);
}
if (isLoading && workspaceId) { if (isLoading && workspaceId) {
return ( return (
<div className="flex items-center justify-center w-screen h-screen bg-bunker-800"> <div className="flex items-center justify-center w-screen h-screen bg-bunker-800">
@@ -29,20 +38,8 @@ export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => {
); );
} }
if (!permission && currentWorkspace) {
return (
<div className="flex items-center justify-center w-screen h-screen bg-bunker-800">
Failed to load user permissions
</div>
);
}
if (!permission) {
return <>children</>;
}
return ( return (
<ProjectPermissionContext.Provider value={permission}> <ProjectPermissionContext.Provider value={permission!}>
{children} {children}
</ProjectPermissionContext.Provider> </ProjectPermissionContext.Provider>
); );
@@ -1,3 +1,3 @@
export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext";
export type { ProjectPermissionSet, TProjectPermission } from "./types"; export type { ProjectPermissionSet, TProjectPermission } from "./types";
export { ProjectGeneralPermissionActions, ProjectPermissionSubjects } from "./types"; export { ProjectPermissionActions,ProjectPermissionSub } from "./types";
@@ -1,13 +1,13 @@
import { MongoAbility } from "@casl/ability"; import { ForcedSubject, MongoAbility } from "@casl/ability";
export enum ProjectGeneralPermissionActions { export enum ProjectPermissionActions {
Read = "read", Read = "read",
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete" Delete = "delete"
} }
export enum ProjectPermissionSubjects { export enum ProjectPermissionSub {
Role = "role", Role = "role",
Member = "member", Member = "member",
Settings = "settings", Settings = "settings",
@@ -21,24 +21,44 @@ export enum ProjectPermissionSubjects {
Workspace = "workspace", Workspace = "workspace",
Secrets = "secrets", Secrets = "secrets",
SecretImports = "secret-imports", SecretImports = "secret-imports",
SecretRollback = "secret-rollback",
Folders = "folders" Folders = "folders"
} }
type SubjectFields = {
environment: string;
secretPath?: string;
};
export type ProjectPermissionSet = export type ProjectPermissionSet =
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Secrets] | [
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Folders] ProjectPermissionActions,
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.SecretImports] ProjectPermissionSub.Secrets | (ForcedSubject<ProjectPermissionSub.Secrets> & SubjectFields)
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Role] ]
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Tags] | [
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Member] ProjectPermissionActions,
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Integrations] ProjectPermissionSub.Folders | (ForcedSubject<ProjectPermissionSub.Folders> & SubjectFields)
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Webhooks] ]
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.AuditLogs] | [
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Environments] ProjectPermissionActions,
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.IpAllowList] (
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Settings] | ProjectPermissionSub.SecretImports
| [ProjectGeneralPermissionActions, ProjectPermissionSubjects.ServiceTokens] | (ForcedSubject<ProjectPermissionSub.SecretImports> & SubjectFields)
| [ProjectGeneralPermissionActions.Delete, ProjectPermissionSubjects.Workspace] )
| [ProjectGeneralPermissionActions.Edit, ProjectPermissionSubjects.Workspace]; ]
| [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member]
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
| [ProjectPermissionActions, ProjectPermissionSub.AuditLogs]
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback];
export type TProjectPermission = MongoAbility<ProjectPermissionSet>; export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
+3 -2
View File
@@ -7,10 +7,11 @@ export {
OrgPermissionSubjects, OrgPermissionSubjects,
useOrgPermission useOrgPermission
} from "./OrgPermissionContext"; } from "./OrgPermissionContext";
export type { TProjectPermission } from "./ProjectPermissionContext";
export { export {
ProjectGeneralPermissionActions, ProjectPermissionActions,
ProjectPermissionProvider, ProjectPermissionProvider,
ProjectPermissionSubjects, ProjectPermissionSub,
useProjectPermission useProjectPermission
} from "./ProjectPermissionContext"; } from "./ProjectPermissionContext";
export { SubscriptionProvider, useSubscription } from "./SubscriptionContext"; export { SubscriptionProvider, useSubscription } from "./SubscriptionContext";
+1
View File
@@ -1 +1,2 @@
export { withPermission } from "./withPermission"; export { withPermission } from "./withPermission";
export { withProjectPermission } from "./withProjectPermission";
@@ -0,0 +1 @@
export { withProjectPermission } from "./withProjectPermission";
@@ -0,0 +1,62 @@
import { ComponentType } from "react";
import { Abilities, AbilityTuple, Generics, SubjectType } from "@casl/ability";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { TProjectPermission, useProjectPermission } from "@app/context";
type Props<T extends Abilities> = (T extends AbilityTuple
? {
action: T[0];
subject: Extract<T[1], SubjectType>;
}
: {
action: string;
subject: string;
}) & { className?: string; containerClassName?: string };
export const withProjectPermission = <T extends {}, J extends TProjectPermission>(
Component: ComponentType<T>,
{ action, subject, className, containerClassName }: Props<Generics<J>["abilities"]>
) => {
const HOC = (hocProps: T) => {
const permission = useProjectPermission();
// akhilmhdh: Set as any due to casl/react ts type bug
// REASON: casl due to its type checking can't seem to union even if union intersection is applied
if (permission.cannot(action as any, subject)) {
return (
<div
className={twMerge(
"container h-full mx-auto flex justify-center items-center",
containerClassName
)}
>
<div
className={twMerge(
"rounded-md bg-mineshaft-800 text-bunker-300 p-16 flex space-x-12 items-end",
className
)}
>
<div>
<FontAwesomeIcon icon={faLock} size="6x" />
</div>
<div>
<div className="text-4xl font-medium mb-2">Permission Denied</div>
<div className="text-sm">
You do not have permission to this page. <br /> Kindly contact your organization
administrator
</div>
</div>
</div>
</div>
);
}
return <Component {...hocProps} />;
};
HOC.displayName = "WithProjectPermission";
return HOC;
};
+122 -115
View File
@@ -6,8 +6,9 @@ import { useRouter } from "next/router";
import Button from "@app/components/basic/buttons/Button"; import Button from "@app/components/basic/buttons/Button";
import EventFilter from "@app/components/basic/EventFilter"; import EventFilter from "@app/components/basic/EventFilter";
import { UpgradePlanModal } from "@app/components/v2"; import { UpgradePlanModal } from "@app/components/v2";
import { useSubscription } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import ActivitySideBar from "@app/ee/components/ActivitySideBar"; import ActivitySideBar from "@app/ee/components/ActivitySideBar";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import getProjectLogs from "../../../../ee/api/secrets/GetProjectLogs"; import getProjectLogs from "../../../../ee/api/secrets/GetProjectLogs";
@@ -23,10 +24,10 @@ interface LogData {
}; };
serviceAccount?: { serviceAccount?: {
string: string; string: string;
}, };
serviceTokenData?: { serviceTokenData?: {
name: string; name: string;
} };
actions: { actions: {
_id: string; _id: string;
name: string; name: string;
@@ -60,67 +61,33 @@ interface LogDataPoint {
/** /**
* This is the tab that includes all of the user activity logs * This is the tab that includes all of the user activity logs
*/ */
export default function Activity() { const Activity = withProjectPermission(
const router = useRouter(); () => {
const [eventChosen, setEventChosen] = useState(""); const router = useRouter();
const [logsData, setLogsData] = useState<LogDataPoint[]>([]); const [eventChosen, setEventChosen] = useState("");
const [isLoading, setIsLoading] = useState(false); const [logsData, setLogsData] = useState<LogDataPoint[]>([]);
const [currentOffset, setCurrentOffset] = useState(0); const [isLoading, setIsLoading] = useState(false);
const currentLimit = 10; const [currentOffset, setCurrentOffset] = useState(0);
const [currentSidebarAction, toggleSidebar] = useState<string>(); const currentLimit = 10;
const { t } = useTranslation(); const [currentSidebarAction, toggleSidebar] = useState<string>();
const { subscription } = useSubscription(); const { t } = useTranslation();
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { subscription } = useSubscription();
"upgradePlan" const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["upgradePlan"] as const);
] as const);
// this use effect updates the data in case of a new filter being added // this use effect updates the data in case of a new filter being added
useEffect(() => { useEffect(() => {
setCurrentOffset(0); setCurrentOffset(0);
const getLogData = async () => { const getLogData = async () => {
setIsLoading(true); setIsLoading(true);
const tempLogsData = await getProjectLogs({ const tempLogsData = await getProjectLogs({
workspaceId: String(router.query.id), workspaceId: String(router.query.id),
offset: 0, offset: 0,
limit: currentLimit, limit: currentLimit,
userId: "", userId: "",
actionNames: eventChosen actionNames: eventChosen
}); });
setLogsData( setLogsData(
tempLogsData.map((log: LogData) => ({
_id: log._id,
channel: log.channel,
createdAt: log.createdAt,
ipAddress: log.ipAddress,
user: log?.user?.email,
serviceAccount: log?.serviceAccount,
serviceTokenData: log?.serviceTokenData,
payload: log.actions.map((action) => ({
_id: action._id,
name: action.name,
secretVersions: action.payload.secretVersions
}))
}))
);
setIsLoading(false);
};
getLogData();
}, [eventChosen]);
// this use effect adds more data in case 'View More' button is clicked
useEffect(() => {
const getLogData = async () => {
setIsLoading(true);
const tempLogsData = await getProjectLogs({
workspaceId: String(router.query.id),
offset: currentOffset,
limit: currentLimit,
userId: "",
actionNames: eventChosen
});
setLogsData(
logsData.concat(
tempLogsData.map((log: LogData) => ({ tempLogsData.map((log: LogData) => ({
_id: log._id, _id: log._id,
channel: log.channel, channel: log.channel,
@@ -135,63 +102,103 @@ export default function Activity() {
secretVersions: action.payload.secretVersions secretVersions: action.payload.secretVersions
})) }))
})) }))
) );
); setIsLoading(false);
setIsLoading(false); };
getLogData();
}, [eventChosen]);
// this use effect adds more data in case 'View More' button is clicked
useEffect(() => {
const getLogData = async () => {
setIsLoading(true);
const tempLogsData = await getProjectLogs({
workspaceId: String(router.query.id),
offset: currentOffset,
limit: currentLimit,
userId: "",
actionNames: eventChosen
});
setLogsData(
logsData.concat(
tempLogsData.map((log: LogData) => ({
_id: log._id,
channel: log.channel,
createdAt: log.createdAt,
ipAddress: log.ipAddress,
user: log?.user?.email,
serviceAccount: log?.serviceAccount,
serviceTokenData: log?.serviceTokenData,
payload: log.actions.map((action) => ({
_id: action._id,
name: action.name,
secretVersions: action.payload.secretVersions
}))
}))
)
);
setIsLoading(false);
};
getLogData();
}, [currentLimit, currentOffset]);
const loadMoreLogs = () => {
if (subscription?.auditLogs === false) {
handlePopUpOpen("upgradePlan");
} else {
setCurrentOffset(currentOffset + currentLimit);
}
}; };
getLogData();
}, [currentLimit, currentOffset]);
const loadMoreLogs = () => { return (
if (subscription?.auditLogs === false) { <div className="mx-auto w-full h-full max-w-7xl">
handlePopUpOpen("upgradePlan"); <Head>
} else { <title>Audit Logs</title>
setCurrentOffset(currentOffset + currentLimit); <link rel="icon" href="/infisical.ico" />
} <meta property="og:image" content="/images/message.png" />
}; </Head>
{currentSidebarAction && (
return ( <ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />
<div className="mx-auto w-full h-full max-w-7xl"> )}
<Head> <div className="flex flex-col justify-between items-start mx-4 mb-4 text-xl px-2">
<title>Audit Logs</title> <div className="flex flex-row justify-start items-center text-3xl mt-6">
<link rel="icon" href="/infisical.ico" /> <p className="font-semibold mr-4 text-bunker-100">{t("activity.title")}</p>
<meta property="og:image" content="/images/message.png" /> </div>
</Head> <p className="mr-4 text-base text-gray-400">{t("activity.subtitle")}</p>
{currentSidebarAction && (
<ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />
)}
<div className="flex flex-col justify-between items-start mx-4 mb-4 text-xl px-2">
<div className="flex flex-row justify-start items-center text-3xl mt-6">
<p className="font-semibold mr-4 text-bunker-100">{t("activity.title")}</p>
</div> </div>
<p className="mr-4 text-base text-gray-400">{t("activity.subtitle")}</p> <div className="px-6 h-8 mt-2">
</div> <EventFilter selected={eventChosen} select={setEventChosen} />
<div className="px-6 h-8 mt-2"> </div>
<EventFilter selected={eventChosen} select={setEventChosen} /> <ActivityTable data={logsData} toggleSidebar={toggleSidebar} isLoading={isLoading} />
</div> <div className="flex justify-center w-full mb-6">
<ActivityTable data={logsData} toggleSidebar={toggleSidebar} isLoading={isLoading} /> <div className="items-center w-60">
<div className="flex justify-center w-full mb-6"> <Button
<div className="items-center w-60"> text={String(t("common.view-more"))}
<Button textDisabled={String(t("common.end-of-history"))}
text={String(t("common.view-more"))} active={logsData.length % 10 === 0}
textDisabled={String(t("common.end-of-history"))} onButtonPressed={loadMoreLogs}
active={logsData.length % 10 === 0} size="md"
onButtonPressed={loadMoreLogs} color="mineshaft"
size="md" />
color="mineshaft" </div>
</div>
{subscription && (
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
text={
subscription.slug === null
? "You can see more logs under an Enterprise license"
: "You can see more logs if you switch to Infisical's Business/Professional Plan."
}
/> />
</div> )}
</div> </div>
{subscription && ( );
<UpgradePlanModal },
isOpen={popUp.upgradePlan.isOpen} { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
onOpenChange={() => handlePopUpClose("upgradePlan")} );
text={subscription.slug === null ? "You can see more logs under an Enterprise license" : "You can see more logs if you switch to Infisical's Business/Professional Plan."}
/>
)}
</div>
);
}
Activity.requireAuth = true; Object.assign(Activity, { requireAuth: true });
export default Activity;
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,9 @@ import { memo } from "react";
import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2"; import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
type Props = { type Props = {
folders?: Array<{ id: string; name: string }>; folders?: Array<{ id: string; name: string }>;
@@ -47,32 +49,48 @@ export const FolderSection = memo(
{name} {name}
</div> </div>
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all"> <div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
<div className="opacity-0 group-hover:opacity-100"> <ProjectPermissionCan
<Tooltip content="Settings" className="capitalize"> I={ProjectPermissionActions.Edit}
<IconButton a={ProjectPermissionSub.Folders}
size="md" >
colorSchema="primary" {(isAllowed) => (
variant="plain" <div className="opacity-0 group-hover:opacity-100">
onClick={() => handleFolderUpdate(id, name)} <Tooltip content="Settings" className="capitalize">
ariaLabel="expand" <IconButton
> size="md"
<FontAwesomeIcon icon={faEdit} /> colorSchema="primary"
</IconButton> variant="plain"
</Tooltip> isDisabled={!isAllowed}
</div> onClick={() => handleFolderUpdate(id, name)}
<div className="opacity-0 group-hover:opacity-100"> ariaLabel="expand"
<Tooltip content="Delete" className="capitalize"> >
<IconButton <FontAwesomeIcon icon={faEdit} />
size="md" </IconButton>
variant="plain" </Tooltip>
colorSchema="danger" </div>
ariaLabel="delete" )}
onClick={() => handleFolderDelete(id, name)} </ProjectPermissionCan>
> <ProjectPermissionCan
<FontAwesomeIcon icon={faXmark} size="lg" /> I={ProjectPermissionActions.Delete}
</IconButton> a={ProjectPermissionSub.Folders}
</Tooltip> >
</div> {(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete" className="capitalize">
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
onClick={() => handleFolderDelete(id, name)}
>
<FontAwesomeIcon icon={faXmark} size="lg" />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div> </div>
</td> </td>
</tr> </tr>
@@ -2,6 +2,7 @@ import { useFormContext, useWatch } from "react-hook-form";
import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons"; import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
Drawer, Drawer,
@@ -14,6 +15,7 @@ import {
Switch, Switch,
TextArea TextArea
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { FormData, SecretActionType } from "../../DashboardPage.utils"; import { FormData, SecretActionType } from "../../DashboardPage.utils";
@@ -85,20 +87,34 @@ export const SecretDetailDrawer = ({
</Button> </Button>
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<Button isFullWidth onClick={onSave} isDisabled={isReadOnly}> <ProjectPermissionCan
Save Changes I={ProjectPermissionActions.Edit}
</Button> a={ProjectPermissionSub.Secrets}
<Button
colorSchema="danger"
isDisabled={isReadOnly}
onClick={() => {
const secret = getValues(`secrets.${index}`);
onSecretDelete(index, secret.key, secret._id, secret.idOverride);
}}
> >
Delete {(isAllowed) => (
</Button> <Button isFullWidth onClick={onSave} isDisabled={isReadOnly || !isAllowed}>
Save Changes
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Secrets}
>
{(isAllowed) => (
<Button
colorSchema="danger"
isDisabled={isReadOnly || !isAllowed}
onClick={() => {
const secret = getValues(`secrets.${index}`);
onSecretDelete(index, secret.key, secret._id, secret.idOverride);
}}
>
Delete
</Button>
)}
</ProjectPermissionCan>
</div> </div>
</div> </div>
} }
@@ -32,6 +32,8 @@ import {
Skeleton, Skeleton,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDebounce, usePopUp, useToggle } from "@app/hooks"; import { useDebounce, usePopUp, useToggle } from "@app/hooks";
import { useGetProjectSecrets } from "@app/hooks/api"; import { useGetProjectSecrets } from "@app/hooks/api";
import { UserWsKeyPair } from "@app/hooks/api/types"; import { UserWsKeyPair } from "@app/hooks/api/types";
@@ -78,333 +80,343 @@ type Props = {
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
}; };
export const SecretDropzone = ({ export const SecretDropzone = withProjectPermission(
isSmaller, ({
onParsedEnv, isSmaller,
onAddNewSecret, onParsedEnv,
environments = [], onAddNewSecret,
workspaceId, environments = [],
decryptFileKey
}: Props): JSX.Element => {
const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const);
const [searchFilter, setSearchFilter] = useState("");
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
const {
handleSubmit,
control,
watch,
register,
reset,
setValue,
formState: { isDirty }
} = useForm<TFormSchema>({
resolver: yupResolver(formSchema),
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
});
const secretPath = watch("secretPath");
const selectedEnvSlug = watch("environment");
const debouncedSecretPath = useDebounce(secretPath);
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId, workspaceId,
env: selectedEnvSlug,
secretPath: debouncedSecretPath,
isPaused:
!(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) &&
!popUp.importSecEnv.isOpen,
decryptFileKey decryptFileKey
}); }: Props): JSX.Element => {
const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle();
const [isLoading, setIsLoading] = useToggle();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const);
const [searchFilter, setSearchFilter] = useState("");
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
useEffect(() => { const {
setValue("secrets", {}); handleSubmit,
setSearchFilter(""); control,
}, [debouncedSecretPath]); watch,
register,
reset,
setValue,
formState: { isDirty }
} = useForm<TFormSchema>({
resolver: yupResolver(formSchema),
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
});
const handleDrag = (e: DragEvent) => { const secretPath = watch("secretPath");
e.preventDefault(); const selectedEnvSlug = watch("environment");
e.stopPropagation(); const debouncedSecretPath = useDebounce(secretPath);
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive.on();
} else if (e.type === "dragleave") {
setDragActive.off();
}
};
const parseFile = (file?: File, isJson?: boolean) => { const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
const reader = new FileReader(); workspaceId,
if (!file) { env: selectedEnvSlug,
createNotification({ secretPath: debouncedSecretPath,
text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", isPaused:
type: "error", !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) &&
timeoutMs: 10000 !popUp.importSecEnv.isOpen,
}); decryptFileKey
return; });
}
// const fileType = file.name.split('.')[1]; useEffect(() => {
setIsLoading.on(); setValue("secrets", {});
reader.onload = (event) => { setSearchFilter("");
if (!event?.target?.result) return; }, [debouncedSecretPath]);
// parse function's argument looks like to be ArrayBuffer
const env = isJson const handleDrag = (e: DragEvent) => {
? parseJson(event.target.result as ArrayBuffer) e.preventDefault();
: parseDotEnv(event.target.result as ArrayBuffer); e.stopPropagation();
setIsLoading.off(); if (e.type === "dragenter" || e.type === "dragover") {
onParsedEnv(env); setDragActive.on();
} else if (e.type === "dragleave") {
setDragActive.off();
}
}; };
// If something is wrong show an error const parseFile = (file?: File, isJson?: boolean) => {
try { const reader = new FileReader();
reader.readAsText(file); if (!file) {
} catch (error) { createNotification({
console.log(error); text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.",
} type: "error",
}; timeoutMs: 10000
});
const handleDrop = (e: DragEvent) => { return;
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) {
return;
}
e.dataTransfer.dropEffect = "copy";
setDragActive.off();
parseFile(e.dataTransfer.files[0], e.dataTransfer?.files?.[0]?.type === "application/json");
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
};
const handleFormSubmit = (data: TFormSchema) => {
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
Object.keys(data.secrets || {}).forEach((key) => {
if (data.secrets[key]) {
secretsToBePulled[key] = {
value: (shouldIncludeValues && data.secrets[key]) || "",
comments: [""]
};
} }
}); // const fileType = file.name.split('.')[1];
onParsedEnv(secretsToBePulled); setIsLoading.on();
handlePopUpClose("importSecEnv"); reader.onload = (event) => {
reset(); if (!event?.target?.result) return;
}; // parse function's argument looks like to be ArrayBuffer
const env = isJson
? parseJson(event.target.result as ArrayBuffer)
: parseDotEnv(event.target.result as ArrayBuffer);
setIsLoading.off();
onParsedEnv(env);
};
const handleSecSelectAll = () => { // If something is wrong show an error
if (secrets?.secrets) { try {
setValue( reader.readAsText(file);
"secrets", } catch (error) {
secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), console.log(error);
{ shouldDirty: true } }
); };
}
};
return ( const handleDrop = (e: DragEvent) => {
<div e.preventDefault();
onDragEnter={handleDrag} e.stopPropagation();
onDragLeave={handleDrag} if (!e.dataTransfer) {
onDragOver={handleDrag} return;
onDrop={handleDrop} }
className={twMerge(
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 text-sm px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100", e.dataTransfer.dropEffect = "copy";
isDragActive && "opacity-100", setDragActive.off();
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20", parseFile(e.dataTransfer.files[0]);
isLoading && "bg-bunker-800" };
)}
> const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
{isLoading ? ( e.preventDefault();
<div className="mb-16 flex items-center justify-center pt-16"> parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" /> };
</div>
) : ( const handleFormSubmit = (data: TFormSchema) => {
<form onSubmit={handleSubmit(handleFormSubmit)}> const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
<div className="flex items-center justify-cente flex-col space-y-2"> Object.keys(data.secrets || {}).forEach((key) => {
<div> if (data.secrets[key]) {
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} /> secretsToBePulled[key] = {
</div> value: (shouldIncludeValues && data.secrets[key]) || "",
<div> comments: [""]
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p> };
</div> }
<input });
id="fileSelect" onParsedEnv(secretsToBePulled);
type="file" handlePopUpClose("importSecEnv");
className="absolute h-full w-full cursor-pointer opacity-0" reset();
accept=".txt,.env,.yml,.yaml,.json" };
onChange={handleFileUpload}
const handleSecSelectAll = () => {
if (secrets?.secrets) {
setValue(
"secrets",
secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
{ shouldDirty: true }
);
}
};
return (
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={twMerge(
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 text-sm px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
isDragActive && "opacity-100",
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20",
isLoading && "bg-bunker-800"
)}
>
{isLoading ? (
<div className="mb-16 flex items-center justify-center pt-16">
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="loading animation"
/> />
<div </div>
className={twMerge( ) : (
"flex w-full flex-row items-center justify-center py-4", <form onSubmit={handleSubmit(handleFormSubmit)}>
isSmaller && "py-1" <div className="flex items-center justify-cente flex-col space-y-2">
)} <div>
> <FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
<div className="w-1/5 border-t border-mineshaft-700" /> </div>
<p className="mx-4 text-xs text-mineshaft-400">OR</p> <div>
<div className="w-1/5 border-t border-mineshaft-700" /> <p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
</div> </div>
<div className="flex items-center justify-center space-x-8"> <input
<Modal id="fileSelect"
isOpen={popUp.importSecEnv.isOpen} type="file"
onOpenChange={(isOpen) => { className="absolute h-full w-full cursor-pointer opacity-0"
handlePopUpToggle("importSecEnv", isOpen); accept=".txt,.env,.yml,.yaml,.json"
reset(); onChange={handleFileUpload}
setSearchFilter(""); />
}} <div
className={twMerge(
"flex w-full flex-row items-center justify-center py-4",
isSmaller && "py-1"
)}
> >
<ModalTrigger asChild> <div className="w-1/5 border-t border-mineshaft-700" />
<Button variant="star" size={isSmaller ? "xs" : "sm"}> <p className="mx-4 text-xs text-mineshaft-400">OR</p>
Copy Secrets From An Environment <div className="w-1/5 border-t border-mineshaft-700" />
</Button> </div>
</ModalTrigger> <div className="flex items-center justify-center space-x-8">
<ModalContent <Modal
className="max-w-2xl" isOpen={popUp.importSecEnv.isOpen}
title="Copy Secret From An Environment" onOpenChange={(isOpen) => {
subTitle="Copy/paste secrets from other environments into this context" handlePopUpToggle("importSecEnv", isOpen);
reset();
setSearchFilter("");
}}
> >
<form> <ModalTrigger asChild>
<div className="flex items-center space-x-2"> <Button variant="star" size={isSmaller ? "xs" : "sm"}>
<Controller Copy Secrets From An Environment
control={control} </Button>
name="environment" </ModalTrigger>
render={({ field: { value, onChange } }) => ( <ModalContent
<FormControl label="Environment" isRequired className="w-1/3"> className="max-w-2xl"
<Select title="Copy Secret From An Environment"
value={value} subTitle="Copy/paste secrets from other environments into this context"
onValueChange={(val) => onChange(val)} >
className="w-full border border-mineshaft-500" <form>
defaultValue={environments?.[0]?.slug} <div className="flex items-center space-x-2">
position="popper" <Controller
> control={control}
{environments.map((sourceEnvironment) => ( name="environment"
<SelectItem render={({ field: { value, onChange } }) => (
value={sourceEnvironment.slug} <FormControl label="Environment" isRequired className="w-1/3">
key={`source-environment-${sourceEnvironment.slug}`} <Select
> value={value}
{sourceEnvironment.name} onValueChange={(val) => onChange(val)}
</SelectItem> className="w-full border border-mineshaft-500"
))} defaultValue={environments?.[0]?.slug}
</Select> position="popper"
</FormControl> >
)} {environments.map((sourceEnvironment) => (
/> <SelectItem
<FormControl label="Secret Path" className="flex-grow" isRequired> value={sourceEnvironment.slug}
<Input key={`source-environment-${sourceEnvironment.slug}`}
{...register("secretPath")} >
placeholder="Provide a path, default is /" {sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/> />
</FormControl> <FormControl label="Secret Path" className="flex-grow" isRequired>
</div>
<div className="border-t border-mineshaft-600 pt-4">
<div className="mb-4 flex items-center justify-between">
<div>Secrets</div>
<div className="w-1/2 flex items-center space-x-2">
<Input <Input
placeholder="Search for secret" {...register("secretPath")}
value={searchFilter} placeholder="Provide a path, default is /"
size="xs"
leftIcon={<FontAwesomeIcon icon={faSearch} />}
onChange={(evt) => setSearchFilter(evt.target.value)}
/> />
<Tooltip content="Select All"> </FormControl>
<IconButton </div>
ariaLabel="Select all" <div className="border-t border-mineshaft-600 pt-4">
variant="outline_bg" <div className="mb-4 flex items-center justify-between">
<div>Secrets</div>
<div className="w-1/2 flex items-center space-x-2">
<Input
placeholder="Search for secret"
value={searchFilter}
size="xs" size="xs"
onClick={handleSecSelectAll} leftIcon={<FontAwesomeIcon icon={faSearch} />}
> onChange={(evt) => setSearchFilter(evt.target.value)}
<FontAwesomeIcon icon={faSquareCheck} size="lg" /> />
</IconButton> <Tooltip content="Select All">
</Tooltip> <IconButton
<Tooltip content="Unselect All"> ariaLabel="Select all"
<IconButton variant="outline_bg"
ariaLabel="UnSelect all" size="xs"
variant="outline_bg" onClick={handleSecSelectAll}
size="xs" >
onClick={() => reset()} <FontAwesomeIcon icon={faSquareCheck} size="lg" />
> </IconButton>
<FontAwesomeIcon icon={faSquareXmark} size="lg" /> </Tooltip>
</IconButton> <Tooltip content="Unselect All">
</Tooltip> <IconButton
ariaLabel="UnSelect all"
variant="outline_bg"
size="xs"
onClick={() => reset()}
>
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
</IconButton>
</Tooltip>
</div>
</div>
{!isSecretsLoading && !secrets?.secrets?.length && (
<EmptyState title="No secrets found" icon={faKey} />
)}
<div className="grid grid-cols-2 gap-4 max-h-64 overflow-auto thin-scrollbar ">
{isSecretsLoading &&
Array.apply(0, Array(2)).map((_x, i) => (
<Skeleton
key={`secret-pull-loading-${i + 1}`}
className="bg-mineshaft-700"
/>
))}
{secrets?.secrets
?.filter(({ key }) =>
key.toLowerCase().includes(searchFilter.toLowerCase())
)
?.map(({ _id, key, value: secVal }) => (
<Controller
key={`pull-secret--${_id}`}
control={control}
name={`secrets.${key}`}
render={({ field: { value, onChange } }) => (
<Checkbox
id={`pull-secret-${_id}`}
isChecked={Boolean(value)}
onCheckedChange={(isChecked) =>
onChange(isChecked ? secVal : "")
}
>
{key}
</Checkbox>
)}
/>
))}
</div>
<div className="mt-6 mb-4">
<Checkbox
id="populate-include-value"
isChecked={shouldIncludeValues}
onCheckedChange={(isChecked) =>
setShouldIncludeValues(isChecked as boolean)
}
>
Include secret values
</Checkbox>
</div>
<div className="flex items-center space-x-2">
<Button
leftIcon={<FontAwesomeIcon icon={faClone} />}
type="submit"
isDisabled={!isDirty}
>
Paste Secrets
</Button>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</div> </div>
</div> </div>
{!isSecretsLoading && !secrets?.secrets?.length && ( </form>
<EmptyState title="No secrets found" icon={faKey} /> </ModalContent>
)} </Modal>
<div className="grid grid-cols-2 gap-4 max-h-64 overflow-auto thin-scrollbar "> {!isSmaller && (
{isSecretsLoading && <Button variant="star" onClick={onAddNewSecret}>
Array.apply(0, Array(2)).map((_x, i) => ( Add a new secret
<Skeleton </Button>
key={`secret-pull-loading-${i + 1}`} )}
className="bg-mineshaft-700" </div>
/>
))}
{secrets?.secrets
?.filter(({ key }) =>
key.toLowerCase().includes(searchFilter.toLowerCase())
)
?.map(({ _id, key, value: secVal }) => (
<Controller
key={`pull-secret--${_id}`}
control={control}
name={`secrets.${key}`}
render={({ field: { value, onChange } }) => (
<Checkbox
id={`pull-secret-${_id}`}
isChecked={Boolean(value)}
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
>
{key}
</Checkbox>
)}
/>
))}
</div>
<div className="mt-6 mb-4">
<Checkbox
id="populate-include-value"
isChecked={shouldIncludeValues}
onCheckedChange={(isChecked) =>
setShouldIncludeValues(isChecked as boolean)
}
>
Include secret values
</Checkbox>
</div>
<div className="flex items-center space-x-2">
<Button
leftIcon={<FontAwesomeIcon icon={faClone} />}
type="submit"
isDisabled={!isDirty}
>
Paste Secrets
</Button>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</div>
</div>
</form>
</ModalContent>
</Modal>
{!isSmaller && (
<Button variant="star" onClick={onAddNewSecret}>
Add a new secret
</Button>
)}
</div> </div>
</div> </form>
</form> )}
)} </div>
</div> );
); },
}; { action: ProjectPermissionActions.Create, subject: ProjectPermissionSub.Secrets }
);
@@ -9,9 +9,10 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2"; import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub,useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks/useToggle"; import { useToggle } from "@app/hooks";
type Props = { type Props = {
onDelete: (environment: string, secretPath: string) => void; onDelete: (environment: string, secretPath: string) => void;
@@ -49,7 +50,9 @@ export const SecretImportItem = ({
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv); const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
useEffect(() => { useEffect(() => {
const filteredSecrets = importedSecrets.filter(secret => secret.key.toUpperCase().includes(searchTerm.toUpperCase())) const filteredSecrets = importedSecrets.filter((secret) =>
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
);
if (filteredSecrets.length > 0 && searchTerm) { if (filteredSecrets.length > 0 && searchTerm) {
setIsExpanded.on(); setIsExpanded.on();
@@ -58,7 +61,6 @@ export const SecretImportItem = ({
} }
}, [searchTerm]); }, [searchTerm]);
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
setIsExpanded.off(); setIsExpanded.off();
@@ -78,7 +80,11 @@ export const SecretImportItem = ({
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700" className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
onClick={() => setIsExpanded.toggle()} onClick={() => setIsExpanded.toggle()}
> >
<td className={`ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4 ${isExpanded && "border-t-2 border-mineshaft-500"}`}> <td
className={`ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4 ${
isExpanded && "border-t-2 border-mineshaft-500"
}`}
>
<Tooltip content="Secret Import" className="capitalize"> <Tooltip content="Secret Import" className="capitalize">
<FontAwesomeIcon icon={faFileImport} className="text-green-700" /> <FontAwesomeIcon icon={faFileImport} className="text-green-700" />
</Tooltip> </Tooltip>
@@ -106,28 +112,39 @@ export const SecretImportItem = ({
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
<div className="opacity-0 group-hover:opacity-100"> <ProjectPermissionCan
<Tooltip content="Delete" className="capitalize"> I={ProjectPermissionActions.Delete}
<IconButton a={ProjectPermissionSub.SecretImports}
size="md" >
variant="plain" {(isAllowed) => (
colorSchema="danger" <div className="opacity-0 group-hover:opacity-100">
ariaLabel="delete" <Tooltip content="Delete" className="capitalize">
onClick={(evt) => { <IconButton
evt.stopPropagation(); size="md"
onDelete(importedEnv, importedSecPath); variant="plain"
}} colorSchema="danger"
> ariaLabel="delete"
<FontAwesomeIcon icon={faXmark} size="lg" /> isDisabled={!isAllowed}
</IconButton> onClick={(evt) => {
</Tooltip> evt.stopPropagation();
</div> onDelete(importedEnv, importedSecPath);
}}
>
<FontAwesomeIcon icon={faXmark} size="lg" />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div> </div>
</td> </td>
</tr> </tr>
<tr> <tr>
{isExpanded && !isDragging && ( {isExpanded && !isDragging && (
<td colSpan={3} className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}> <td
colSpan={3}
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
>
<div className="rounded-md bg-bunker-700 p-1"> <div className="rounded-md bg-bunker-700 p-1">
<TableContainer> <TableContainer>
<table className="secret-table"> <table className="secret-table">
@@ -146,19 +163,26 @@ export const SecretImportItem = ({
</td> </td>
</tr> </tr>
)} )}
{importedSecrets.filter(secret => secret.key.toUpperCase().includes(searchTerm.toUpperCase())).map(({ key, value, overriden }, index) => ( {importedSecrets
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}> .filter((secret) =>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> secret.key.toUpperCase().includes(searchTerm.toUpperCase())
{key} )
</td> .map(({ key, value, overriden }, index) => (
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> <tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
<SecretInput value={value} isDisabled isVisible /> <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
</td> {key}
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> </td>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} /> <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
</td> <SecretInput value={value} isDisabled isVisible />
</tr> </td>
))} <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon
env={overriden?.env}
secretPath={overriden?.secretPath}
/>
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</TableContainer> </TableContainer>
@@ -22,6 +22,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "cva"; import { cx } from "cva";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
@@ -34,6 +35,10 @@ import {
Tag, Tag,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
@@ -452,22 +457,29 @@ export const SecretInputRow = memo(
</Tooltip> </Tooltip>
</div> </div>
)} )}
<div className="opacity-0 group-hover:opacity-100"> <ProjectPermissionCan
<Tooltip content="Delete"> I={ProjectPermissionActions.Delete}
<IconButton a={ProjectPermissionSub.Secrets}
size="lg" >
variant="plain" {(isAllowed) => (
colorSchema="danger" <div className="opacity-0 group-hover:opacity-100">
ariaLabel="delete" <Tooltip content="Delete">
isDisabled={isReadOnly || isRollbackMode} <IconButton
onClick={() => { size="lg"
onSecretDelete(index, secKey, secId, idOverride); variant="plain"
}} colorSchema="danger"
> ariaLabel="delete"
<FontAwesomeIcon icon={faXmark} /> isDisabled={isReadOnly || isRollbackMode || !isAllowed}
</IconButton> onClick={() => {
</Tooltip> onSecretDelete(index, secKey, secId, idOverride);
</div> }}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div> </div>
</div> </div>
</td> </td>
@@ -1,17 +1,21 @@
import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
LogsSection import { withProjectPermission } from "@app/hoc";
} from "./components";
export const AuditLogsPage = () => { import { LogsSection } from "./components";
export const AuditLogsPage = withProjectPermission(
() => {
return ( return (
<div className="flex justify-center bg-bunker-800 text-white w-full h-full"> <div className="flex justify-center bg-bunker-800 text-white w-full h-full">
<div className="max-w-7xl px-6 w-full"> <div className="max-w-7xl px-6 w-full">
<div className="my-6"> <div className="my-6">
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p> <p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
<div /> <div />
</div> </div>
<LogsSection /> <LogsSection />
</div> </div>
</div> </div>
); );
} },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
);
@@ -1,15 +1,21 @@
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { IPAllowlistSection } from "./components"; import { IPAllowlistSection } from "./components";
export const IPAllowlistPage = () => { export const IPAllowlistPage = withProjectPermission(
() => {
return ( return (
<div className="flex justify-center bg-bunker-800 text-white w-full h-full"> <div className="flex justify-center bg-bunker-800 text-white w-full h-full">
<div className="max-w-7xl px-6 w-full"> <div className="max-w-7xl px-6 w-full">
<div className="my-6"> <div className="my-6">
<p className="text-3xl font-semibold text-gray-200">IP Allowlist</p> <p className="text-3xl font-semibold text-gray-200">IP Allowlist</p>
<div /> <div />
</div> </div>
<IPAllowlistSection /> <IPAllowlistSection />
</div> </div>
</div> </div>
); );
} },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.IpAllowList }
);
@@ -2,104 +2,111 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { import { ProjectPermissionCan } from "@app/components/permissions";
Button, import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
DeleteActionModal,
UpgradePlanModal
} from "@app/components/v2";
import { useSubscription,useWorkspace } from "@app/context";
import { import {
useDeleteTrustedIp ProjectPermissionActions,
} from "@app/hooks/api"; ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { useDeleteTrustedIp } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { IPAllowlistModal } from "./IPAllowlistModal"; import { IPAllowlistModal } from "./IPAllowlistModal";
import { IPAllowlistTable } from "./IPAllowlistTable"; import { IPAllowlistTable } from "./IPAllowlistTable";
export const IPAllowlistSection = () => { export const IPAllowlistSection = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { mutateAsync } = useDeleteTrustedIp(); const { mutateAsync } = useDeleteTrustedIp();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"trustedIp",
"deleteTrustedIp",
"upgradePlan"
] as const);
const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
try {
if (!currentWorkspace?._id) return;
await mutateAsync({ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
workspaceId: currentWorkspace._id, "trustedIp",
trustedIpId "deleteTrustedIp",
}); "upgradePlan"
] as const);
createNotification({ const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
text: "Successfully deleted IP access range", try {
type: "success" if (!currentWorkspace?._id) return;
});
handlePopUpClose("deleteTrustedIp"); await mutateAsync({
} catch (err) { workspaceId: currentWorkspace._id,
console.log(err); trustedIpId
createNotification({ });
text: "Failed to delete IP access range",
type: "error" createNotification({
}); text: "Successfully deleted IP access range",
} type: "success"
});
handlePopUpClose("deleteTrustedIp");
} catch (err) {
console.log(err);
createNotification({
text: "Failed to delete IP access range",
type: "error"
});
} }
};
return (
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"> return (
<div className="flex items-center mb-8"> <div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600">
<h2 className="text-xl font-semibold flex-1 text-white"> <div className="flex items-center mb-8">
IP Allowlist <h2 className="text-xl font-semibold flex-1 text-white">IP Allowlist</h2>
</h2> <ProjectPermissionCan
<Button I={ProjectPermissionActions.Create}
onClick={() => { a={ProjectPermissionSub.IpAllowList}
if (subscription?.ipAllowlisting) { >
handlePopUpOpen("trustedIp") {(isAllowed) => (
} else { <Button
handlePopUpOpen("upgradePlan"); onClick={() => {
} if (subscription?.ipAllowlisting) {
}} handlePopUpOpen("trustedIp");
colorSchema="secondary" } else {
isLoading={false} handlePopUpOpen("upgradePlan");
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add IP
</Button>
</div>
<IPAllowlistTable
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<IPAllowlistModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTrustedIp.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteTrustedIp?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteTrustedIp", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteTrustedIpSubmit((popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId)
} }
/> }}
<UpgradePlanModal colorSchema="secondary"
isOpen={popUp.upgradePlan.isOpen} isLoading={false}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} isDisabled={!isAllowed}
text="You can use IP allowlisting if you switch to Infisical's Pro plan." leftIcon={<FontAwesomeIcon icon={faPlus} />}
/> >
</div> Add IP
); </Button>
} )}
</ProjectPermissionCan>
</div>
<IPAllowlistTable
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<IPAllowlistModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTrustedIp.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteTrustedIp?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteTrustedIp", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onDeleteTrustedIpSubmit(
(popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -1,92 +1,81 @@
import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
TBody, TBody,
Td, Td,
Th, Th,
THead, THead,
Tr, Tr,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { import {
useGetTrustedIps ProjectPermissionActions,
} from "@app/hooks/api"; ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { useGetTrustedIps } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
popUp: UsePopUpState<["upgradePlan"]>; popUp: UsePopUpState<["upgradePlan"]>;
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
data?: { data?: {
trustedIpId: string; trustedIpId: string;
ipAddress?: string; ipAddress?: string;
comment?: string; comment?: string;
isActive?: boolean; isActive?: boolean;
prefix?: number; prefix?: number;
}, }
) => void; ) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
}; };
export const IPAllowlistTable = ({ export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
popUp, const { subscription } = useSubscription();
handlePopUpOpen, const { currentWorkspace } = useWorkspace();
handlePopUpToggle const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? "");
}: Props) => {
const { subscription } = useSubscription(); const formatType = (type: string, prefix?: number) => {
const { currentWorkspace } = useWorkspace(); return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${
const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? ""); prefix !== undefined ? "CIDR" : ""
}`;
const formatType = (type: string, prefix?: number) => { };
return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${(prefix !== undefined) ? "CIDR" : ""}`;
} return (
<div>
return ( <TableContainer className="mt-4">
<div> <Table>
<TableContainer className="mt-4"> <THead>
<Table> <Tr>
<THead> <Th className="flex-1">IP Address / Range</Th>
<Tr> <Th className="flex-1">Format</Th>
<Th className="flex-1">IP Address / Range</Th> <Th className="flex-1">Comment</Th>
<Th className="flex-1">Format</Th> {/* <Th className="flex-1">Status</Th> */}
<Th className="flex-1">Comment</Th> <Th className="w-5" />
{/* <Th className="flex-1">Status</Th> */} </Tr>
<Th className="w-5" /> </THead>
</Tr> <TBody>
</THead> {!isLoading &&
<TBody> data &&
{!isLoading && data && data?.length > 0 && data data?.length > 0 &&
.sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) data
.map(({ .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
_id, .map(({ _id, ipAddress, comment, type, prefix, isActive }) => {
ipAddress, return (
comment, <Tr key={`ip-access-range-${_id}`} className="h-10">
type, <Td>{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}</Td>
prefix, <Td>{formatType(type, prefix)}</Td>
isActive <Td>{comment}</Td>
}) => { {/* <Td>
return (
<Tr
key={`ip-access-range-${_id}`}
className="h-10"
>
<Td>
{`${ipAddress}${(prefix !== undefined) ? `/${prefix}` : ""}`}
</Td>
<Td>
{formatType(type, prefix)}
</Td>
<Td>
{comment}
</Td>
{/* <Td>
<div className="flex items-center"> <div className="flex items-center">
<FontAwesomeIcon <FontAwesomeIcon
icon={faCircle} icon={faCircle}
@@ -95,68 +84,83 @@ export const IPAllowlistTable = ({
<p className="ml-4">Active</p> <p className="ml-4">Active</p>
</div> </div>
</Td> */} </Td> */}
<Td className="flex items-center"> <Td className="flex items-center">
<IconButton <ProjectPermissionCan
className="mr-3 py-2" I={ProjectPermissionActions.Edit}
onClick={() => { a={ProjectPermissionSub.IpAllowList}
if (subscription?.ipAllowlisting) { >
handlePopUpOpen("trustedIp", { {(isAllowed) => (
trustedIpId: _id, <IconButton
ipAddress, className="mr-3 py-2"
comment, onClick={() => {
prefix, if (subscription?.ipAllowlisting) {
isActive handlePopUpOpen("trustedIp", {
}); trustedIpId: _id,
} else { ipAddress,
handlePopUpOpen("upgradePlan"); comment,
} prefix,
}} isActive
colorSchema="primary" });
variant="plain" } else {
ariaLabel="update" handlePopUpOpen("upgradePlan");
> }
<FontAwesomeIcon icon={faPencil} /> }}
</IconButton> colorSchema="primary"
<IconButton variant="plain"
onClick={() => { ariaLabel="update"
if (subscription?.ipAllowlisting) { isDisabled={!isAllowed}
handlePopUpOpen("deleteTrustedIp", { >
trustedIpId: _id <FontAwesomeIcon icon={faPencil} />
}); </IconButton>
} else { )}
handlePopUpOpen("upgradePlan"); </ProjectPermissionCan>
} <ProjectPermissionCan
}} I={ProjectPermissionActions.Delete}
size="lg" a={ProjectPermissionSub.IpAllowList}
colorSchema="danger" >
variant="plain" {(isAllowed) => (
ariaLabel="update" <IconButton
> onClick={() => {
<FontAwesomeIcon icon={faXmark} /> if (subscription?.ipAllowlisting) {
</IconButton> handlePopUpOpen("deleteTrustedIp", {
</Td> trustedIpId: _id
</Tr> });
); } else {
})} handlePopUpOpen("upgradePlan");
{isLoading && <TableSkeleton innerKey="ip-access-table" columns={4} key="ip-access-ranges" />} }
{!isLoading && data && data?.length === 0 && ( }}
<Tr> size="lg"
<Td colSpan={5}> colorSchema="danger"
<EmptyState variant="plain"
title="No IP addresses added" ariaLabel="update"
icon={faGlobe} isDisabled={!isAllowed}
/> >
</Td> <FontAwesomeIcon icon={faXmark} />
</Tr> </IconButton>
)} )}
</TBody> </ProjectPermissionCan>
</Table> </Td>
</TableContainer> </Tr>
<UpgradePlanModal );
isOpen={popUp.upgradePlan.isOpen} })}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} {isLoading && (
text="You can use IP allowlisting if you switch to Infisical's Pro plan." <TableSkeleton innerKey="ip-access-table" columns={4} key="ip-access-ranges" />
/> )}
</div> {!isLoading && data && data?.length === 0 && (
); <Tr>
} <Td colSpan={5}>
<EmptyState title="No IP addresses added" icon={faGlobe} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useGetRoles } from "@app/hooks/api"; import { useGetRoles } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types"; import { TRole } from "@app/hooks/api/roles/types";
@@ -15,44 +16,50 @@ enum TabSections {
Roles = "roles" Roles = "roles"
} }
export const MembersPage = () => { export const MembersPage = withProjectPermission(
const { t } = useTranslation(); () => {
const { currentWorkspace } = useWorkspace(); const { t } = useTranslation();
const workspaceId = currentWorkspace?._id || ""; const { currentWorkspace } = useWorkspace();
const orgId = currentWorkspace?.organization || ""; const workspaceId = currentWorkspace?._id || "";
const orgId = currentWorkspace?.organization || "";
const { data: roles, isLoading: isRolesLoading } = useGetRoles({ const { data: roles, isLoading: isRolesLoading } = useGetRoles({
orgId, orgId,
workspaceId workspaceId
}); });
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto"> <div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">{t("settings.members.title")}</p> <p className="mr-4 mb-4 text-3xl font-semibold text-white">
<Tabs defaultValue={TabSections.Member}> {t("settings.members.title")}
<TabList> </p>
<Tab value={TabSections.Member}>Members</Tab> <Tabs defaultValue={TabSections.Member}>
{process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && ( <TabList>
<Tab value={TabSections.Member}>Members</Tab>
<Tab value={TabSections.Roles}>Roles</Tab> <Tab value={TabSections.Roles}>Roles</Tab>
)} </TabList>
</TabList> <TabPanel value={TabSections.Member}>
<TabPanel value={TabSections.Member}> <motion.div
<motion.div key="panel-1"
key="panel-1" transition={{ duration: 0.15 }}
transition={{ duration: 0.15 }} initial={{ opacity: 0, translateX: 30 }}
initial={{ opacity: 0, translateX: 30 }} animate={{ opacity: 1, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }}
exit={{ opacity: 0, translateX: 30 }} >
> <MemberListTab roles={roles as TRole<string>[]} />
<MemberListTab roles={roles as TRole<string>[]} /> </motion.div>
</motion.div> </TabPanel>
</TabPanel> <TabPanel value={TabSections.Roles}>
<TabPanel value={TabSections.Roles}> <ProjectRoleListTab
<ProjectRoleListTab roles={roles as TRole<string>[]} isRolesLoading={isRolesLoading} /> roles={roles as TRole<string>[]}
</TabPanel> isRolesLoading={isRolesLoading}
</Tabs> />
</TabPanel>
</Tabs>
</div>
</div> </div>
</div> );
); },
}; { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Member }
);
@@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions";
import { import {
decryptAssymmetric, decryptAssymmetric,
encryptAssymmetric encryptAssymmetric
@@ -36,6 +36,8 @@ import {
import { import {
GeneralPermissionActions, GeneralPermissionActions,
OrgPermissionSubjects, OrgPermissionSubjects,
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization, useOrganization,
useUser, useUser,
useWorkspace useWorkspace
@@ -240,7 +242,7 @@ export const MemberListTab = ({ roles = [] }: Props) => {
placeholder="Search members..." placeholder="Search members..."
/> />
</div> </div>
<OrgPermissionCan I={GeneralPermissionActions.Create} a={OrgPermissionSubjects.Member}> <ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
isDisabled={!isAllowed} isDisabled={!isAllowed}
@@ -250,7 +252,7 @@ export const MemberListTab = ({ roles = [] }: Props) => {
Add Member Add Member
</Button> </Button>
)} )}
</OrgPermissionCan> </ProjectPermissionCan>
</div> </div>
<div> <div>
<TableContainer> <TableContainer>
@@ -276,9 +278,9 @@ export const MemberListTab = ({ roles = [] }: Props) => {
<Td>{name}</Td> <Td>{name}</Td>
<Td>{email}</Td> <Td>{email}</Td>
<Td> <Td>
<OrgPermissionCan <ProjectPermissionCan
I={GeneralPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={OrgPermissionSubjects.Member} a={ProjectPermissionSub.Member}
> >
{(isAllowed) => ( {(isAllowed) => (
<> <>
@@ -316,13 +318,13 @@ export const MemberListTab = ({ roles = [] }: Props) => {
)} )}
</> </>
)} )}
</OrgPermissionCan> </ProjectPermissionCan>
</Td> </Td>
<Td> <Td>
{userId !== u?._id && ( {userId !== u?._id && (
<OrgPermissionCan <ProjectPermissionCan
I={GeneralPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={OrgPermissionSubjects.Member} a={ProjectPermissionSub.Member}
> >
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -336,7 +338,7 @@ export const MemberListTab = ({ roles = [] }: Props) => {
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
)} )}
</OrgPermissionCan> </ProjectPermissionCan>
)} )}
</Td> </Td>
</Tr> </Tr>
@@ -1,5 +1,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { TRole } from "@app/hooks/api/roles/types"; import { TRole } from "@app/hooks/api/roles/types";
@@ -11,35 +13,38 @@ type Props = {
isRolesLoading?: boolean; isRolesLoading?: boolean;
}; };
export const ProjectRoleListTab = ({ roles = [], isRolesLoading }: Props) => { export const ProjectRoleListTab = withProjectPermission(
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); ({ roles = [], isRolesLoading }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
return popUp.editRole.isOpen ? ( return popUp.editRole.isOpen ? (
<motion.div <motion.div
key="role-modify" key="role-modify"
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }} initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }} animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }} exit={{ opacity: 0, translateX: 30 }}
> >
<ProjectRoleModifySection <ProjectRoleModifySection
role={popUp.editRole.data as TRole<string>} role={popUp.editRole.data as TRole<string>}
onGoBack={() => handlePopUpClose("editRole")} onGoBack={() => handlePopUpClose("editRole")}
/> />
</motion.div> </motion.div>
) : ( ) : (
<motion.div <motion.div
key="role-list" key="role-list"
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }} initial={{ opacity: 0, translateX: -30 }}
animate={{ opacity: 1, translateX: 0 }} animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }} exit={{ opacity: 0, translateX: -30 }}
> >
<ProjectRoleList <ProjectRoleList
roles={roles} roles={roles}
isRolesLoading={isRolesLoading} isRolesLoading={isRolesLoading}
onSelectRole={(role) => handlePopUpOpen("editRole", role)} onSelectRole={(role) => handlePopUpOpen("editRole", role)}
/> />
</motion.div> </motion.div>
); );
}; },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
);
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
@@ -19,7 +20,12 @@ import {
Tooltip, Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context"; import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteRole } from "@app/hooks/api"; import { useDeleteRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types"; import { TRole } from "@app/hooks/api/roles/types";
@@ -69,9 +75,17 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
placeholder="Search roles..." placeholder="Search roles..."
/> />
</div> </div>
<Button leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => onSelectRole()}> <ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
Add Role {(isAllowed) => (
</Button> <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => onSelectRole()}
isDisabled={!isAllowed}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
</div> </div>
<div> <div>
<TableContainer> <TableContainer>
@@ -99,27 +113,48 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
</Td> </Td>
<Td> <Td>
<div className="flex space-x-2 items-center"> <div className="flex space-x-2 items-center">
<Tooltip content="Edit"> <ProjectPermissionCan
<IconButton I={ProjectPermissionActions.Edit}
ariaLabel="edit" a={ProjectPermissionSub.Role}
onClick={() => onSelectRole(role)}
variant="plain"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
<Tooltip
content={isNonMutatable ? "Reserved roles are non-removable" : "Delete"}
> >
<IconButton {(isAllowed) => (
ariaLabel="delete" <div>
onClick={() => handlePopUpOpen("deleteRole", role)} <Tooltip content="Edit">
variant="plain" <IconButton
isDisabled={isNonMutatable} isDisabled={!isAllowed}
> ariaLabel="edit"
<FontAwesomeIcon icon={faTrash} /> onClick={() => onSelectRole(role)}
</IconButton> variant="plain"
</Tooltip> >
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<div>
<Tooltip
content={
isNonMutatable ? "Reserved roles are non-removable" : "Delete"
}
>
<IconButton
ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain"
isDisabled={isNonMutatable || !isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</div> </div>
</Td> </Td>
</Tr> </Tr>
@@ -34,6 +34,7 @@ import {
TFormSchema TFormSchema
} from "./ProjectRoleModifySection.utils"; } from "./ProjectRoleModifySection.utils";
import { SingleProjectPermission } from "./SingleProjectPermission"; import { SingleProjectPermission } from "./SingleProjectPermission";
import { WsProjectPermission } from "./WsProjectPermission";
const SINGLE_PERMISSION_LIST = [ const SINGLE_PERMISSION_LIST = [
{ {
@@ -271,6 +272,13 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
/> />
</div> </div>
))} ))}
<div className="flex flex-col space-y-4" key="permission-ws">
<WsProjectPermission
control={control}
setValue={setValue}
isNonEditable={isNonEditable}
/>
</div>
</div> </div>
<div className="flex items-center space-x-4 mt-12"> <div className="flex items-center space-x-4 mt-12">
<Button <Button
@@ -0,0 +1,127 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>;
};
enum Permission {
NoAccess = "no-access",
ReadOnly = "read-only",
FullAccess = "full-acess",
Custom = "custom"
}
const PERMISSIONS = [
{ action: "edit", label: "Update" },
{ action: "delete", label: "Remove" }
] as const;
export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => {
const rule = useWatch({
control,
name: "permissions.workspace"
});
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
if (val === Permission.Custom) setIsCustom.on();
else setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
break;
case Permission.FullAccess:
setValue("permissions.workspace", { edit: true, delete: true }, { shouldDirty: true });
break;
default:
setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
break;
}
};
return (
<div
className={twMerge(
"px-10 py-6 bg-mineshaft-800 rounded-md",
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Workspace</div>
<div className="text-xs font-light">Workspace control actions</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
<SelectItem value={Permission.Custom}>Custom</SelectItem>
</Select>
</div>
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
>
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.workspace.${action}`}
key={`permissions.workspace.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);
};
@@ -23,7 +23,13 @@ import {
Tooltip, Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context"; import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { import {
useCreateSecretV3, useCreateSecretV3,
useDeleteSecretV3, useDeleteSecretV3,
@@ -38,7 +44,7 @@ import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
export const SecretOverviewPage = () => { const SecretOverview = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const router = useRouter(); const router = useRouter();
@@ -394,3 +400,8 @@ export const SecretOverviewPage = () => {
</div> </div>
); );
}; };
export const SecretOverviewPage = withProjectPermission(SecretOverview, {
action: ProjectPermissionActions.Read,
subject: ProjectPermissionSub.Secrets
});
@@ -3,7 +3,9 @@ import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-i
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, SecretInput, Tooltip } from "@app/components/v2"; import { IconButton, SecretInput, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
type Props = { type Props = {
@@ -91,19 +93,26 @@ export const SecretEditRow = ({
<div className="flex w-16 justify-center space-x-3 pl-2 transition-all"> <div className="flex w-16 justify-center space-x-3 pl-2 transition-all">
{isDirty ? ( {isDirty ? (
<> <>
<div> <ProjectPermissionCan
<Tooltip content="save"> I={ProjectPermissionActions.Create}
<IconButton a={ProjectPermissionSub.Secrets}
variant="plain" >
ariaLabel="submit-value" {(isAllowed) => (
className="h-full" <div>
isDisabled={isSubmitting} <Tooltip content="save">
onClick={handleSubmit(handleFormSubmit)} <IconButton
> variant="plain"
<FontAwesomeIcon icon={faCheck} /> ariaLabel="submit-value"
</IconButton> className="h-full"
</Tooltip> isDisabled={isSubmitting || !isAllowed}
</div> onClick={handleSubmit(handleFormSubmit)}
>
<FontAwesomeIcon icon={faCheck} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
<div> <div>
<Tooltip content="cancel"> <Tooltip content="cancel">
<IconButton <IconButton
@@ -132,19 +141,26 @@ export const SecretEditRow = ({
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
<div className="opacity-0 group-hover:opacity-100"> <ProjectPermissionCan
<Tooltip content="Delete"> I={ProjectPermissionActions.Delete}
<IconButton a={ProjectPermissionSub.Secrets}
variant="plain" >
ariaLabel="delete-value" {(isAllowed) => (
className="h-full" <div className="opacity-0 group-hover:opacity-100">
onClick={handleDeleteSecret} <Tooltip content="Delete">
isDisabled={isDeleting} <IconButton
> variant="plain"
<FontAwesomeIcon icon={faTrash} /> ariaLabel="delete-value"
</IconButton> className="h-full"
</Tooltip> onClick={handleDeleteSecret}
</div> isDisabled={isDeleting || !isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</> </>
)} )}
</div> </div>
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab";
@@ -14,45 +16,50 @@ const tabs = [
{ name: "Webhooks", key: "tab-project-webhooks" } { name: "Webhooks", key: "tab-project-webhooks" }
]; ];
export const ProjectSettingsPage = () => { export const ProjectSettingsPage = withProjectPermission(
const { t } = useTranslation(); () => {
return ( const { t } = useTranslation();
<div className="flex w-full justify-center bg-bunker-800 px-6 text-white"> return (
<div className="w-full max-w-screen-lg"> <div className="flex w-full justify-center bg-bunker-800 px-6 text-white">
<div className="relative right-5 ml-4"> <div className="w-full max-w-screen-lg">
<NavHeader pageName={t("settings.project.title")} isProjectRelated /> <div className="relative right-5 ml-4">
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
</div>
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
</div>
<Tab.Group>
<Tab.List className="mb-4 w-full border-b-2 border-mineshaft-800">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${
selected ? "border-b border-white text-white" : "text-mineshaft-400"
}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
<Tab.Panel>
<WebhooksTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div> </div>
<div className="my-8">
<p className="text-3xl font-semibold text-gray-200">{t("settings.project.title")}</p>
</div>
<Tab.Group>
<Tab.List className="mb-4 w-full border-b-2 border-mineshaft-800">
{tabs.map((tab) => (
<Tab as={Fragment} key={tab.key}>
{({ selected }) => (
<button
type="button"
className={`w-30 py-2 mx-2 mr-4 font-medium text-sm outline-none ${selected ? "border-b border-white text-white" : "text-mineshaft-400"}`}
>
{tab.name}
</button>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProjectGeneralTab />
</Tab.Panel>
<Tab.Panel>
<ProjectServiceTokensTab />
</Tab.Panel>
<Tab.Panel>
<WebhooksTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div> </div>
</div> );
); },
}; { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings }
);
@@ -1,52 +1,62 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Checkbox } from "@app/components/v2"; import { Checkbox } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggleAutoCapitalization } from "@app/hooks/api"; import { withProjectPermission } from "@app/hoc";
import { useToggleAutoCapitalization } from "@app/hooks/api";
export const AutoCapitalizationSection = () => { export const AutoCapitalizationSection = withProjectPermission(
const { t } = useTranslation(); () => {
const { createNotification } = useNotificationContext(); const { t } = useTranslation();
const { currentWorkspace } = useWorkspace(); const { createNotification } = useNotificationContext();
const { mutateAsync } = useToggleAutoCapitalization(); const { currentWorkspace } = useWorkspace();
const { mutateAsync } = useToggleAutoCapitalization();
const handleToggleCapitalizationToggle = async (state: boolean) => {
try {
if (!currentWorkspace?._id) return;
await mutateAsync({ const handleToggleCapitalizationToggle = async (state: boolean) => {
workspaceID: currentWorkspace._id, try {
state if (!currentWorkspace?._id) return;
});
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; await mutateAsync({
createNotification({ workspaceID: currentWorkspace._id,
text, state
type: "success" });
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update auto capitalization",
type: "error"
});
}
}
return ( const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> createNotification({
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p> text,
<Checkbox type: "success"
className="data-[state=checked]:bg-primary" });
id="autoCapitalization" } catch (err) {
isChecked={currentWorkspace?.autoCapitalization ?? false} console.error(err);
onCheckedChange={(state) => { createNotification({
handleToggleCapitalizationToggle(state as boolean); text: "Failed to update auto capitalization",
}} type: "error"
> });
{t("settings.project.auto-capitalization-description")} }
</Checkbox> };
</div>
); return (
}; <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<Checkbox
className="data-[state=checked]:bg-primary"
id="autoCapitalization"
isDisabled={!isAllowed}
isChecked={currentWorkspace?.autoCapitalization ?? false}
onCheckedChange={(state) => {
handleToggleCapitalizationToggle(state as boolean);
}}
>
{t("settings.project.auto-capitalization-description")}
</Checkbox>
)}
</ProjectPermissionCan>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings }
);
@@ -3,30 +3,34 @@ import { useTranslation } from "react-i18next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2"; import { Button, FormControl, Input } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { import {
useDeleteWorkspace ProjectPermissionActions,
} from "@app/hooks/api"; ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { useToggle } from "@app/hooks";
import { useDeleteWorkspace } from "@app/hooks/api";
export const DeleteProjectSection = () => { export const DeleteProjectSection = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization() const { currentOrg } = useOrganization();
const [isDeleting, setIsDeleting] = useToggle(); const [isDeleting, setIsDeleting] = useToggle();
const [deleteProjectInput, setDeleteProjectInput] = useState(""); const [deleteProjectInput, setDeleteProjectInput] = useState("");
const deleteWorkspace = useDeleteWorkspace(); const deleteWorkspace = useDeleteWorkspace();
const onDeleteWorkspace = async () => { const onDeleteWorkspace = async () => {
setIsDeleting.on(); setIsDeleting.on();
try { try {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
await deleteWorkspace.mutateAsync({ await deleteWorkspace.mutateAsync({
workspaceID: currentWorkspace?._id workspaceID: currentWorkspace?._id
}); });
// redirect user to the org overview // redirect user to the org overview
router.push(`/org/${currentOrg?._id}/overview`); router.push(`/org/${currentOrg?._id}/overview`);
@@ -45,38 +49,42 @@ export const DeleteProjectSection = () => {
} }
}; };
return ( return (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-red"> <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-red">
<p className="mb-3 text-xl font-semibold text-red">{t("settings.project.danger-zone")}</p> <p className="mb-3 text-xl font-semibold text-red">{t("settings.project.danger-zone")}</p>
<p className="text-gray-400 mb-8">{t("settings.project.danger-zone-note")}</p> <p className="text-gray-400 mb-8">{t("settings.project.danger-zone-note")}</p>
<div className="mr-auto mt-4 max-h-28 w-full max-w-md"> <div className="mr-auto mt-4 max-h-28 w-full max-w-md">
<FormControl <FormControl
label={ label={
<div className="mb-0.5 text-sm font-normal text-gray-400"> <div className="mb-0.5 text-sm font-normal text-gray-400">
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
workspace workspace
</div> </div>
} }
> >
<Input <Input
onChange={(e) => setDeleteProjectInput(e.target.value)} onChange={(e) => setDeleteProjectInput(e.target.value)}
value={deleteProjectInput} value={deleteProjectInput}
placeholder="Type the project name to delete" placeholder="Type the project name to delete"
className="bg-mineshaft-800" className="bg-mineshaft-800"
/> />
</FormControl> </FormControl>
</div> </div>
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Workspace}>
{(isAllowed) => (
<Button <Button
colorSchema="danger" colorSchema="danger"
onClick={onDeleteWorkspace} onClick={onDeleteWorkspace}
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting} isDisabled={!isAllowed || deleteProjectInput !== currentWorkspace?.name || isDeleting}
isLoading={isDeleting} isLoading={isDeleting}
> >
{t("settings.project.delete-project")} {t("settings.project.delete-project")}
</Button> </Button>
<p className="mt-3 ml-0.5 text-xs text-gray-500"> )}
{t("settings.project.delete-project-note")} </ProjectPermissionCan>
</p> <p className="mt-3 ml-0.5 text-xs text-gray-500">
</div> {t("settings.project.delete-project-note")}
); </p>
} </div>
);
};
@@ -1,99 +1,115 @@
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
decryptAssymmetric, decryptAssymmetric,
encryptAssymmetric encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { Checkbox } from "@app/components/v2"; import { Checkbox } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetUserWsKey,useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api"; import { withProjectPermission } from "@app/hoc";
import { useGetUserWsKey, useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api";
export const E2EESection = () => { export const E2EESection = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data: bot } = useGetWorkspaceBot(currentWorkspace?._id ?? ""); const { data: bot } = useGetWorkspaceBot(currentWorkspace?._id ?? "");
const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus(); const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus();
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
/** /**
* Activate bot for project by performing the following steps: * Activate bot for project by performing the following steps:
* 1. Get the (encrypted) project key * 1. Get the (encrypted) project key
* 2. Decrypt project key with user's private key * 2. Decrypt project key with user's private key
* 3. Encrypt project key with bot's public key * 3. Encrypt project key with bot's public key
* 4. Send encrypted project key to backend and set bot status to active * 4. Send encrypted project key to backend and set bot status to active
*/ */
const toggleBotActivate = async () => { const toggleBotActivate = async () => {
let botKey; let botKey;
try { try {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
if (bot && wsKey) { if (bot && wsKey) {
// case: there is a bot // case: there is a bot
if (!bot.isActive) {
// bot is not active -> activate bot
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
if (!PRIVATE_KEY) { if (!bot.isActive) {
throw new Error("Private Key missing"); // bot is not active -> activate bot
}
const WORKSPACE_KEY = decryptAssymmetric({ const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({ if (!PRIVATE_KEY) {
plaintext: WORKSPACE_KEY, throw new Error("Private Key missing");
publicKey: bot.publicKey,
privateKey: PRIVATE_KEY
});
botKey = {
encryptedKey: ciphertext,
nonce
};
await updateBotActiveStatus({
workspaceId: currentWorkspace._id,
botKey,
isActive: true,
botId: bot._id
});
} else {
// bot is active -> deactivate bot
await updateBotActiveStatus({
isActive: false,
botId: bot._id,
workspaceId: currentWorkspace._id
});
}
} }
} catch (err) {
console.error(err); const WORKSPACE_KEY = decryptAssymmetric({
ciphertext: wsKey.encryptedKey,
nonce: wsKey.nonce,
publicKey: wsKey.sender.publicKey,
privateKey: PRIVATE_KEY
});
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: WORKSPACE_KEY,
publicKey: bot.publicKey,
privateKey: PRIVATE_KEY
});
botKey = {
encryptedKey: ciphertext,
nonce
};
await updateBotActiveStatus({
workspaceId: currentWorkspace._id,
botKey,
isActive: true,
botId: bot._id
});
} else {
// bot is active -> deactivate bot
await updateBotActiveStatus({
isActive: false,
botId: bot._id,
workspaceId: currentWorkspace._id
});
}
} }
} catch (err) {
console.error(err);
}
}; };
return bot ? ( return bot ? (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p> <p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
<p className="text-gray-400 mb-8"> <p className="text-gray-400 mb-8">
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values. Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to
cloud providers as well as HTTP calls to get secrets back raw but enables the server to
read/decrypt your secret values.
</p> </p>
<p className="text-gray-400 mb-8"> <p className="text-gray-400 mb-8">
Note that, even with E2EE disabled, your secrets are always encrypted at rest. Note that, even with E2EE disabled, your secrets are always encrypted at rest.
</p> </p>
<Checkbox <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
className="data-[state=checked]:bg-primary" {(isAllowed) => (
id="autoCapitalization" <Checkbox
isChecked={!bot.isActive} className="data-[state=checked]:bg-primary"
onCheckedChange={async () => { id="autoCapitalization"
await toggleBotActivate(); isChecked={!bot.isActive}
}} isDisabled={!isAllowed}
> onCheckedChange={async () => {
End-to-end encryption enabled await toggleBotActivate();
</Checkbox> }}
>
End-to-end encryption enabled
</Checkbox>
)}
</ProjectPermissionCan>
</div> </div>
) : <div />; ) : (
}; <div />
);
},
{
action: ProjectPermissionActions.Read,
subject: ProjectPermissionSub.Settings
}
);
@@ -2,115 +2,127 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import { import {
Button, ProjectPermissionActions,
DeleteActionModal, ProjectPermissionSub,
UpgradePlanModal useSubscription,
} from "@app/components/v2"; useWorkspace
import { useSubscription,useWorkspace } from "@app/context"; } from "@app/context";
import { import { withProjectPermission } from "@app/hoc";
useDeleteWsEnvironment import { useDeleteWsEnvironment } from "@app/hooks/api";
} from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { AddEnvironmentModal } from "./AddEnvironmentModal"; import { AddEnvironmentModal } from "./AddEnvironmentModal";
import { EnvironmentTable } from "./EnvironmentTable"; import { EnvironmentTable } from "./EnvironmentTable";
import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal"; import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
export const EnvironmentSection = () => { export const EnvironmentSection = withProjectPermission(
const { createNotification } = useNotificationContext(); () => {
const { subscription } = useSubscription(); const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace(); const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const deleteWsEnvironment = useDeleteWsEnvironment(); const deleteWsEnvironment = useDeleteWsEnvironment();
const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true; const isMoreEnvironmentsAllowed =
subscription?.environmentLimit && currentWorkspace?.environments
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ ? currentWorkspace.environments.length < subscription.environmentLimit
"createEnv", : true;
"updateEnv",
"deleteEnv",
"upgradePlan"
] as const);
const onEnvDeleteSubmit = async (environmentSlug: string) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
try { "createEnv",
if (!currentWorkspace?._id) return; "updateEnv",
"deleteEnv",
await deleteWsEnvironment.mutateAsync({ "upgradePlan"
workspaceID: currentWorkspace._id, ] as const);
environmentSlug
});
createNotification({ const onEnvDeleteSubmit = async (environmentSlug: string) => {
text: "Successfully deleted environment", try {
type: "success" if (!currentWorkspace?._id) return;
});
handlePopUpClose("deleteEnv");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete environment",
type: "error"
});
}
};
return ( await deleteWsEnvironment.mutateAsync({
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> workspaceID: currentWorkspace._id,
<div className="flex justify-between mb-8"> environmentSlug
<p className="text-xl font-semibold text-mineshaft-100"> });
Environments
</p> createNotification({
<div> text: "Successfully deleted environment",
<Button type: "success"
colorSchema="secondary" });
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { handlePopUpClose("deleteEnv");
if (isMoreEnvironmentsAllowed) { } catch (err) {
handlePopUpOpen("createEnv"); console.error(err);
} else { createNotification({
handlePopUpOpen("upgradePlan"); text: "Failed to delete environment",
} type: "error"
}} });
> }
Create environment };
</Button>
return (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8">
<p className="text-xl font-semibold text-mineshaft-100">Environments</p>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Environments}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreEnvironmentsAllowed) {
handlePopUpOpen("createEnv");
} else {
handlePopUpOpen("upgradePlan");
}
}}
isDisabled={!isAllowed}
>
Create environment
</Button>
)}
</ProjectPermissionCan>
</div>
</div> </div>
<p className="text-gray-400 mb-8">
Choose which environments will show up in your dashboard like development, staging,
production
</p>
<EnvironmentTable handlePopUpOpen={handlePopUpOpen} />
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteEnv", isOpen)}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""}
onDeleteApproved={() =>
onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div> </div>
<p className="text-gray-400 mb-8"> );
Choose which environments will show up in your dashboard like development, staging, production },
</p> { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Environments }
<EnvironmentTable );
handlePopUpOpen={handlePopUpOpen}
/>
<AddEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpdateEnvironmentModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteEnv.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteEnv?.data as { name: string })?.name || " "
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteEnv", isOpen)}
deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""}
onDeleteApproved={() =>
onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div>
);
};
@@ -2,6 +2,7 @@ import { faArrowDown,faArrowUp, faPencil, faXmark } from "@fortawesome/free-soli
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
@@ -14,7 +15,7 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { import {
useReorderWsEnvironment useReorderWsEnvironment
} from "@app/hooks/api"; } from "@app/hooks/api";
@@ -115,28 +116,42 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
> >
<FontAwesomeIcon icon={faArrowUp} /> <FontAwesomeIcon icon={faArrowUp} />
</IconButton> </IconButton>
<IconButton <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Environments}
className="mr-3 py-2"
onClick={() => {
handlePopUpOpen("updateEnv", { name, slug });
}}
colorSchema="primary"
variant="plain"
ariaLabel="update"
> >
<FontAwesomeIcon icon={faPencil} /> {(isAllowed) => (
</IconButton> <IconButton
<IconButton className="mr-3 py-2"
onClick={() => { onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug }); handlePopUpOpen("updateEnv", { name, slug });
}} }}
size="lg" isDisabled={!isAllowed}
colorSchema="danger" colorSchema="primary"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Environments}
> >
<FontAwesomeIcon icon={faXmark} /> {(isAllowed) => (
</IconButton> <IconButton
onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug });
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td> </Td>
</Tr> </Tr>
))} ))}
@@ -1,71 +1,77 @@
import { import {
decryptAssymmetric, decryptAssymmetric,
decryptSymmetric decryptSymmetric
} from "@app/components/utilities/cryptography/crypto"; } from "@app/components/utilities/cryptography/crypto";
import { Button } from "@app/components/v2"; import { Button } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { import {
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceIndexStatus, useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets, useGetWorkspaceSecrets,
useNameWorkspaceSecrets useNameWorkspaceSecrets
} from "@app/hooks/api"; } from "@app/hooks/api";
export const ProjectIndexSecretsSection = () => { // TODO: add check so that this only shows up if user is
// an admin in the workspace
export const ProjectIndexSecretsSection = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? ""); const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(
currentWorkspace?._id ?? ""
);
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? ""); const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? "");
const nameWorkspaceSecrets = useNameWorkspaceSecrets(); const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const onEnableBlindIndices = async () => { const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return; if (!encryptedSecrets) return;
if (!latestFileKey) return; if (!latestFileKey) return;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey, ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce, nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey, publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
const secretName = decryptSymmetric({
ciphertext: encryptedSecret.secretKeyCiphertext,
iv: encryptedSecret.secretKeyIV,
tag: encryptedSecret.secretKeyTag,
key
}); });
return { const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
secretName, const secretName = decryptSymmetric({
_id: encryptedSecret._id ciphertext: encryptedSecret.secretKeyCiphertext,
}; iv: encryptedSecret.secretKeyIV,
}); tag: encryptedSecret.secretKeyTag,
key
});
await nameWorkspaceSecrets.mutateAsync({ return {
workspaceId: currentWorkspace._id, secretName,
secretsToUpdate _id: encryptedSecret._id
}); };
}; });
return (!isBlindIndexedLoading && (isBlindIndexed === false)) ? ( await nameWorkspaceSecrets.mutateAsync({
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> workspaceId: currentWorkspace._id,
<p className="mb-3 text-xl font-semibold">Blind Indices</p> secretsToUpdate
<p className="text-gray-400 mb-8"> });
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing. };
</p>
<Button return !isBlindIndexedLoading && !isBlindIndexed ? (
onClick={onEnableBlindIndices} <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
color="mineshaft" <p className="mb-3 text-xl font-semibold">Blind Indices</p>
size="sm" <p className="text-gray-400 mb-8">
type="submit" Your project, created before the introduction of blind indexing, contains unindexed
> secrets. To access individual secrets by name through the SDK and public API, please
Enable Blind Indexing enable blind indexing.
</Button> </p>
</div> <Button onClick={onEnableBlindIndices} color="mineshaft" size="sm" type="submit">
Enable Blind Indexing
</Button>
</div>
) : ( ) : (
<div /> <div />
) );
} },
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings }
);
@@ -4,11 +4,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2"; import { Button, FormControl, Input } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { import { useRenameWorkspace } from "@app/hooks/api";
useRenameWorkspace
} from "@app/hooks/api";
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Project Name") name: yup.string().required().label("Project Name")
@@ -21,25 +20,20 @@ export const ProjectNameChangeSection = () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { mutateAsync, isLoading } = useRenameWorkspace(); const { mutateAsync, isLoading } = useRenameWorkspace();
const { const { handleSubmit, control, reset } = useForm<FormData>({ resolver: yupResolver(formSchema) });
handleSubmit,
control,
reset
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
useEffect(() => { useEffect(() => {
if (currentWorkspace) { if (currentWorkspace) {
reset({ reset({
name: currentWorkspace.name name: currentWorkspace.name
}); });
} }
}, [currentWorkspace]); }, [currentWorkspace]);
const onFormSubmit = async ({ name }: FormData) => { const onFormSubmit = async ({ name }: FormData) => {
try { try {
if (!currentWorkspace?._id) return; if (!currentWorkspace?._id) return;
await mutateAsync({ await mutateAsync({
workspaceID: currentWorkspace._id, workspaceID: currentWorkspace._id,
newWorkspaceName: name newWorkspaceName: name
@@ -49,7 +43,6 @@ export const ProjectNameChangeSection = () => {
text: "Successfully renamed workspace", text: "Successfully renamed workspace",
type: "success" type: "success"
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
@@ -60,37 +53,35 @@ export const ProjectNameChangeSection = () => {
}; };
return ( return (
<form <form
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600" className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"
> >
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8"> <h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">Project Name</h2>
Project Name <div className="max-w-md">
</h2> <Controller
<div className="max-w-md"> defaultValue=""
<Controller render={({ field, fieldState: { error } }) => (
defaultValue="" <FormControl isError={Boolean(error)} errorText={error?.message}>
render={({ field, fieldState: { error } }) => ( <Input placeholder="Project name" {...field} className="bg-mineshaft-800" />
<FormControl isError={Boolean(error)} errorText={error?.message}> </FormControl>
<Input )}
placeholder="Project name" control={control}
{...field} name="name"
className="bg-mineshaft-800" />
/> </div>
</FormControl> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
)} {(isAllowed) => (
control={control} <Button
name="name" colorSchema="secondary"
/> type="submit"
</div> isLoading={isLoading}
<Button isDisabled={isLoading || !isAllowed}
colorSchema="secondary" >
type="submit" Save
isLoading={isLoading} </Button>
isDisabled={isLoading} )}
> </ProjectPermissionCan>
Save
</Button>
</form> </form>
); );
}; };
@@ -2,10 +2,10 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { import { ProjectPermissionCan } from "@app/components/permissions";
Button, import { Button, DeleteActionModal } from "@app/components/v2";
DeleteActionModal import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
} from "@app/components/v2"; import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteWsTag } from "@app/hooks/api"; import { useDeleteWsTag } from "@app/hooks/api";
@@ -14,74 +14,80 @@ import { SecretTagsTable } from "./SecretTagsTable";
type DeleteModalData = { name: string; id: string }; type DeleteModalData = { name: string; id: string };
export const SecretTagsSection = (): JSX.Element => { export const SecretTagsSection = withProjectPermission(
const { createNotification } = useNotificationContext(); (): JSX.Element => {
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ const { createNotification } = useNotificationContext();
"CreateSecretTag", const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"deleteTagConfirmation" "CreateSecretTag",
] as const); "deleteTagConfirmation"
] as const);
const deleteWsTag = useDeleteWsTag(); const deleteWsTag = useDeleteWsTag();
const onDeleteApproved = async () => { const onDeleteApproved = async () => {
try { try {
await deleteWsTag.mutateAsync({ await deleteWsTag.mutateAsync({
tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id
}); });
createNotification({ createNotification({
text: "Successfully deleted tag", text: "Successfully deleted tag",
type: "success" type: "success"
}); });
handlePopUpClose("deleteTagConfirmation"); handlePopUpClose("deleteTagConfirmation");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to delete the tag", text: "Failed to delete the tag",
type: "error" type: "error"
}); });
} }
}; };
return ( return (
<div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600"> <div className="mb-6 p-4 bg-mineshaft-900 rounded-lg border border-mineshaft-600">
<div className="flex justify-between mb-8"> <div className="flex justify-between mb-8">
<p className="mb-3 text-xl font-semibold">Secret Tags</p> <p className="mb-3 text-xl font-semibold">Secret Tags</p>
<Button <ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Tags}>
colorSchema="secondary" {(isAllowed) => (
leftIcon={<FontAwesomeIcon icon={faPlus} />} <Button
onClick={() => { colorSchema="secondary"
console.log("x"); leftIcon={<FontAwesomeIcon icon={faPlus} />}
handlePopUpOpen("CreateSecretTag"); onClick={() => {
console.log("x2"); console.log("x");
}} handlePopUpOpen("CreateSecretTag");
> console.log("x2");
Create tag }}
</Button> isDisabled={!isAllowed}
>
Create tag
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="text-gray-400 mb-8">
Every secret can be assigned to one or more tags. Here you can add and remove tags for the
current project.
</p>
<SecretTagsTable handlePopUpOpen={handlePopUpOpen} />
<AddSecretTagModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTagConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name || " "
} api key?`}
onChange={(isOpen) => handlePopUpToggle("deleteTagConfirmation", isOpen)}
deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteTagConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div> </div>
<p className="text-gray-400 mb-8"> );
Every secret can be assigned to one or more tags. Here you can add and remove tags for },
the current project. { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Tags }
</p> );
<SecretTagsTable
handlePopUpOpen={handlePopUpOpen}
/>
<AddSecretTagModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal
isOpen={popUp.deleteTagConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name || " "
} api key?`}
onChange={(isOpen) => handlePopUpToggle("deleteTagConfirmation", isOpen)}
deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteTagConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
};
@@ -1,6 +1,7 @@
import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
@@ -13,7 +14,7 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetWsTags } from "@app/hooks/api"; import { useGetWsTags } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -53,18 +54,26 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
<Td>{name}</Td> <Td>{name}</Td>
<Td>{slug}</Td> <Td>{slug}</Td>
<Td className="flex items-center justify-end"> <Td className="flex items-center justify-end">
<IconButton <ProjectPermissionCan
onClick={() => I={ProjectPermissionActions.Delete}
handlePopUpOpen("deleteTagConfirmation", { a={ProjectPermissionSub.Tags}
name,
id: _id
})
}
colorSchema="danger"
ariaLabel="update"
> >
<FontAwesomeIcon icon={faTrashCan} /> {(isAllowed) => (
</IconButton> <IconButton
onClick={() =>
handlePopUpOpen("deleteTagConfirmation", {
name,
id: _id
})
}
colorSchema="danger"
ariaLabel="update"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td> </Td>
</Tr> </Tr>
))} ))}
@@ -3,7 +3,10 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2"; import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteServiceToken } from "@app/hooks/api"; import { useDeleteServiceToken } from "@app/hooks/api";
@@ -12,65 +15,76 @@ import { ServiceTokenTable } from "./ServiceTokenTable";
type DeleteModalData = { name: string; id: string }; type DeleteModalData = { name: string; id: string };
export const ServiceTokenSection = () => { export const ServiceTokenSection = withProjectPermission(
const { t } = useTranslation(); () => {
const { createNotification } = useNotificationContext(); const { t } = useTranslation();
const deleteServiceToken = useDeleteServiceToken(); const { createNotification } = useNotificationContext();
const deleteServiceToken = useDeleteServiceToken();
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"createAPIToken", "createAPIToken",
"deleteAPITokenConfirmation" "deleteAPITokenConfirmation"
] as const); ] as const);
const onDeleteApproved = async () => { const onDeleteApproved = async () => {
try { try {
deleteServiceToken.mutateAsync( deleteServiceToken.mutateAsync(
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id
); );
createNotification({ createNotification({
text: "Successfully deleted service token", text: "Successfully deleted service token",
type: "success" type: "success"
}); });
handlePopUpClose("deleteAPITokenConfirmation"); handlePopUpClose("deleteAPITokenConfirmation");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to delete service token", text: "Failed to delete service token",
type: "error" type: "error"
}); });
} }
}; };
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between"> <div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100"> <p className="text-xl font-semibold text-mineshaft-100">
{t("section.token.service-tokens")} {t("section.token.service-tokens")}
</p> </p>
<Button <ProjectPermissionCan
colorSchema="secondary" I={ProjectPermissionActions.Create}
leftIcon={<FontAwesomeIcon icon={faPlus} />} a={ProjectPermissionSub.ServiceTokens}
onClick={() => { >
handlePopUpOpen("createAPIToken"); {(isAllowed) => (
}} <Button
> colorSchema="secondary"
Create token leftIcon={<FontAwesomeIcon icon={faPlus} />}
</Button> onClick={() => {
handlePopUpOpen("createAPIToken");
}}
isDisabled={!isAllowed}
>
Create token
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteAPITokenConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " "
} service token?`}
onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)}
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteAPITokenConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div> </div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p> );
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} /> },
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens }
<DeleteActionModal );
isOpen={popUp.deleteAPITokenConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " "
} service token?`}
onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)}
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteAPITokenConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
};
@@ -1,6 +1,7 @@
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
@@ -13,7 +14,7 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetUserWsServiceTokens } from "@app/hooks/api"; import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -70,18 +71,26 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
</Td> </Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td> <Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td> <Td>
<IconButton <ProjectPermissionCan
onClick={() => I={ProjectPermissionActions.Delete}
handlePopUpOpen("deleteAPITokenConfirmation", { a={ProjectPermissionSub.ServiceTokens}
name: row.name,
id: row._id
})
}
colorSchema="danger"
ariaLabel="delete"
> >
<FontAwesomeIcon icon={faTrashCan} /> {(isAllowed) => (
</IconButton> <IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row._id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td> </Td>
</Tr> </Tr>
))} ))}
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
@@ -17,7 +18,8 @@ import {
Tooltip, Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useCreateWebhook, useCreateWebhook,
@@ -29,252 +31,291 @@ import {
import { AddWebhookForm, TFormSchema } from "./AddWebhookForm"; import { AddWebhookForm, TFormSchema } from "./AddWebhookForm";
export const WebhooksTab = () => { export const WebhooksTab = withProjectPermission(
const { t } = useTranslation(); () => {
const { createNotification } = useNotificationContext(); const { t } = useTranslation();
const { currentWorkspace } = useWorkspace(); const { createNotification } = useNotificationContext();
const workspaceId = currentWorkspace?._id || ""; const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ const workspaceId = currentWorkspace?._id || "";
"addWebhook", const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deleteWebhook" "addWebhook",
] as const); "deleteWebhook"
] as const);
const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId); const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId);
// mutation // mutation
const { mutateAsync: createWebhook } = useCreateWebhook(); const { mutateAsync: createWebhook } = useCreateWebhook();
const { const {
mutateAsync: testWebhook, mutateAsync: testWebhook,
variables: testWebhookVars, variables: testWebhookVars,
isLoading: isTestWebhookSubmitting isLoading: isTestWebhookSubmitting
} = useTestWebhook(); } = useTestWebhook();
const { const {
mutateAsync: updateWebhook, mutateAsync: updateWebhook,
variables: updateWebhookVars, variables: updateWebhookVars,
isLoading: isUpdateWebhookSubmitting isLoading: isUpdateWebhookSubmitting
} = useUpdateWebhook(); } = useUpdateWebhook();
const { mutateAsync: deleteWebhook } = useDeleteWebhook(); const { mutateAsync: deleteWebhook } = useDeleteWebhook();
const handleWebhookCreate = async (data: TFormSchema) => { const handleWebhookCreate = async (data: TFormSchema) => {
try { try {
await createWebhook({ await createWebhook({
...data, ...data,
workspaceId workspaceId
}); });
handlePopUpClose("addWebhook"); handlePopUpClose("addWebhook");
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully created webhook" text: "Successfully created webhook"
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
createNotification({ createNotification({
type: "error", type: "error",
text: "Failed to create webhook" text: "Failed to create webhook"
}); });
} }
}; };
const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => { const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => {
try { try {
await updateWebhook({ await updateWebhook({
webhookId, webhookId,
workspaceId, workspaceId,
isDisabled isDisabled
}); });
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully updated webhook" text: "Successfully updated webhook"
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
createNotification({ createNotification({
type: "error", type: "error",
text: "Failed to update webhook" text: "Failed to update webhook"
}); });
} }
}; };
const handleWebhookDelete = async () => { const handleWebhookDelete = async () => {
try { try {
const webhookId = popUp?.deleteWebhook?.data as string; const webhookId = popUp?.deleteWebhook?.data as string;
await deleteWebhook({ await deleteWebhook({
webhookId, webhookId,
workspaceId workspaceId
}); });
handlePopUpClose("deleteWebhook"); handlePopUpClose("deleteWebhook");
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully deleted webhook" text: "Successfully deleted webhook"
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
createNotification({ createNotification({
type: "error", type: "error",
text: "Failed to delete webhook" text: "Failed to delete webhook"
}); });
} }
}; };
const handleWebhookTest = async (webhookId: string) => { const handleWebhookTest = async (webhookId: string) => {
try { try {
await testWebhook({ await testWebhook({
webhookId, webhookId,
workspaceId workspaceId
}); });
createNotification({ createNotification({
type: "success", type: "success",
text: "Successfully triggered webhook" text: "Successfully triggered webhook"
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
createNotification({ createNotification({
type: "error", type: "error",
text: "Failed to trigger webhook" text: "Failed to trigger webhook"
}); });
} }
}; };
return ( return (
<div className="mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between"> <div className="flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">{t("settings.webhooks.title")}</p> <p className="text-xl font-semibold text-mineshaft-100">{t("settings.webhooks.title")}</p>
<Button <ProjectPermissionCan
onClick={() => handlePopUpOpen("addWebhook")} I={ProjectPermissionActions.Create}
leftIcon={<FontAwesomeIcon icon={faPlus} />} a={ProjectPermissionSub.Webhooks}
> >
Create {(isAllowed) => (
</Button> <Button
</div> onClick={() => handlePopUpOpen("addWebhook")}
<p className="mb-8 text-gray-400">{t("settings.webhooks.description")}</p> leftIcon={<FontAwesomeIcon icon={faPlus} />}
<div> isDisabled={!isAllowed}
<TableContainer> >
<Table> Create
<THead> </Button>
<Tr> )}
<Td>URL</Td> </ProjectPermissionCan>
<Td>Environment</Td> </div>
<Td>Secret Path</Td> <p className="mb-8 text-gray-400">{t("settings.webhooks.description")}</p>
<Td>Status</Td> <div>
<Td className="text-right">Action</Td> <TableContainer>
</Tr> <Table>
</THead> <THead>
<TBody>
{isWebhooksLoading && <TableSkeleton columns={5} innerKey="webhooks-loading" />}
{!isWebhooksLoading && webhooks && webhooks?.length === 0 && (
<Tr> <Tr>
<Td colSpan={5}> <Td>URL</Td>
<EmptyState title="No webhooks found" icon={faPlug} /> <Td>Environment</Td>
</Td> <Td>Secret Path</Td>
<Td>Status</Td>
<Td className="text-right">Action</Td>
</Tr> </Tr>
)} </THead>
{!isWebhooksLoading && <TBody>
webhooks?.map( {isWebhooksLoading && <TableSkeleton columns={5} innerKey="webhooks-loading" />}
({ {!isWebhooksLoading && webhooks && webhooks?.length === 0 && (
_id: id, <Tr>
url, <Td colSpan={5}>
environment, <EmptyState title="No webhooks found" icon={faPlug} />
secretPath, </Td>
lastStatus, </Tr>
isDisabled,
updatedAt,
lastRunErrorMessage
}) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>{environment}</Td>
<Td>{secretPath}</Td>
<Td>
{!lastStatus ? (
"-"
) : (
<div className="inline-flex w-min items-center rounded bg-mineshaft-600 px-2 py-0.5 text-sm">
{lastStatus}{" "}
<Tooltip
content={
<div className="text-xs">
<div>
Updated At:{" "}
{format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")}
</div>
{lastRunErrorMessage && (
<div className="mt-2 text-red">
Error: {lastRunErrorMessage}
</div>
)}
</div>
}
>
<FontAwesomeIcon
className={`ml-1 ${
lastStatus === "failed" ? "text-red" : "text-green"
}`}
icon={faInfoCircle}
/>
</Tooltip>
</div>
)}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<Button
variant="star"
size="xs"
onClick={() => handleWebhookTest(id)}
isDisabled={
isTestWebhookSubmitting && testWebhookVars?.webhookId === id
}
isLoading={isTestWebhookSubmitting && testWebhookVars?.webhookId === id}
>
Test
</Button>
<Button
variant="outline_bg"
size="xs"
onClick={() => handleWebhookDisable(id, !isDisabled)}
isDisabled={
isUpdateWebhookSubmitting && updateWebhookVars?.webhookId === id
}
isLoading={
isUpdateWebhookSubmitting && updateWebhookVars?.webhookId === id
}
>
{isDisabled ? "Enable" : "Disable"}
</Button>
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
onClick={() => handlePopUpOpen("deleteWebhook", id)}
>
Delete
</Button>
</div>
</Td>
</Tr>
)
)} )}
</TBody> {!isWebhooksLoading &&
</Table> webhooks?.map(
</TableContainer> ({
_id: id,
url,
environment,
secretPath,
lastStatus,
isDisabled,
updatedAt,
lastRunErrorMessage
}) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>{environment}</Td>
<Td>{secretPath}</Td>
<Td>
{!lastStatus ? (
"-"
) : (
<div className="inline-flex w-min items-center rounded bg-mineshaft-600 px-2 py-0.5 text-sm">
{lastStatus}{" "}
<Tooltip
content={
<div className="text-xs">
<div>
Updated At:{" "}
{format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")}
</div>
{lastRunErrorMessage && (
<div className="mt-2 text-red">
Error: {lastRunErrorMessage}
</div>
)}
</div>
}
>
<FontAwesomeIcon
className={`ml-1 ${
lastStatus === "failed" ? "text-red" : "text-green"
}`}
icon={faInfoCircle}
/>
</Tooltip>
</div>
)}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
variant="star"
size="xs"
onClick={() => handleWebhookTest(id)}
isDisabled={
(isTestWebhookSubmitting &&
testWebhookVars?.webhookId === id) ||
!isAllowed
}
isLoading={
isTestWebhookSubmitting && testWebhookVars?.webhookId === id
}
>
Test
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
onClick={() => handleWebhookDisable(id, !isDisabled)}
isDisabled={
(isUpdateWebhookSubmitting &&
updateWebhookVars?.webhookId === id) ||
!isAllowed
}
isLoading={
isUpdateWebhookSubmitting && updateWebhookVars?.webhookId === id
}
>
{isDisabled ? "Enable" : "Disable"}
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Webhooks}
>
{(isAllowed) => (
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteWebhook", id)}
>
Delete
</Button>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
)
)}
</TBody>
</Table>
</TableContainer>
</div>
<AddWebhookForm
environments={currentWorkspace?.environments}
isOpen={popUp?.addWebhook?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addWebhook", isOpen)}
onCreateWebhook={handleWebhookCreate}
/>
<DeleteActionModal
isOpen={popUp.deleteWebhook.isOpen}
deleteKey="remove"
title="Are you sure you want to delete this webhook?"
onChange={(isOpen) => handlePopUpToggle("deleteWebhook", isOpen)}
onClose={() => handlePopUpClose("deleteWebhook")}
onDeleteApproved={handleWebhookDelete}
/>
</div> </div>
<AddWebhookForm );
environments={currentWorkspace?.environments} },
isOpen={popUp?.addWebhook?.isOpen} { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Webhooks }
onOpenChange={(isOpen) => handlePopUpToggle("addWebhook", isOpen)} );
onCreateWebhook={handleWebhookCreate}
/>
<DeleteActionModal
isOpen={popUp.deleteWebhook.isOpen}
deleteKey="remove"
title="Are you sure you want to delete this webhook?"
onChange={(isOpen) => handlePopUpToggle("deleteWebhook", isOpen)}
onClose={() => handlePopUpClose("deleteWebhook")}
onDeleteApproved={handleWebhookDelete}
/>
</div>
);
};