From 6671699867f92f3ab4a9cd41f3e876995dccd61f Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 28 Aug 2023 14:03:00 +0530 Subject: [PATCH] feat(rbac): added new permission check for workspace in frontend --- .../controllers/v1/membershipController.ts | 43 +- .../src/controllers/v2/secretsController.ts | 11 +- backend/src/helpers/user.ts | 135 +- backend/src/routes/v1/membership.ts | 9 +- backend/src/services/ProjectRoleService.ts | 8 +- backend/src/types/secret/index.d.ts | 10 +- backend/src/validation/auth.ts | 2 +- backend/src/validation/secrets.ts | 18 +- backend/src/validation/workspace.ts | 12 +- .../ProjectPermissionContext.tsx | 23 +- .../ProjectPermissionContext/index.tsx | 2 +- .../context/ProjectPermissionContext/types.ts | 56 +- frontend/src/context/index.tsx | 5 +- frontend/src/hoc/index.tsx | 1 + .../src/hoc/withProjectPermission/index.tsx | 1 + .../withProjectPermission.tsx | 62 + .../src/pages/project/[id]/logs/index.tsx | 237 +- .../src/views/DashboardPage/DashboardPage.tsx | 1975 +++++++++-------- .../FolderSection/FolderSection.tsx | 70 +- .../SecretDetailDrawer/SecretDetailDrawer.tsx | 42 +- .../SecretDropzone/SecretDropzone.tsx | 632 +++--- .../SecretImportSection/SecretImportItem.tsx | 94 +- .../SecretInputRow/SecretInputRow.tsx | 44 +- .../Project/AuditLogsPage/AuditLogsPage.tsx | 32 +- .../IPAllowListPage/IPAllowlistPage.tsx | 28 +- .../components/IPAllowlistSection.tsx | 185 +- .../components/IPAllowlistTable.tsx | 290 +-- .../views/Project/MembersPage/MembersPage.tsx | 83 +- .../MemberListTab/MemberListTab.tsx | 24 +- .../ProjectRoleListTab/ProjectRoleListTab.tsx | 67 +- .../ProjectRoleList/ProjectRoleList.tsx | 83 +- .../ProjectRoleModifySection.tsx | 8 + .../WsProjectPermission.tsx | 127 ++ .../SecretOverviewPage/SecretOverviewPage.tsx | 15 +- .../SecretOverviewTableRow/SecretEditRow.tsx | 68 +- .../ProjectSettingsPage.tsx | 87 +- .../AutoCapitalizationSection.tsx | 98 +- .../DeleteProjectSection.tsx | 96 +- .../components/E2EESection/E2EESection.tsx | 160 +- .../EnvironmentSection/EnvironmentSection.tsx | 208 +- .../EnvironmentSection/EnvironmentTable.tsx | 57 +- .../ProjectIndexSecretsSection.tsx | 112 +- .../ProjectNameChangeSection.tsx | 73 +- .../SecretTagsSection/SecretTagsSection.tsx | 144 +- .../SecretTagsSection/SecretTagsTable.tsx | 33 +- .../ServiceTokenSection.tsx | 128 +- .../ServiceTokenSection/ServiceTokenTable.tsx | 33 +- .../components/WebhooksTab/WebhooksTab.tsx | 519 +++-- 48 files changed, 3446 insertions(+), 2804 deletions(-) create mode 100644 frontend/src/hoc/withProjectPermission/index.tsx create mode 100644 frontend/src/hoc/withProjectPermission/withProjectPermission.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx diff --git a/backend/src/controllers/v1/membershipController.ts b/backend/src/controllers/v1/membershipController.ts index 2c63525a2..49a169f8a 100644 --- a/backend/src/controllers/v1/membershipController.ts +++ b/backend/src/controllers/v1/membershipController.ts @@ -4,7 +4,7 @@ import { IUser, Key, Membership, MembershipOrg, User } from "../../models"; import { EventType } from "../../ee/models"; import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership"; import { sendMail } from "../../helpers/nodemailer"; -import { ACCEPTED, ADMIN, MEMBER } from "../../variables"; +import { ACCEPTED, ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; import { getSiteURL } from "../../config"; import { EEAuditLogService } from "../../ee/services"; import { validateRequest } from "../../helpers/validation"; @@ -15,6 +15,8 @@ import { getUserProjectPermissions } from "../../services/ProjectRoleService"; 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] @@ -107,10 +109,6 @@ export const changeMembershipRole = async (req: Request, res: Response) => { params: { membershipId } } = await validateRequest(reqValidator.ChangeMembershipRoleV1, req); - if (![ADMIN, MEMBER].includes(role)) { - throw new Error("Failed to validate role"); - } - // validate target membership const membershipToChangeRole = await Membership.findById(membershipId).populate<{ user: IUser }>( "user" @@ -129,9 +127,32 @@ export const changeMembershipRole = async (req: Request, res: Response) => { ProjectPermissionSub.Member ); - const oldRole = membershipToChangeRole.role; - membershipToChangeRole.role = role; - await membershipToChangeRole.save(); + const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + if (isCustomRole) { + 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( req.authData, @@ -140,8 +161,8 @@ export const changeMembershipRole = async (req: Request, res: Response) => { metadata: { userId: membershipToChangeRole.user._id.toString(), email: membershipToChangeRole.user.email, - oldRole, - newRole: membershipToChangeRole.role + oldRole: membershipToChangeRole.role, + newRole: role } }, { @@ -150,7 +171,7 @@ export const changeMembershipRole = async (req: Request, res: Response) => { ); return res.status(200).send({ - membership: membershipToChangeRole + membership }); }; diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 60fd983e4..24be055aa 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -25,7 +25,6 @@ import { userHasWriteOnlyAbility } from "../../ee/helpers/checkMembershipPermissions"; import _ from "lodash"; -import { BatchSecret } from "../../types/secret"; import { getFolderByPath, getFolderIdFromServiceToken, @@ -56,8 +55,8 @@ export const batchSecrets = async (req: Request, res: Response) => { body: { secretPath, folderId } } = validatedData; - const createSecrets: BatchSecret[] = []; - const updateSecrets: BatchSecret[] = []; + const createSecrets: any[] = []; + const updateSecrets: any[] = []; const deleteSecrets: { _id: Types.ObjectId; secretName: string }[] = []; const actions: IAction[] = []; @@ -111,7 +110,7 @@ export const batchSecrets = async (req: Request, res: Response) => { version: 1, user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, environment, - workspace: new Types.ObjectId(workspaceId), + workspace: workspaceId, folder: folderId, secretBlindIndex, algorithm: ALGORITHM_AES_256_GCM, @@ -126,7 +125,7 @@ export const batchSecrets = async (req: Request, res: Response) => { updateSecrets.push({ ...request.secret, - _id: new Types.ObjectId(request.secret._id), + _id: request.secret._id, secretBlindIndex, folder: folderId, algorithm: ALGORITHM_AES_256_GCM, @@ -145,7 +144,7 @@ export const batchSecrets = async (req: Request, res: Response) => { // handle create secrets let createdSecrets: ISecret[] = []; if (createSecrets.length > 0) { - createdSecrets = await Secret.insertMany(createSecrets); + createdSecrets = (await Secret.insertMany(createSecrets)) as any; // (EE) add secret versions for new secrets await EESecretService.addSecretVersions({ secretVersions: createdSecrets.map((n: any) => { diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index 59030ff8c..728c7d089 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,7 +1,4 @@ -import { - IUser, - User, -} from "../models"; +import { IUser, User } from "../models"; import { sendMail } from "./nodemailer"; /** @@ -12,10 +9,10 @@ import { sendMail } from "./nodemailer"; */ export const setupAccount = async ({ email }: { email: string }) => { const user = await new User({ - email, + email }).save(); - return user; + return user; }; /** @@ -37,36 +34,36 @@ export const setupAccount = async ({ email }: { email: string }) => { * @returns {Object} user - the completed user */ export const completeAccount = async ({ - userId, - firstName, - lastName, - encryptionVersion, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - salt, - verifier, + userId, + firstName, + lastName, + encryptionVersion, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier }: { - userId: string; - firstName: string; - lastName: string; - encryptionVersion: number; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - publicKey: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - salt: string; - verifier: string; + userId: string; + firstName: string; + lastName?: string; + encryptionVersion: number; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; }) => { const options = { - new: true, + new: true }; const user = await User.findByIdAndUpdate( userId, @@ -82,12 +79,12 @@ export const completeAccount = async ({ iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, salt, - verifier, + verifier }, options ); - return user; + return user; }; /** @@ -98,38 +95,42 @@ export const completeAccount = async ({ * @param {String} obj.userAgent - login user-agent */ export const checkUserDevice = async ({ - user, - ip, - userAgent, + user, + ip, + userAgent }: { - user: IUser; - ip: string; - userAgent: string; + user: IUser; + ip: string; + userAgent: string; }) => { - 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(); + const isDeviceSeen = user.devices.some( + (device) => device.ip === ip && device.userAgent === userAgent + ); - // 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, - }, - }); - } -} \ No newline at end of file + 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] + await sendMail({ + template: "newDevice.handlebars", + subjectLine: "Successful login from new device", + recipients: [user.email], + substitutions: { + email: user.email, + timestamp: new Date().toString(), + ip, + userAgent + } + }); + } +}; diff --git a/backend/src/routes/v1/membership.ts b/backend/src/routes/v1/membership.ts index fc2070133..54b3b7c9e 100644 --- a/backend/src/routes/v1/membership.ts +++ b/backend/src/routes/v1/membership.ts @@ -1,7 +1,6 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; -import { requireAuth, validateRequest } from "../../middleware"; +import { requireAuth } from "../../middleware"; import { membershipController } from "../../controllers/v1"; import { AuthMode } from "../../variables"; @@ -14,8 +13,6 @@ router.get( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - param("workspaceId").exists().trim(), - validateRequest, membershipController.validateMembership ); @@ -25,8 +22,6 @@ router.delete( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - param("membershipId").exists().trim(), - validateRequest, membershipController.deleteMembership ); @@ -36,8 +31,6 @@ router.post( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - body("role").exists().trim(), - validateRequest, membershipController.changeMembershipRole ); diff --git a/backend/src/services/ProjectRoleService.ts b/backend/src/services/ProjectRoleService.ts index 6a42d6de3..f3e8b754a 100644 --- a/backend/src/services/ProjectRoleService.ts +++ b/backend/src/services/ProjectRoleService.ts @@ -34,7 +34,7 @@ export enum ProjectPermissionSub { Folders = "folders" } -type GenericFields = { +type SubjectFields = { environment: string; secretPath?: string; }; @@ -42,17 +42,17 @@ type GenericFields = { export type ProjectPermissionSet = | [ ProjectPermissionActions, - ProjectPermissionSub.Secrets | (ForcedSubject & GenericFields) + ProjectPermissionSub.Secrets | (ForcedSubject & SubjectFields) ] | [ ProjectPermissionActions, - ProjectPermissionSub.Folders | (ForcedSubject & GenericFields) + ProjectPermissionSub.Folders | (ForcedSubject & SubjectFields) ] | [ ProjectPermissionActions, ( | ProjectPermissionSub.SecretImports - | (ForcedSubject & GenericFields) + | (ForcedSubject & SubjectFields) ) ] | [ProjectPermissionActions, ProjectPermissionSub.Role] diff --git a/backend/src/types/secret/index.d.ts b/backend/src/types/secret/index.d.ts index f60fdfd4c..05016b38e 100644 --- a/backend/src/types/secret/index.d.ts +++ b/backend/src/types/secret/index.d.ts @@ -28,7 +28,13 @@ export interface BatchSecretRequest { } export interface BatchSecret { - _id: string; + version?: number; + _id?: string; + user?: string; + environment: string; + workspace?: string; + algorithm?: string; + keyEncoding?: string; type: "shared" | "personal"; secretName: string; secretBlindIndex: string; @@ -42,5 +48,5 @@ export interface BatchSecret { secretCommentIV: string; secretCommentTag: string; tags: string[]; - folder: string + folder: string; } diff --git a/backend/src/validation/auth.ts b/backend/src/validation/auth.ts index 3cf292477..e9ffec8de 100644 --- a/backend/src/validation/auth.ts +++ b/backend/src/validation/auth.ts @@ -117,7 +117,7 @@ export const CompletedAccountSignupV3 = z.object({ body: z.object({ email: z.string().email().trim(), firstName: z.string().trim(), - lastName: z.string().trim().optional().nullish(), + lastName: z.string().trim().optional(), protectedKey: z.string().trim(), protectedKeyIV: z.string().trim(), protectedKeyTag: z.string().trim(), diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index a908a0fba..0e733112f 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -115,8 +115,8 @@ export const GetSecretVersionsV1 = z.object({ secretId: z.string().trim() }), query: z.object({ - offset: z.number(), - limit: z.number() + offset: z.coerce.number(), + limit: z.coerce.number() }) }); @@ -175,17 +175,19 @@ const batchUpdateRequestV2 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), - tags: z.object({ - _id: z.string().trim(), - name: z.string().trim(), - slug: z.string().trim() - }) + tags: z + .object({ + _id: z.string().trim(), + name: z.string().trim(), + slug: z.string().trim() + }) + .array() }); export const BatchSecretsV2 = z.object({ body: z.object({ workspaceId: z.string().trim(), - folderId: z.string().trim(), + folderId: z.string().trim().default("root"), environment: z.string().trim(), secretPath: z.string().trim().optional(), requests: z diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index 6058e3e83..d6a42a55f 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -130,8 +130,8 @@ export const GetWorkspaceSecretSnapshotsV1 = z.object({ query: z.object({ environment: z.string().trim(), folderId: z.string().trim().default("root"), - offset: z.number(), - limit: z.number() + offset: z.coerce.number(), + limit: z.coerce.number() }) }); @@ -161,8 +161,8 @@ export const GetWorkspaceLogsV1 = z.object({ workspaceId: z.string().trim() }), query: z.object({ - offset: z.number(), - limit: z.number(), + offset: z.coerce.number(), + limit: z.coerce.number(), sortBy: z.string().trim().optional(), userId: z.string().trim().optional(), actionNames: z.string().trim().optional() @@ -178,8 +178,8 @@ export const GetWorkspaceAuditLogsV1 = z.object({ userAgentType: z.nativeEnum(UserAgentType).nullable().optional(), startDate: z.string().datetime().nullable().optional(), endDate: z.string().datetime().nullable().optional(), - offset: z.number(), - limit: z.number(), + offset: z.coerce.number(), + limit: z.coerce.number(), actor: z.string().nullish().optional() }) }); diff --git a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx index 6fe0b6e29..087882263 100644 --- a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx +++ b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx @@ -16,6 +16,15 @@ export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => { const workspaceId = currentWorkspace?._id || ""; const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId }); + console.log(workspaceId); + if (!permission && currentWorkspace) { + return ( +
+ Failed to load user permissions +
+ ); + } + if (isLoading && workspaceId) { return (
@@ -29,20 +38,8 @@ export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => { ); } - if (!permission && currentWorkspace) { - return ( -
- Failed to load user permissions -
- ); - } - - if (!permission) { - return <>children; - } - return ( - + {children} ); diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index 209b3a24e..68da4a459 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -1,3 +1,3 @@ export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext"; export type { ProjectPermissionSet, TProjectPermission } from "./types"; -export { ProjectGeneralPermissionActions, ProjectPermissionSubjects } from "./types"; +export { ProjectPermissionActions,ProjectPermissionSub } from "./types"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 3f4559251..d81ed068e 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -1,13 +1,13 @@ -import { MongoAbility } from "@casl/ability"; +import { ForcedSubject, MongoAbility } from "@casl/ability"; -export enum ProjectGeneralPermissionActions { +export enum ProjectPermissionActions { Read = "read", Create = "create", Edit = "edit", Delete = "delete" } -export enum ProjectPermissionSubjects { +export enum ProjectPermissionSub { Role = "role", Member = "member", Settings = "settings", @@ -21,24 +21,44 @@ export enum ProjectPermissionSubjects { Workspace = "workspace", Secrets = "secrets", SecretImports = "secret-imports", + SecretRollback = "secret-rollback", Folders = "folders" } +type SubjectFields = { + environment: string; + secretPath?: string; +}; + export type ProjectPermissionSet = - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Secrets] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Folders] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.SecretImports] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Role] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Tags] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Member] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Integrations] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Webhooks] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.AuditLogs] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Environments] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.IpAllowList] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Settings] - | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.ServiceTokens] - | [ProjectGeneralPermissionActions.Delete, ProjectPermissionSubjects.Workspace] - | [ProjectGeneralPermissionActions.Edit, ProjectPermissionSubjects.Workspace]; + | [ + ProjectPermissionActions, + ProjectPermissionSub.Secrets | (ForcedSubject & SubjectFields) + ] + | [ + ProjectPermissionActions, + ProjectPermissionSub.Folders | (ForcedSubject & SubjectFields) + ] + | [ + ProjectPermissionActions, + ( + | ProjectPermissionSub.SecretImports + | (ForcedSubject & SubjectFields) + ) + ] + | [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; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 3234adccd..491fa2439 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -7,10 +7,11 @@ export { OrgPermissionSubjects, useOrgPermission } from "./OrgPermissionContext"; +export type { TProjectPermission } from "./ProjectPermissionContext"; export { - ProjectGeneralPermissionActions, + ProjectPermissionActions, ProjectPermissionProvider, - ProjectPermissionSubjects, + ProjectPermissionSub, useProjectPermission } from "./ProjectPermissionContext"; export { SubscriptionProvider, useSubscription } from "./SubscriptionContext"; diff --git a/frontend/src/hoc/index.tsx b/frontend/src/hoc/index.tsx index d5dfb8dc0..4efe5f1f9 100644 --- a/frontend/src/hoc/index.tsx +++ b/frontend/src/hoc/index.tsx @@ -1 +1,2 @@ export { withPermission } from "./withPermission"; +export { withProjectPermission } from "./withProjectPermission"; diff --git a/frontend/src/hoc/withProjectPermission/index.tsx b/frontend/src/hoc/withProjectPermission/index.tsx new file mode 100644 index 000000000..34171ed28 --- /dev/null +++ b/frontend/src/hoc/withProjectPermission/index.tsx @@ -0,0 +1 @@ +export { withProjectPermission } from "./withProjectPermission"; diff --git a/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx b/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx new file mode 100644 index 000000000..911703973 --- /dev/null +++ b/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx @@ -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 AbilityTuple + ? { + action: T[0]; + subject: Extract; + } + : { + action: string; + subject: string; + }) & { className?: string; containerClassName?: string }; + +export const withProjectPermission = ( + Component: ComponentType, + { action, subject, className, containerClassName }: Props["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 ( +
+
+
+ +
+
+
Permission Denied
+
+ You do not have permission to this page.
Kindly contact your organization + administrator +
+
+
+
+ ); + } + + return ; + }; + + HOC.displayName = "WithProjectPermission"; + return HOC; +}; diff --git a/frontend/src/pages/project/[id]/logs/index.tsx b/frontend/src/pages/project/[id]/logs/index.tsx index 0c44abbef..cc6f79457 100644 --- a/frontend/src/pages/project/[id]/logs/index.tsx +++ b/frontend/src/pages/project/[id]/logs/index.tsx @@ -6,8 +6,9 @@ import { useRouter } from "next/router"; import Button from "@app/components/basic/buttons/Button"; import EventFilter from "@app/components/basic/EventFilter"; 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 { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks/usePopUp"; import getProjectLogs from "../../../../ee/api/secrets/GetProjectLogs"; @@ -23,10 +24,10 @@ interface LogData { }; serviceAccount?: { string: string; - }, + }; serviceTokenData?: { name: string; - } + }; actions: { _id: string; name: string; @@ -60,67 +61,33 @@ interface LogDataPoint { /** * This is the tab that includes all of the user activity logs */ -export default function Activity() { - const router = useRouter(); - const [eventChosen, setEventChosen] = useState(""); - const [logsData, setLogsData] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [currentOffset, setCurrentOffset] = useState(0); - const currentLimit = 10; - const [currentSidebarAction, toggleSidebar] = useState(); - const { t } = useTranslation(); - const { subscription } = useSubscription(); - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "upgradePlan" - ] as const); +const Activity = withProjectPermission( + () => { + const router = useRouter(); + const [eventChosen, setEventChosen] = useState(""); + const [logsData, setLogsData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentOffset, setCurrentOffset] = useState(0); + const currentLimit = 10; + const [currentSidebarAction, toggleSidebar] = useState(); + const { t } = useTranslation(); + const { subscription } = useSubscription(); + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["upgradePlan"] as const); - // this use effect updates the data in case of a new filter being added - useEffect(() => { - setCurrentOffset(0); - const getLogData = async () => { - setIsLoading(true); - const tempLogsData = await getProjectLogs({ - workspaceId: String(router.query.id), - offset: 0, - limit: currentLimit, - userId: "", - actionNames: eventChosen - }); + // this use effect updates the data in case of a new filter being added + useEffect(() => { + setCurrentOffset(0); + const getLogData = async () => { + setIsLoading(true); + const tempLogsData = await getProjectLogs({ + workspaceId: String(router.query.id), + offset: 0, + limit: currentLimit, + userId: "", + actionNames: eventChosen + }); - 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( + setLogsData( tempLogsData.map((log: LogData) => ({ _id: log._id, channel: log.channel, @@ -135,63 +102,103 @@ export default function Activity() { 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 = () => { - if (subscription?.auditLogs === false) { - handlePopUpOpen("upgradePlan"); - } else { - setCurrentOffset(currentOffset + currentLimit); - } - }; - - return ( -
- - Audit Logs - - - - {currentSidebarAction && ( - - )} -
-
-

{t("activity.title")}

+ return ( +
+ + Audit Logs + + + + {currentSidebarAction && ( + + )} +
+
+

{t("activity.title")}

+
+

{t("activity.subtitle")}

-

{t("activity.subtitle")}

-
-
- -
- -
-
-
+ +
+
+
+
+ {subscription && ( + 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." + } /> -
+ )}
- {subscription && ( - 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."} - /> - )} -
- ); -} + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } +); -Activity.requireAuth = true; +Object.assign(Activity, { requireAuth: true }); +export default Activity; diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index faaaeb49c..e4895a7cd 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -39,6 +39,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import NavHeader from "@app/components/navigation/NavHeader"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -55,7 +56,8 @@ import { UpgradePlanModal } from "@app/components/v2"; import { leaveConfirmDefaultMessage } from "@app/const"; -import { useOrganization, useSubscription, useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub,useOrganization, useSubscription, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks"; import { useBatchSecretsOp, @@ -123,1035 +125,1106 @@ type TDeleteSecretImport = { environment: string; secretPath: string }; * Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving * They will get it back */ -export const DashboardPage = () => { - const { subscription } = useSubscription(); - const { t } = useTranslation(); - const router = useRouter(); - const { createNotification } = useNotificationContext(); - const queryClient = useQueryClient(); - const envQuery = router.query.env as string; +export const DashboardPage = withProjectPermission( + () => { + const { subscription } = useSubscription(); + const { t } = useTranslation(); + const router = useRouter(); + const { createNotification } = useNotificationContext(); + const queryClient = useQueryClient(); + const envQuery = router.query.env as string; - const secretContainer = useRef(null); - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "secretDetails", - "addTag", - "secretSnapshots", - "uploadedSecOpts", - "compareSecrets", - "folderForm", - "deleteFolder", - "upgradePlan", - "addSecretImport", - "deleteSecretImport" - ] as const); - const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); - const [searchFilter, setSearchFilter] = useState(""); - const [snapshotId, setSnaphotId] = useState(null); - const [selectedEnv, setSelectedEnv] = useState(null); - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - const deletedSecretIds = useRef<{ id: string; secretName: string; }[]>([]); - const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); + const secretContainer = useRef(null); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "secretDetails", + "addTag", + "secretSnapshots", + "uploadedSecOpts", + "compareSecrets", + "folderForm", + "deleteFolder", + "upgradePlan", + "addSecretImport", + "deleteSecretImport" + ] as const); + const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); + const [searchFilter, setSearchFilter] = useState(""); + const [snapshotId, setSnaphotId] = useState(null); + const [selectedEnv, setSelectedEnv] = useState(null); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const deletedSecretIds = useRef<{ id: string; secretName: string }[]>([]); + const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); - const folderId = router.query.folderId as string; - const isRollbackMode = Boolean(snapshotId); + const folderId = router.query.folderId as string; + const isRollbackMode = Boolean(snapshotId); - const { currentWorkspace, isLoading } = useWorkspace(); - const { currentOrg } = useOrganization(); - const workspaceId = currentWorkspace?._id as string; - const selectedEnvSlug = selectedEnv?.slug || ""; + const { currentWorkspace, isLoading } = useWorkspace(); + const { currentOrg } = useOrganization(); + const workspaceId = currentWorkspace?._id as string; + const selectedEnvSlug = selectedEnv?.slug || ""; - const { data: latestFileKey } = useGetUserWsKey(workspaceId); + const { data: latestFileKey } = useGetUserWsKey(workspaceId); - useEffect(() => { - if (!isLoading && !workspaceId && router.isReady) { - router.push(`/org/${currentOrg?._id}/overview`); - } - }, [isLoading, workspaceId, router.isReady]); - - // fetching data - const { data: userAction } = useGetUserAction(USER_ACTION_PUSH); - const hasUserPushed = Boolean(userAction); - - const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({ - workspaceId, - onSuccess: (data) => { - // get an env with one of the access available - const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied); - if (env && data?.map((wsenv) => wsenv.slug).includes(envQuery)) { - setSelectedEnv(data?.filter((dp) => dp.slug === envQuery)[0]); + useEffect(() => { + if (!isLoading && !workspaceId && router.isReady) { + router.push(`/org/${currentOrg?._id}/overview`); } - } - }); + }, [isLoading, workspaceId, router.isReady]); - const { data: secretVersion } = useGetSecretVersion({ - limit: 10, - offset: 0, - secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id, - decryptFileKey: latestFileKey! - }); + // fetching data + const { data: userAction } = useGetUserAction(USER_ACTION_PUSH); + const hasUserPushed = Boolean(userAction); - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ - workspaceId, - env: selectedEnvSlug, - decryptFileKey: latestFileKey!, - isPaused: Boolean(snapshotId), - folderId - }); + const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({ + workspaceId, + onSuccess: (data) => { + // get an env with one of the access available + const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied); + if (env && data?.map((wsenv) => wsenv.slug).includes(envQuery)) { + setSelectedEnv(data?.filter((dp) => dp.slug === envQuery)[0]); + } + } + }); - const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ - workspaceId: workspaceId || "", - environment: selectedEnvSlug, - parentFolderId: folderId, - isPaused: isRollbackMode, - sortDir - }); + const { data: secretVersion } = useGetSecretVersion({ + limit: 10, + offset: 0, + secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id, + decryptFileKey: latestFileKey! + }); - const { - data: secretSnaphots, - fetchNextPage, - hasNextPage, - isFetchingNextPage - } = useGetWorkspaceSecretSnapshots({ - workspaceId, - environment: selectedEnvSlug, - folder: folderId, - limit: 10 - }); + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + env: selectedEnvSlug, + decryptFileKey: latestFileKey!, + isPaused: Boolean(snapshotId), + folderId + }); - const { - data: snapshotSecret, - isLoading: isSnapshotSecretsLoading, - isFetching: isSnapshotChanging - } = useGetSnapshotSecrets({ - snapshotId: snapshotId || "", - env: selectedEnvSlug, - decryptFileKey: latestFileKey! - }); + const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ + workspaceId: workspaceId || "", + environment: selectedEnvSlug, + parentFolderId: folderId, + isPaused: isRollbackMode, + sortDir + }); - const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( - workspaceId, - selectedEnvSlug, - folderId - ); + const { + data: secretSnaphots, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useGetWorkspaceSecretSnapshots({ + workspaceId, + environment: selectedEnvSlug, + folder: folderId, + limit: 10 + }); - const { data: wsTags } = useGetWsTags(workspaceId); + const { + data: snapshotSecret, + isLoading: isSnapshotSecretsLoading, + isFetching: isSnapshotChanging + } = useGetSnapshotSecrets({ + snapshotId: snapshotId || "", + env: selectedEnvSlug, + decryptFileKey: latestFileKey! + }); - // mutation calls - const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); - const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); - const { mutateAsync: registerUserAction } = useRegisterUserAction(); - const { mutateAsync: createWsTag } = useCreateWsTag(); - const { mutateAsync: createFolder } = useCreateFolder(); - const { mutateAsync: updateFolder } = useUpdateFolder(folderId); - const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); + const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( + workspaceId, + selectedEnvSlug, + folderId + ); - const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( - workspaceId, - selectedEnvSlug, - folderId - ); + const { data: wsTags } = useGetWsTags(workspaceId); - const { data: importedSecrets } = useGetImportedSecrets({ - workspaceId, - decryptFileKey: latestFileKey!, - environment: selectedEnvSlug, - folderId - }); + // mutation calls + const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); + const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); + const { mutateAsync: registerUserAction } = useRegisterUserAction(); + const { mutateAsync: createWsTag } = useCreateWsTag(); + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: updateFolder } = useUpdateFolder(folderId); + const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); - // This is for dnd-kit. As react-query state mutation async - // This will act as a placeholder to avoid a glitching animation on dropping items - const [items, setItems] = useState< - Array<{ environment: string; secretPath: string; id: string }> - >([]); + const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( + workspaceId, + selectedEnvSlug, + folderId + ); - useEffect(() => { - if ( - !isSecretImportCfgFetching || - // case in which u go to a folder and come back to fill in with cache data - (items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching) - ) { - setItems( - secretImportCfg?.imports?.map((el) => ({ - ...el, - id: `${el.environment}-${el.secretPath}` - })) || [] + const { data: importedSecrets } = useGetImportedSecrets({ + workspaceId, + decryptFileKey: latestFileKey!, + environment: selectedEnvSlug, + folderId + }); + + // This is for dnd-kit. As react-query state mutation async + // This will act as a placeholder to avoid a glitching animation on dropping items + const [items, setItems] = useState< + Array<{ environment: string; secretPath: string; id: string }> + >([]); + + useEffect(() => { + if ( + !isSecretImportCfgFetching || + // case in which u go to a folder and come back to fill in with cache data + (items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching) + ) { + setItems( + secretImportCfg?.imports?.map((el) => ({ + ...el, + id: `${el.environment}-${el.secretPath}` + })) || [] + ); + } + }, [isSecretImportCfgFetching]); + + const { mutateAsync: createSecretImport } = useCreateSecretImport(); + const { mutate: updateSecretImportSync } = useUpdateSecretImport(); + const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + const method = useForm({ + // why any: well yup inferred ts expects other keys to defined as undefined + defaultValues: secrets as any, + values: secrets as any, + mode: "onBlur", + resolver: yupResolver(schema) + }); + + const { + register, + control, + handleSubmit, + getValues, + setValue, + formState: { isSubmitting, isDirty, errors }, + reset + } = method; + const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); + const isReadOnly = selectedEnv?.isWriteDenied; + const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied; + const canDoRollback = !isReadOnly && !isAddOnly; + const isSubmitDisabled = + isReadOnly || (!isRollbackMode && !isDirty) || isAddOnly || isSubmitting; + + useEffect(() => { + if (!isSnapshotChanging && Boolean(snapshotId)) { + reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true }); + } + }, [isSnapshotChanging]); + + useEffect(() => { + setHasUnsavedChanges(!isSubmitDisabled); + }, [isSubmitDisabled]); + + const onSortSecrets = () => { + const dir = sortDir === "asc" ? "desc" : "asc"; + const sec = getValues("secrets") || []; + const sortedSec = sec.sort((a, b) => + dir === "asc" ? a?.key?.localeCompare(b?.key || "") : b?.key?.localeCompare(a?.key || "") ); - } - }, [isSecretImportCfgFetching]); + setValue("secrets", sortedSec); + setSortDir(dir); + }; - const { mutateAsync: createSecretImport } = useCreateSecretImport(); - const { mutate: updateSecretImportSync } = useUpdateSecretImport(); - const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); + const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt["secrets"]) => { + const sec = getValues("secrets") || []; + const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key])); + const conflictingSecIds = conflictingSec.reduce>( + (prev, curr) => ({ + ...prev, + [curr.key]: true + }), + {} + ); + // filter to get all conflicting ones + const conflictingUploadedSec = { ...uploadedSec }; + // append non conflicting ones + Object.keys(uploadedSec).forEach((key) => { + if (!conflictingSecIds?.[key]) { + delete conflictingUploadedSec[key]; + sec.push({ + ...DEFAULT_SECRET_VALUE, + key, + value: uploadedSec[key].value, + comment: uploadedSec[key].comments.join(",") + }); + } + }); + setValue("secrets", sec, { shouldDirty: true }); + if (conflictingSec.length > 0) { + handlePopUpOpen("uploadedSecOpts", { secrets: conflictingUploadedSec }); + } + }; - const sensors = useSensors( - useSensor(MouseSensor, {}), - useSensor(TouchSensor, {}), - useSensor(KeyboardSensor, {}) - ); - - const method = useForm({ - // why any: well yup inferred ts expects other keys to defined as undefined - defaultValues: secrets as any, - values: secrets as any, - mode: "onBlur", - resolver: yupResolver(schema) - }); - - - const { - register, - control, - handleSubmit, - getValues, - setValue, - formState: { isSubmitting, isDirty, errors }, - reset - } = method; - const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); - const isReadOnly = selectedEnv?.isWriteDenied; - const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied; - const canDoRollback = !isReadOnly && !isAddOnly; - const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isAddOnly || isSubmitting; - - useEffect(() => { - if (!isSnapshotChanging && Boolean(snapshotId)) { - reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true }); - } - }, [isSnapshotChanging]); - - useEffect(() => { - setHasUnsavedChanges(!isSubmitDisabled); - }, [isSubmitDisabled]); - - const onSortSecrets = () => { - const dir = sortDir === "asc" ? "desc" : "asc"; - const sec = getValues("secrets") || []; - const sortedSec = sec.sort((a, b) => - dir === "asc" ? a?.key?.localeCompare(b?.key || "") : b?.key?.localeCompare(a?.key || "") - ); - setValue("secrets", sortedSec); - setSortDir(dir); - }; - - const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt["secrets"]) => { - const sec = getValues("secrets") || []; - const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key])); - const conflictingSecIds = conflictingSec.reduce>( - (prev, curr) => ({ - ...prev, - [curr.key]: true - }), - {} - ); - // filter to get all conflicting ones - const conflictingUploadedSec = { ...uploadedSec }; - // append non conflicting ones - Object.keys(uploadedSec).forEach((key) => { - if (!conflictingSecIds?.[key]) { - delete conflictingUploadedSec[key]; - sec.push({ + const onOverwriteSecrets = () => { + const sec = getValues("secrets") || []; + const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets; + const data: Array<{ key: string; index: number }> = []; + sec.forEach(({ key }, index) => { + if (uploadedSec?.[key]) data.push({ key, index }); + }); + data.forEach(({ key, index }) => { + const { value, comments } = uploadedSec[key]; + const comment = comments.join(", "); + sec[index] = { ...DEFAULT_SECRET_VALUE, key, - value: uploadedSec[key].value, - comment: uploadedSec[key].comments.join(",") - }); - } - }); - setValue("secrets", sec, { shouldDirty: true }); - if (conflictingSec.length > 0) { - handlePopUpOpen("uploadedSecOpts", { secrets: conflictingUploadedSec }); - } - }; - - const onOverwriteSecrets = () => { - const sec = getValues("secrets") || []; - const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets; - const data: Array<{ key: string; index: number }> = []; - sec.forEach(({ key }, index) => { - if (uploadedSec?.[key]) data.push({ key, index }); - }); - data.forEach(({ key, index }) => { - const { value, comments } = uploadedSec[key]; - const comment = comments.join(", "); - sec[index] = { - ...DEFAULT_SECRET_VALUE, - key, - value, - comment, - tags: sec[index].tags - }; - }); - setValue("secrets", sec, { shouldDirty: true }); - handlePopUpClose("uploadedSecOpts"); - }; - - const onSecretRollback = async () => { - if (!snapshotSecret?.version) { - createNotification({ - text: "Failed to find secret version", - type: "success" + value, + comment, + tags: sec[index].tags + }; }); - return; - } - try { - await performSecretRollback({ - workspaceId, - version: snapshotSecret.version, - environment: selectedEnvSlug, - folderId - }); - setValue("isSnapshotMode", false); - setSnaphotId(null); - queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug)); - createNotification({ - text: "Successfully rollback secrets", - type: "success" - }); - } catch (error) { - console.log(error); - createNotification({ - text: "Failed to rollback secrets", - type: "error" - }); - } - }; + setValue("secrets", sec, { shouldDirty: true }); + handlePopUpClose("uploadedSecOpts"); + }; - const onAppendSecret = () => { - setSearchFilter(""); - append(DEFAULT_SECRET_VALUE); - }; - - const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { - if (isSnapshotMode) { - await onSecretRollback(); - return; - } - // just closing this if save is triggered from drawer - handlePopUpClose("secretDetails"); - // when add only mode remove rest of things not created - const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec; - // encrypt and format the secrets to batch api format - // requests = [ {method:"", secret:""} ] - const batchedSecret = transformSecretsToBatchSecretReq( - deletedSecretIds.current, - latestFileKey, - sec, - secrets?.secrets - ); - // type check - if (!selectedEnv?.slug) return; - if (batchedSecret.length === 0) { - reset(); - return; - } - try { - await batchSecretOp({ - requests: batchedSecret, - workspaceId, - folderId, - environment: selectedEnv?.slug - }); - createNotification({ - text: "Successfully saved changes", - type: "success" - }); - deletedSecretIds.current = []; - if (!hasUserPushed) { - await registerUserAction(USER_ACTION_PUSH); - } - } catch (error) { - console.log(error); - createNotification({ - text: "Failed to save changes", - type: "error" - }); - } - }; - - const onDrawerOpen = useCallback((id: string | undefined, index: number) => { - handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen); - }, []); - - const onEnvChange = (slug: string) => { - if (hasUnsavedChanges) { - // eslint-disable-next-line no-alert - if (!window.confirm(leaveConfirmDefaultMessage)) return; - } - const env = wsEnv?.find((el) => el.slug === slug); - if (env) setSelectedEnv(env); - const query: Record = { ...router.query, env: slug }; - delete query.folderId; - router.push({ - pathname: router.pathname, - query - }); - }; - - const handleDownloadSecret = () => { - const secretsFromImport: { key: string; value: string; comment: string }[] = []; - importedSecrets?.forEach(({ secrets: impSec }) => { - impSec.forEach((el) => { - secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment }); - }); - }); - downloadSecret(getValues("secrets"), secretsFromImport, selectedEnv?.slug); - }; - - // record all deleted ids - // This will make final deletion easier - const onSecretDelete = useCallback((index: number, secretName: string, id?: string, overrideId?: string) => { - if (id) deletedSecretIds.current.push({ - id, - secretName - }); - if (overrideId) deletedSecretIds.current.push({ - id: overrideId, - secretName - }); - remove(index); - // just the case if this is called from drawer - handlePopUpClose("secretDetails"); - }, []); - - const onCreateWsTag = useCallback( - async (tagName: string, tagColor: string) => { - try { - await createWsTag({ - workspaceID: workspaceId, - tagName, - tagColor, - tagSlug: tagName.replace(" ", "_") - }); - handlePopUpClose("addTag"); + const onSecretRollback = async () => { + if (!snapshotSecret?.version) { createNotification({ - text: "Successfully created a tag", + text: "Failed to find secret version", + type: "success" + }); + return; + } + try { + await performSecretRollback({ + workspaceId, + version: snapshotSecret.version, + environment: selectedEnvSlug, + folderId + }); + setValue("isSnapshotMode", false); + setSnaphotId(null); + queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug)); + createNotification({ + text: "Successfully rollback secrets", type: "success" }); } catch (error) { - console.error(error); + console.log(error); createNotification({ - text: "Failed to create a tag", + text: "Failed to rollback secrets", type: "error" }); } - }, - [workspaceId] - ); + }; - const handleFolderOpen = useCallback( - (id: string) => { + const onAppendSecret = () => { setSearchFilter(""); + append(DEFAULT_SECRET_VALUE); + }; + + const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { + if (isSnapshotMode) { + await onSecretRollback(); + return; + } + // just closing this if save is triggered from drawer + handlePopUpClose("secretDetails"); + // when add only mode remove rest of things not created + const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec; + // encrypt and format the secrets to batch api format + // requests = [ {method:"", secret:""} ] + const batchedSecret = transformSecretsToBatchSecretReq( + deletedSecretIds.current, + latestFileKey, + sec, + secrets?.secrets + ); + // type check + if (!selectedEnv?.slug) return; + if (batchedSecret.length === 0) { + reset(); + return; + } + try { + await batchSecretOp({ + requests: batchedSecret, + workspaceId, + folderId, + environment: selectedEnv?.slug + }); + createNotification({ + text: "Successfully saved changes", + type: "success" + }); + deletedSecretIds.current = []; + if (!hasUserPushed) { + await registerUserAction(USER_ACTION_PUSH); + } + } catch (error) { + console.log(error); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + const onDrawerOpen = useCallback((id: string | undefined, index: number) => { + handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen); + }, []); + + const onEnvChange = (slug: string) => { + if (hasUnsavedChanges) { + // eslint-disable-next-line no-alert + if (!window.confirm(leaveConfirmDefaultMessage)) return; + } + const env = wsEnv?.find((el) => el.slug === slug); + if (env) setSelectedEnv(env); + const query: Record = { ...router.query, env: slug }; + delete query.folderId; router.push({ pathname: router.pathname, - query: { - id: workspaceId, - env: envQuery, - folderId: id + query + }); + }; + + const handleDownloadSecret = () => { + const secretsFromImport: { key: string; value: string; comment: string }[] = []; + importedSecrets?.forEach(({ secrets: impSec }) => { + impSec.forEach((el) => { + secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment }); + }); + }); + downloadSecret(getValues("secrets"), secretsFromImport, selectedEnv?.slug); + }; + + // record all deleted ids + // This will make final deletion easier + const onSecretDelete = useCallback( + (index: number, secretName: string, id?: string, overrideId?: string) => { + if (id) + deletedSecretIds.current.push({ + id, + secretName + }); + if (overrideId) + deletedSecretIds.current.push({ + id: overrideId, + secretName + }); + remove(index); + // just the case if this is called from drawer + handlePopUpClose("secretDetails"); + }, + [] + ); + + const onCreateWsTag = useCallback( + async (tagName: string) => { + try { + await createWsTag({ + workspaceID: workspaceId, + tagName, + tagSlug: tagName.replace(" ", "_") + }); + handlePopUpClose("addTag"); + createNotification({ + text: "Successfully created a tag", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to create a tag", + type: "error" + }); } - }); - }, - [envQuery, workspaceId] - ); + }, + [workspaceId] + ); - const isEditFolder = Boolean(popUp?.folderForm?.data); + const handleFolderOpen = useCallback( + (id: string) => { + setSearchFilter(""); + router.push({ + pathname: router.pathname, + query: { + id: workspaceId, + env: envQuery, + folderId: id + } + }); + }, + [envQuery, workspaceId] + ); - // FOLDER SECTION - const handleFolderCreate = async (name: string) => { - try { - await createFolder({ - workspaceId, - environment: selectedEnv?.slug || "", - folderName: name, - parentFolderId: folderId - }); - createNotification({ - type: "success", - text: "Successfully created folder" - }); - handlePopUpClose("folderForm"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create folder", - type: "error" - }); - } - }; + const isEditFolder = Boolean(popUp?.folderForm?.data); - const handleFolderUpdate = useCallback( - async (name: string) => { - const { id } = popUp?.folderForm?.data as TDeleteFolderForm; + // FOLDER SECTION + const handleFolderCreate = async (name: string) => { try { - await updateFolder({ - folderId: id, + await createFolder({ workspaceId, environment: selectedEnv?.slug || "", - name + folderName: name, + parentFolderId: folderId }); createNotification({ type: "success", - text: "Successfully updated folder" + text: "Successfully created folder" }); handlePopUpClose("folderForm"); } catch (error) { console.error(error); createNotification({ - text: "Failed to update folder", + text: "Failed to create folder", type: "error" }); } - }, - [selectedEnv?.slug, (popUp?.folderForm?.data as TDeleteFolderForm)?.id] - ); + }; - const handleFolderDelete = useCallback(async () => { - const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm; - try { - deleteFolder({ - workspaceId, - environment: selectedEnv?.slug || "", - folderId: id - }); - createNotification({ - type: "success", - text: "Successfully removed folder" - }); - handlePopUpClose("deleteFolder"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to remove folder", - type: "error" - }); - } - }, [selectedEnv?.slug, (popUp?.deleteFolder?.data as TDeleteFolderForm)?.id]); - - // SECRET IMPORT SECTION - const handleSecretImportCreate = async (env: string, secretPath: string) => { - try { - await createSecretImport({ - workspaceId, - environment: selectedEnv?.slug || "", - folderId, - secretImport: { - environment: env, - secretPath + const handleFolderUpdate = useCallback( + async (name: string) => { + const { id } = popUp?.folderForm?.data as TDeleteFolderForm; + try { + await updateFolder({ + folderId: id, + workspaceId, + environment: selectedEnv?.slug || "", + name + }); + createNotification({ + type: "success", + text: "Successfully updated folder" + }); + handlePopUpClose("folderForm"); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to update folder", + type: "error" + }); } - }); - createNotification({ - type: "success", - text: "Successfully create secret link" - }); - handlePopUpClose("addSecretImport"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to create secret link", - type: "error" - }); - } - }; + }, + [selectedEnv?.slug, (popUp?.folderForm?.data as TDeleteFolderForm)?.id] + ); - const handleSecretImportDelete = async () => { - const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport - ?.data as TDeleteSecretImport; - try { - if (secretImportCfg?._id) { - await deleteSecretImport({ + const handleFolderDelete = useCallback(async () => { + const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm; + try { + deleteFolder({ + workspaceId, + environment: selectedEnv?.slug || "", + folderId: id + }); + createNotification({ + type: "success", + text: "Successfully removed folder" + }); + handlePopUpClose("deleteFolder"); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove folder", + type: "error" + }); + } + }, [selectedEnv?.slug, (popUp?.deleteFolder?.data as TDeleteFolderForm)?.id]); + + // SECRET IMPORT SECTION + const handleSecretImportCreate = async (env: string, secretPath: string) => { + try { + await createSecretImport({ + workspaceId, + environment: selectedEnv?.slug || "", + folderId, + secretImport: { + environment: env, + secretPath + } + }); + createNotification({ + type: "success", + text: "Successfully create secret link" + }); + handlePopUpClose("addSecretImport"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create secret link", + type: "error" + }); + } + }; + + const handleSecretImportDelete = async () => { + const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport + ?.data as TDeleteSecretImport; + try { + if (secretImportCfg?._id) { + await deleteSecretImport({ + workspaceId, + environment: selectedEnvSlug, + folderId, + id: secretImportCfg?._id, + secretImportEnv: importEnv, + secretImportPath: impSecPath + }); + handlePopUpClose("deleteSecretImport"); + createNotification({ + type: "success", + text: "Successfully removed secret link" + }); + } + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove secret link", + type: "error" + }); + } + }; + + const handleDragEnd = (evt: DragEndEvent) => { + const { active, over } = evt; + if (over?.id && active.id !== over.id) { + const oldIndex = items.findIndex(({ id }) => id === active.id); + const newIndex = items.findIndex(({ id }) => id === over.id); + const newImportOrder = arrayMove(items, oldIndex, newIndex); + setItems(newImportOrder); + updateSecretImportSync({ workspaceId, environment: selectedEnvSlug, folderId, - id: secretImportCfg?._id, - secretImportEnv: importEnv, - secretImportPath: impSecPath - }); - handlePopUpClose("deleteSecretImport"); - createNotification({ - type: "success", - text: "Successfully removed secret link" + id: secretImportCfg?._id || "", + secretImports: newImportOrder.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath + })) }); } - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to remove secret link", - type: "error" - }); - } - }; + }; - const handleDragEnd = (evt: DragEndEvent) => { - const { active, over } = evt; - if (over?.id && active.id !== over.id) { - const oldIndex = items.findIndex(({ id }) => id === active.id); - const newIndex = items.findIndex(({ id }) => id === over.id); - const newImportOrder = arrayMove(items, oldIndex, newIndex); - setItems(newImportOrder); - updateSecretImportSync({ - workspaceId, - environment: selectedEnvSlug, - folderId, - id: secretImportCfg?._id || "", - secretImports: newImportOrder.map((el) => ({ - environment: el.environment, - secretPath: el.secretPath - })) - }); - } - }; - - // OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING - const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []); - const handleFolderCreatePopUpOpen = useCallback( - (id: string, name: string) => handlePopUpOpen("folderForm", { id, name }), - [] - ); - const handleFolderDeletePopUpOpen = useCallback( - (id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }), - [] - ); - const handleSecretImportDelPopUpOpen = useCallback( - (impSecEnv: string, impSecPath: string) => - handlePopUpOpen("deleteSecretImport", { - environment: impSecEnv, - secretPath: impSecPath - }), - [] - ); - - // when secrets is not loading and secrets list is empty - const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; - - // folder list checks - const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading; - const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders; - - // when using snapshot mode and snapshot is loading and snapshot list is empty - const isFoldersEmpty = !isFolderListLoading && !folderList?.length; - const isSnapshotSecretEmtpy = - isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length; - const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy; - const isSecretImportEmpty = !secretImportCfg?.imports?.length; - const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; - - if (isSecretsLoading || isEnvListLoading) { - return ( -
- loading animation -
+ // OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING + const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []); + const handleFolderCreatePopUpOpen = useCallback( + (id: string, name: string) => handlePopUpOpen("folderForm", { id, name }), + [] + ); + const handleFolderDeletePopUpOpen = useCallback( + (id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }), + [] + ); + const handleSecretImportDelPopUpOpen = useCallback( + (impSecEnv: string, impSecPath: string) => + handlePopUpOpen("deleteSecretImport", { + environment: impSecEnv, + secretPath: impSecPath + }), + [] ); - } - const userAvailableEnvs = wsEnv?.filter( - ({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied - ); + // when secrets is not loading and secrets list is empty + const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; - return ( -
-
- {/* breadcrumb row */} -
- envir.slug === envQuery)[0].name || ""} - isFolderMode - folders={folderData?.dir} - isProjectRelated - userAvailableEnvs={userAvailableEnvs} - onEnvChange={onEnvChange} - /> + // folder list checks + const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading; + const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders; + + // when using snapshot mode and snapshot is loading and snapshot list is empty + const isFoldersEmpty = !isFolderListLoading && !folderList?.length; + const isSnapshotSecretEmtpy = + isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length; + const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy; + const isSecretImportEmpty = !secretImportCfg?.imports?.length; + const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; + + if (isSecretsLoading || isEnvListLoading) { + return ( +
+ loading animation
-
-
{isRollbackMode ? "Secret Snapshot" : ""}
- {isRollbackMode && Boolean(snapshotSecret) && ( - - {new Date(snapshotSecret?.createdAt || "").toLocaleString()} - - )} -
- {/* Environment, search and other action row */} -
-
- setSearchFilter(e.target.value)} - leftIcon={} + ); + } + + const userAvailableEnvs = wsEnv?.filter( + ({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied + ); + + return ( +
+ + {/* breadcrumb row */} +
+ envir.slug === envQuery)[0].name || "" + } + isFolderMode + folders={folderData?.dir} + isProjectRelated + userAvailableEnvs={userAvailableEnvs} + onEnvChange={onEnvChange} />
-
-
- - - - +
+
{isRollbackMode ? "Secret Snapshot" : ""}
+ {isRollbackMode && Boolean(snapshotSecret) && ( + + {new Date(snapshotSecret?.createdAt || "").toLocaleString()} + + )} +
+ {/* Environment, search and other action row */} +
+
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+
+
+ + + + + + + +
+ +
+
+
+
+
+ + setIsSecretValueHidden.toggle()} + > + - - -
+ +
+ + {(isAllowed) => ( +
+ + handlePopUpOpen("secretSnapshots")} + > + + + +
+ )} +
+ + {(isAllowed) => ( +
-
- -
-
- - setIsSecretValueHidden.toggle()} - > - - - -
-
- - handlePopUpOpen("secretSnapshots")} - > - - - -
-
- -
- {!isReadOnly && !isRollbackMode && ( -
- - - -
- -
-
- -
-
- -
-
- -
-
-
-
-
- )} - {isRollbackMode && ( - - )} - -
-
-
- {!isEmptyPage && ( - - - - - - - - {fields.map(({ id, _id }, index) => ( - - ))} - {!isReadOnly && !isRollbackMode && ( - - - + )} + + {!isReadOnly && !isRollbackMode && ( +
+ + {(isAllowed) => ( + )} -
-
- -
-
-
- )} - - handlePopUpToggle("secretSnapshots", isOpen)} - fetchNextPage={fetchNextPage} - hasNextPage={hasNextPage} - snapshotId={snapshotId} - isFetchingNextPage={isFetchingNextPage} - secretSnaphots={secretSnaphots} - onSelectSnapshot={setSnaphotId} - /> - handlePopUpToggle("secretDetails", isOpen)} - secretVersion={secretVersion} - index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} - onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} - /> - - - -
- {/* secrets table and drawers, modals */} - - {/* Create a new tag modal */} - { - handlePopUpToggle("addTag", open); - }} - > - - - - - {/* Uploaded env override or not confirmation modal */} - handlePopUpToggle("uploadedSecOpts", open)} - > - handlePopUpClose("uploadedSecOpts")} - > - Keep old - , - - ]} - > -
-
Your file contains following duplicate secrets
-
- {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) - ?.map((key) => key) - .join(", ")} + + + +
+ +
+
+ +
+
+ + {(isAllowed) => ( + + )} + +
+
+ + {(isAllowed) => ( + + )} + +
+
+
+
+
+ )} + {isRollbackMode && ( + + )} + + {(isAllowed) => ( + + )} +
-
Are you sure you want to overwrite these secrets?
- - - handlePopUpToggle("folderForm", isOpen)} - > - - - - - handlePopUpToggle("addSecretImport", isOpen)} - > - + {!isEmptyPage && ( + + + + + + + + {fields.map(({ id, _id }, index) => ( + + ))} + {!isReadOnly && !isRollbackMode && ( + + + + )} + +
+ + {(isAllowed) => ( + + )} + +
+
+
+ )} + + handlePopUpToggle("secretSnapshots", isOpen)} + fetchNextPage={fetchNextPage} + hasNextPage={hasNextPage} + snapshotId={snapshotId} + isFetchingNextPage={isFetchingNextPage} + secretSnaphots={secretSnaphots} + onSelectSnapshot={setSnaphotId} + /> + handlePopUpToggle("secretDetails", isOpen)} + secretVersion={secretVersion} + index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} + onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} + /> + + + +
+ {/* secrets table and drawers, modals */} + + {/* Create a new tag modal */} + { + handlePopUpToggle("addTag", open); + }} > - - - - handlePopUpToggle("deleteFolder", isOpen)} - onDeleteApproved={handleFolderDelete} - /> - handlePopUpToggle("deleteSecretImport", isOpen)} - onDeleteApproved={handleSecretImportDelete} - /> - handlePopUpToggle("compareSecrets", open)} - > - + + + + {/* Uploaded env override or not confirmation modal */} + handlePopUpToggle("uploadedSecOpts", open)} > - - - - {subscription && ( - handlePopUpToggle("upgradePlan", isOpen)} - text={ - subscription.slug === null - ? "You can perform point-in-time recovery under an Enterprise license" - : "You can perform point-in-time recovery if you switch to Infisical's Team plan" - } + handlePopUpClose("uploadedSecOpts")} + > + Keep old + , + + ]} + > +
+
Your file contains following duplicate secrets
+
+ {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) + ?.map((key) => key) + .join(", ")} +
+
Are you sure you want to overwrite these secrets?
+
+
+ + handlePopUpToggle("folderForm", isOpen)} + > + + + + + handlePopUpToggle("addSecretImport", isOpen)} + > + + + + + handlePopUpToggle("deleteFolder", isOpen)} + onDeleteApproved={handleFolderDelete} /> - )} -
- ); -}; + handlePopUpToggle("deleteSecretImport", isOpen)} + onDeleteApproved={handleSecretImportDelete} + /> + handlePopUpToggle("compareSecrets", open)} + > + + + + + {subscription && ( + handlePopUpToggle("upgradePlan", isOpen)} + text={ + subscription.slug === null + ? "You can perform point-in-time recovery under an Enterprise license" + : "You can perform point-in-time recovery if you switch to Infisical's Team plan" + } + /> + )} +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Secrets } +); diff --git a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx index b80de5e3b..4fa6df286 100644 --- a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx +++ b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx @@ -2,7 +2,9 @@ import { memo } from "react"; import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; type Props = { folders?: Array<{ id: string; name: string }>; @@ -47,32 +49,48 @@ export const FolderSection = memo( {name}
-
- - handleFolderUpdate(id, name)} - ariaLabel="expand" - > - - - -
-
- - handleFolderDelete(id, name)} - > - - - -
+ + {(isAllowed) => ( +
+ + handleFolderUpdate(id, name)} + ariaLabel="expand" + > + + + +
+ )} +
+ + {(isAllowed) => ( +
+ + handleFolderDelete(id, name)} + > + + + +
+ )} +
diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx index 0f8446fcc..9668fe819 100644 --- a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx @@ -2,6 +2,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, Drawer, @@ -14,6 +15,7 @@ import { Switch, TextArea } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; import { FormData, SecretActionType } from "../../DashboardPage.utils"; @@ -85,20 +87,34 @@ export const SecretDetailDrawer = ({
- - + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} +
} diff --git a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx index 1c992df25..5e9505241 100644 --- a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -32,6 +32,8 @@ import { Skeleton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { useDebounce, usePopUp, useToggle } from "@app/hooks"; import { useGetProjectSecrets } from "@app/hooks/api"; import { UserWsKeyPair } from "@app/hooks/api/types"; @@ -78,333 +80,343 @@ type Props = { decryptFileKey: UserWsKeyPair; }; -export const SecretDropzone = ({ - isSmaller, - onParsedEnv, - onAddNewSecret, - environments = [], - workspaceId, - 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({ - 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({ +export const SecretDropzone = withProjectPermission( + ({ + isSmaller, + onParsedEnv, + onAddNewSecret, + environments = [], workspaceId, - env: selectedEnvSlug, - secretPath: debouncedSecretPath, - isPaused: - !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && - !popUp.importSecEnv.isOpen, 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(() => { - setValue("secrets", {}); - setSearchFilter(""); - }, [debouncedSecretPath]); + const { + handleSubmit, + control, + watch, + register, + reset, + setValue, + formState: { isDirty } + } = useForm({ + resolver: yupResolver(formSchema), + defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } + }); - const handleDrag = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive.on(); - } else if (e.type === "dragleave") { - setDragActive.off(); - } - }; + const secretPath = watch("secretPath"); + const selectedEnvSlug = watch("environment"); + const debouncedSecretPath = useDebounce(secretPath); - const parseFile = (file?: File, isJson?: boolean) => { - const reader = new FileReader(); - if (!file) { - createNotification({ - 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 - }); - return; - } - // const fileType = file.name.split('.')[1]; - setIsLoading.on(); - reader.onload = (event) => { - 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 { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + env: selectedEnvSlug, + secretPath: debouncedSecretPath, + isPaused: + !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && + !popUp.importSecEnv.isOpen, + decryptFileKey + }); + + useEffect(() => { + setValue("secrets", {}); + setSearchFilter(""); + }, [debouncedSecretPath]); + + const handleDrag = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive.on(); + } else if (e.type === "dragleave") { + setDragActive.off(); + } }; - // If something is wrong show an error - try { - reader.readAsText(file); - } catch (error) { - console.log(error); - } - }; - - const handleDrop = (e: DragEvent) => { - 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) => { - e.preventDefault(); - parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); - }; - - const handleFormSubmit = (data: TFormSchema) => { - const secretsToBePulled: Record = {}; - Object.keys(data.secrets || {}).forEach((key) => { - if (data.secrets[key]) { - secretsToBePulled[key] = { - value: (shouldIncludeValues && data.secrets[key]) || "", - comments: [""] - }; + const parseFile = (file?: File, isJson?: boolean) => { + const reader = new FileReader(); + if (!file) { + createNotification({ + 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 + }); + return; } - }); - onParsedEnv(secretsToBePulled); - handlePopUpClose("importSecEnv"); - reset(); - }; + // const fileType = file.name.split('.')[1]; + setIsLoading.on(); + reader.onload = (event) => { + 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 (secrets?.secrets) { - setValue( - "secrets", - secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), - { shouldDirty: true } - ); - } - }; + // If something is wrong show an error + try { + reader.readAsText(file); + } catch (error) { + console.log(error); + } + }; - return ( -
- {isLoading ? ( -
- loading animation -
- ) : ( -
-
-
- -
-
-

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

-
- { + e.preventDefault(); + e.stopPropagation(); + if (!e.dataTransfer) { + return; + } + + e.dataTransfer.dropEffect = "copy"; + setDragActive.off(); + parseFile(e.dataTransfer.files[0]); + }; + + const handleFileUpload = (e: ChangeEvent) => { + e.preventDefault(); + parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); + }; + + const handleFormSubmit = (data: TFormSchema) => { + const secretsToBePulled: Record = {}; + Object.keys(data.secrets || {}).forEach((key) => { + if (data.secrets[key]) { + secretsToBePulled[key] = { + value: (shouldIncludeValues && data.secrets[key]) || "", + comments: [""] + }; + } + }); + onParsedEnv(secretsToBePulled); + handlePopUpClose("importSecEnv"); + reset(); + }; + + const handleSecSelectAll = () => { + if (secrets?.secrets) { + setValue( + "secrets", + secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), + { shouldDirty: true } + ); + } + }; + + return ( +
+ {isLoading ? ( +
+ loading animation -
-
-

OR

-
-
-
- { - handlePopUpToggle("importSecEnv", isOpen); - reset(); - setSearchFilter(""); - }} +
+ ) : ( + +
+
+ +
+
+

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

+
+ +
- - - - +

OR

+
+
+
+ { + handlePopUpToggle("importSecEnv", isOpen); + reset(); + setSearchFilter(""); + }} > - -
- ( - - - - )} - /> - - + + + + +
+ ( + + + + )} /> - -
-
-
-
Secrets
-
+ } - onChange={(evt) => setSearchFilter(evt.target.value)} + {...register("secretPath")} + placeholder="Provide a path, default is /" /> - - +
+
+
+
Secrets
+
+ - - - - - reset()} - > - - - + leftIcon={} + onChange={(evt) => setSearchFilter(evt.target.value)} + /> + + + + + + + reset()} + > + + + +
+
+ {!isSecretsLoading && !secrets?.secrets?.length && ( + + )} +
+ {isSecretsLoading && + Array.apply(0, Array(2)).map((_x, i) => ( + + ))} + + {secrets?.secrets + ?.filter(({ key }) => + key.toLowerCase().includes(searchFilter.toLowerCase()) + ) + ?.map(({ _id, key, value: secVal }) => ( + ( + + onChange(isChecked ? secVal : "") + } + > + {key} + + )} + /> + ))} +
+
+ + setShouldIncludeValues(isChecked as boolean) + } + > + Include secret values + +
+
+ +
- {!isSecretsLoading && !secrets?.secrets?.length && ( - - )} -
- {isSecretsLoading && - Array.apply(0, Array(2)).map((_x, i) => ( - - ))} - - {secrets?.secrets - ?.filter(({ key }) => - key.toLowerCase().includes(searchFilter.toLowerCase()) - ) - ?.map(({ _id, key, value: secVal }) => ( - ( - onChange(isChecked ? secVal : "")} - > - {key} - - )} - /> - ))} -
-
- - setShouldIncludeValues(isChecked as boolean) - } - > - Include secret values - -
-
- - -
-
- - - - {!isSmaller && ( - - )} + + + + {!isSmaller && ( + + )} +
-
- - )} -
- ); -}; + + )} +
+ ); + }, + { action: ProjectPermissionActions.Create, subject: ProjectPermissionSub.Secrets } +); diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx index 31752aa02..866578b73 100644 --- a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx @@ -9,9 +9,10 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks/useToggle"; +import { ProjectPermissionActions, ProjectPermissionSub,useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; type Props = { onDelete: (environment: string, secretPath: string) => void; @@ -49,7 +50,9 @@ export const SecretImportItem = ({ const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv); 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) { setIsExpanded.on(); @@ -58,7 +61,6 @@ export const SecretImportItem = ({ } }, [searchTerm]); - useEffect(() => { if (isDragging) { setIsExpanded.off(); @@ -78,7 +80,11 @@ export const SecretImportItem = ({ className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700" onClick={() => setIsExpanded.toggle()} > - + @@ -106,28 +112,39 @@ export const SecretImportItem = ({
-
- - { - evt.stopPropagation(); - onDelete(importedEnv, importedSecPath); - }} - > - - - -
+ + {(isAllowed) => ( +
+ + { + evt.stopPropagation(); + onDelete(importedEnv, importedSecPath); + }} + > + + + +
+ )} +
{isExpanded && !isDragging && ( - +
@@ -146,19 +163,26 @@ export const SecretImportItem = ({ )} - {importedSecrets.filter(secret => secret.key.toUpperCase().includes(searchTerm.toUpperCase())).map(({ key, value, overriden }, index) => ( - - - - - - ))} + {importedSecrets + .filter((secret) => + secret.key.toUpperCase().includes(searchTerm.toUpperCase()) + ) + .map(({ key, value, overriden }, index) => ( + + + + + + ))}
- {key} - - - - -
+ {key} + + + + +
diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx index 6c089d695..307cc981b 100644 --- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx +++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx @@ -22,6 +22,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { cx } from "cva"; import { twMerge } from "tailwind-merge"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { HoverCard, HoverCardContent, @@ -34,6 +35,10 @@ import { Tag, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { useToggle } from "@app/hooks"; import { WsTag } from "@app/hooks/api/types"; @@ -452,22 +457,29 @@ export const SecretInputRow = memo(
)} -
- - { - onSecretDelete(index, secKey, secId, idOverride); - }} - > - - - -
+ + {(isAllowed) => ( +
+ + { + onSecretDelete(index, secKey, secId, idOverride); + }} + > + + + +
+ )} +
diff --git a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx index 0a192110b..ce3ce4ece 100644 --- a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx @@ -1,17 +1,21 @@ -import { - LogsSection -} from "./components"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; -export const AuditLogsPage = () => { +import { LogsSection } from "./components"; + +export const AuditLogsPage = withProjectPermission( + () => { return ( -
-
-
-

Audit Logs

-
-
- -
-
+
+
+
+

Audit Logs

+
+
+ +
+
); -} \ No newline at end of file + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } +); diff --git a/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx index 9940d6bae..64eec6a2f 100644 --- a/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx +++ b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx @@ -1,15 +1,21 @@ +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + import { IPAllowlistSection } from "./components"; -export const IPAllowlistPage = () => { +export const IPAllowlistPage = withProjectPermission( + () => { return ( -
-
-
-

IP Allowlist

-
-
- -
-
+
+
+
+

IP Allowlist

+
+
+ +
+
); -} \ No newline at end of file + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.IpAllowList } +); diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx index 729ab549e..3fa46051e 100644 --- a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx @@ -2,104 +2,111 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - DeleteActionModal, - UpgradePlanModal -} from "@app/components/v2"; -import { useSubscription,useWorkspace } from "@app/context"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; import { - useDeleteTrustedIp -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { useDeleteTrustedIp } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { IPAllowlistModal } from "./IPAllowlistModal"; import { IPAllowlistTable } from "./IPAllowlistTable"; export const IPAllowlistSection = () => { - const { createNotification } = useNotificationContext(); - const { mutateAsync } = useDeleteTrustedIp(); - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "trustedIp", - "deleteTrustedIp", - "upgradePlan" - ] as const); - - const onDeleteTrustedIpSubmit = async (trustedIpId: string) => { - try { - - if (!currentWorkspace?._id) return; + const { createNotification } = useNotificationContext(); + const { mutateAsync } = useDeleteTrustedIp(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); - await mutateAsync({ - workspaceId: currentWorkspace._id, - trustedIpId - }); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "trustedIp", + "deleteTrustedIp", + "upgradePlan" + ] as const); - createNotification({ - text: "Successfully deleted IP access range", - type: "success" - }); + const onDeleteTrustedIpSubmit = async (trustedIpId: string) => { + try { + if (!currentWorkspace?._id) return; - handlePopUpClose("deleteTrustedIp"); - } catch (err) { - console.log(err); - createNotification({ - text: "Failed to delete IP access range", - type: "error" - }); - } + await mutateAsync({ + workspaceId: currentWorkspace._id, + trustedIpId + }); + + 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 ( -
-
-

- IP Allowlist -

- -
- - - handlePopUpToggle("deleteTrustedIp", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onDeleteTrustedIpSubmit((popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId) + }; + + return ( +
+
+

IP Allowlist

+ + {(isAllowed) => ( +
- ); -} \ No newline at end of file + }} + colorSchema="secondary" + isLoading={false} + isDisabled={!isAllowed} + leftIcon={} + > + Add IP + + )} + +
+ + + handlePopUpToggle("deleteTrustedIp", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteTrustedIpSubmit( + (popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx index 05f65e3ad..a9a84a608 100644 --- a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx @@ -1,92 +1,81 @@ import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { - EmptyState, - IconButton, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr, - UpgradePlanModal + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; import { - useGetTrustedIps -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetTrustedIps } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["upgradePlan"]>; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, - data?: { - trustedIpId: string; - ipAddress?: string; - comment?: string; - isActive?: boolean; - prefix?: number; - }, - ) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; + popUp: UsePopUpState<["upgradePlan"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, + data?: { + trustedIpId: string; + ipAddress?: string; + comment?: string; + isActive?: boolean; + prefix?: number; + } + ) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; }; -export const IPAllowlistTable = ({ - popUp, - handlePopUpOpen, - handlePopUpToggle -}: Props) => { - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? ""); - - const formatType = (type: string, prefix?: number) => { - return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${(prefix !== undefined) ? "CIDR" : ""}`; - } - - return ( -
- - - - - - - - {/* */} - - - - {!isLoading && data && data?.length > 0 && data - .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) - .map(({ - _id, - ipAddress, - comment, - type, - prefix, - isActive - }) => { - return ( - - - - - {/* + + ); + })} + {isLoading && ( + + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
IP Address / RangeFormatCommentStatus -
- {`${ipAddress}${(prefix !== undefined) ? `/${prefix}` : ""}`} - - {formatType(type, prefix)} - - {comment} - +export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? ""); + + const formatType = (type: string, prefix?: number) => { + return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${ + prefix !== undefined ? "CIDR" : "" + }`; + }; + + return ( +
+ + + + + + + + {/* */} + + + + {!isLoading && + data && + data?.length > 0 && + data + .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) + .map(({ _id, ipAddress, comment, type, prefix, isActive }) => { + return ( + + + + + {/* */} - - - ); - })} - {isLoading && } - {!isLoading && data && data?.length === 0 && ( - - - - )} - -
IP Address / RangeFormatCommentStatus +
{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}{formatType(type, prefix)}{comment}
Active

- { - if (subscription?.ipAllowlisting) { - handlePopUpOpen("trustedIp", { - trustedIpId: _id, - ipAddress, - comment, - prefix, - isActive - }); - } else { - handlePopUpOpen("upgradePlan"); - } - }} - colorSchema="primary" - variant="plain" - ariaLabel="update" - > - - - { - if (subscription?.ipAllowlisting) { - handlePopUpOpen("deleteTrustedIp", { - trustedIpId: _id - }); - } else { - handlePopUpOpen("upgradePlan"); - } - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - > - - -
- -
-
- handlePopUpToggle("upgradePlan", isOpen)} - text="You can use IP allowlisting if you switch to Infisical's Pro plan." - /> -
- ); -} \ No newline at end of file +
+ + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("trustedIp", { + trustedIpId: _id, + ipAddress, + comment, + prefix, + isActive + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("deleteTrustedIp", { + trustedIpId: _id + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index 4f8f7a655..3c6a1f680 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; 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 { TRole } from "@app/hooks/api/roles/types"; @@ -15,44 +16,50 @@ enum TabSections { Roles = "roles" } -export const MembersPage = () => { - const { t } = useTranslation(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?._id || ""; - const orgId = currentWorkspace?.organization || ""; +export const MembersPage = withProjectPermission( + () => { + const { t } = useTranslation(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const orgId = currentWorkspace?.organization || ""; - const { data: roles, isLoading: isRolesLoading } = useGetRoles({ - orgId, - workspaceId - }); + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId, + workspaceId + }); - return ( -
-
-

{t("settings.members.title")}

- - - Members - {process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && ( + return ( +
+
+

+ {t("settings.members.title")} +

+ + + Members Roles - )} - - - - []} /> - - - - []} isRolesLoading={isRolesLoading} /> - - + + + + []} /> + + + + []} + isRolesLoading={isRolesLoading} + /> + + +
-
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Member } +); diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 8ece655ea..51897280e 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions"; import { decryptAssymmetric, encryptAssymmetric @@ -36,6 +36,8 @@ import { import { GeneralPermissionActions, OrgPermissionSubjects, + ProjectPermissionActions, + ProjectPermissionSub, useOrganization, useUser, useWorkspace @@ -240,7 +242,7 @@ export const MemberListTab = ({ roles = [] }: Props) => { placeholder="Search members..." />
- + {(isAllowed) => ( )} - +
@@ -276,9 +278,9 @@ export const MemberListTab = ({ roles = [] }: Props) => { {name} {email} - {(isAllowed) => ( <> @@ -316,13 +318,13 @@ export const MemberListTab = ({ roles = [] }: Props) => { )} )} - + {userId !== u?._id && ( - {(isAllowed) => ( { )} - + )} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 17c17b122..b433ed7e4 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -1,5 +1,7 @@ import { motion } from "framer-motion"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { TRole } from "@app/hooks/api/roles/types"; @@ -11,35 +13,38 @@ type Props = { isRolesLoading?: boolean; }; -export const ProjectRoleListTab = ({ roles = [], isRolesLoading }: Props) => { - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); +export const ProjectRoleListTab = withProjectPermission( + ({ roles = [], isRolesLoading }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); - return popUp.editRole.isOpen ? ( - - } - onGoBack={() => handlePopUpClose("editRole")} - /> - - ) : ( - - handlePopUpOpen("editRole", role)} - /> - - ); -}; + return popUp.editRole.isOpen ? ( + + } + onGoBack={() => handlePopUpClose("editRole")} + /> + + ) : ( + + handlePopUpOpen("editRole", role)} + /> + + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } +); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index 0230a248a..c18458127 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -19,7 +20,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; @@ -69,9 +75,17 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr placeholder="Search roles..." />
- + + {(isAllowed) => ( + + )} +
@@ -99,27 +113,48 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
- - onSelectRole(role)} - variant="plain" - > - - - - - handlePopUpOpen("deleteRole", role)} - variant="plain" - isDisabled={isNonMutatable} - > - - - + {(isAllowed) => ( +
+ + onSelectRole(role)} + variant="plain" + > + + + +
+ )} + + + {(isAllowed) => ( +
+ + handlePopUpOpen("deleteRole", role)} + variant="plain" + isDisabled={isNonMutatable || !isAllowed} + > + + + +
+ )} +
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index c74df03ce..f2dbc40fe 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -34,6 +34,7 @@ import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { SingleProjectPermission } from "./SingleProjectPermission"; +import { WsProjectPermission } from "./WsProjectPermission"; const SINGLE_PERMISSION_LIST = [ { @@ -271,6 +272,13 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { />
))} +
+ +
); }; + +export const SecretOverviewPage = withProjectPermission(SecretOverview, { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Secrets +}); diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index e8f3730cb..c2d15a924 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -3,7 +3,9 @@ import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-i import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { IconButton, SecretInput, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; type Props = { @@ -91,19 +93,26 @@ export const SecretEditRow = ({
{isDirty ? ( <> -
- - - - - -
+ + {(isAllowed) => ( +
+ + + + + +
+ )} +
-
- - - - - -
+ + {(isAllowed) => ( +
+ + + + + +
+ )} +
)}
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 8f9fbb441..864d906a8 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { Tab } from "@headlessui/react"; import NavHeader from "@app/components/navigation/NavHeader"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; @@ -14,45 +16,50 @@ const tabs = [ { name: "Webhooks", key: "tab-project-webhooks" } ]; -export const ProjectSettingsPage = () => { - const { t } = useTranslation(); - return ( -
-
-
- +export const ProjectSettingsPage = withProjectPermission( + () => { + const { t } = useTranslation(); + return ( +
+
+
+ +
+
+

{t("settings.project.title")}

+
+ + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + + +
-
-

{t("settings.project.title")}

-
- - - {tabs.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - - - - - - - - - - - - -
-
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx index f41de5ec6..c23770227 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx @@ -1,52 +1,62 @@ import { useTranslation } from "react-i18next"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Checkbox } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useToggleAutoCapitalization } from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useToggleAutoCapitalization } from "@app/hooks/api"; -export const AutoCapitalizationSection = () => { - const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const { mutateAsync } = useToggleAutoCapitalization(); - - const handleToggleCapitalizationToggle = async (state: boolean) => { - try { - if (!currentWorkspace?._id) return; +export const AutoCapitalizationSection = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { mutateAsync } = useToggleAutoCapitalization(); - await mutateAsync({ - workspaceID: currentWorkspace._id, - state - }); + const handleToggleCapitalizationToggle = async (state: boolean) => { + try { + if (!currentWorkspace?._id) return; - const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; - createNotification({ - text, - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update auto capitalization", - type: "error" - }); - } - } + await mutateAsync({ + workspaceID: currentWorkspace._id, + state + }); - return ( -
-

{t("settings.project.auto-capitalization")}

- { - handleToggleCapitalizationToggle(state as boolean); - }} - > - {t("settings.project.auto-capitalization-description")} - -
- ); -}; + const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; + createNotification({ + text, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update auto capitalization", + type: "error" + }); + } + }; + + return ( +
+

{t("settings.project.auto-capitalization")}

+ + {(isAllowed) => ( + { + handleToggleCapitalizationToggle(state as boolean); + }} + > + {t("settings.project.auto-capitalization-description")} + + )} + +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx index d217334e2..18f27d8d5 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -3,30 +3,34 @@ import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input } from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks"; import { - useDeleteWorkspace -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useDeleteWorkspace } from "@app/hooks/api"; export const DeleteProjectSection = () => { - const { t } = useTranslation(); - const router = useRouter(); - const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const { currentOrg } = useOrganization() - const [isDeleting, setIsDeleting] = useToggle(); - const [deleteProjectInput, setDeleteProjectInput] = useState(""); - const deleteWorkspace = useDeleteWorkspace(); + const { t } = useTranslation(); + const router = useRouter(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { currentOrg } = useOrganization(); + const [isDeleting, setIsDeleting] = useToggle(); + const [deleteProjectInput, setDeleteProjectInput] = useState(""); + const deleteWorkspace = useDeleteWorkspace(); - const onDeleteWorkspace = async () => { + const onDeleteWorkspace = async () => { setIsDeleting.on(); try { - if (!currentWorkspace?._id) return; - await deleteWorkspace.mutateAsync({ + if (!currentWorkspace?._id) return; + await deleteWorkspace.mutateAsync({ workspaceID: currentWorkspace?._id - }); + }); // redirect user to the org overview router.push(`/org/${currentOrg?._id}/overview`); @@ -45,38 +49,42 @@ export const DeleteProjectSection = () => { } }; - return ( -
-

{t("settings.project.danger-zone")}

-

{t("settings.project.danger-zone-note")}

-
- - Type {currentWorkspace?.name} to delete the - workspace -
- } - > - setDeleteProjectInput(e.target.value)} - value={deleteProjectInput} - placeholder="Type the project name to delete" - className="bg-mineshaft-800" - /> - -
+ return ( +
+

{t("settings.project.danger-zone")}

+

{t("settings.project.danger-zone-note")}

+
+ + Type {currentWorkspace?.name} to delete the + workspace +
+ } + > + setDeleteProjectInput(e.target.value)} + value={deleteProjectInput} + placeholder="Type the project name to delete" + className="bg-mineshaft-800" + /> + +
+ + {(isAllowed) => ( -

- {t("settings.project.delete-project-note")} -

-
- ); -} \ No newline at end of file + )} + +

+ {t("settings.project.delete-project-note")} +

+
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx index 374681003..815b50f24 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx @@ -1,99 +1,115 @@ +import { ProjectPermissionCan } from "@app/components/permissions"; import { - decryptAssymmetric, - encryptAssymmetric + decryptAssymmetric, + encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { Checkbox } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useGetUserWsKey,useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useGetUserWsKey, useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api"; -export const E2EESection = () => { +export const E2EESection = withProjectPermission( + () => { const { currentWorkspace } = useWorkspace(); const { data: bot } = useGetWorkspaceBot(currentWorkspace?._id ?? ""); const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus(); const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); /** - * Activate bot for project by performing the following steps: - * 1. Get the (encrypted) project key - * 2. Decrypt project key with user's private key - * 3. Encrypt project key with bot's public key - * 4. Send encrypted project key to backend and set bot status to active - */ + * Activate bot for project by performing the following steps: + * 1. Get the (encrypted) project key + * 2. Decrypt project key with user's private key + * 3. Encrypt project key with bot's public key + * 4. Send encrypted project key to backend and set bot status to active + */ const toggleBotActivate = async () => { - let botKey; - try { - if (!currentWorkspace?._id) return; + let botKey; + try { + if (!currentWorkspace?._id) return; - if (bot && wsKey) { - // case: there is a bot - - if (!bot.isActive) { - // bot is not active -> activate bot - - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); + if (bot && wsKey) { + // case: there is a bot - if (!PRIVATE_KEY) { - throw new Error("Private Key missing"); - } + if (!bot.isActive) { + // bot is not active -> activate bot - const WORKSPACE_KEY = decryptAssymmetric({ - ciphertext: wsKey.encryptedKey, - nonce: wsKey.nonce, - publicKey: wsKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); + const PRIVATE_KEY = localStorage.getItem("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 - }); - } + if (!PRIVATE_KEY) { + throw new Error("Private Key missing"); } - } 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 ? (

End-to-End Encryption

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

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

- { - await toggleBotActivate(); - }} - > - End-to-end encryption enabled - + + {(isAllowed) => ( + { + await toggleBotActivate(); + }} + > + End-to-end encryption enabled + + )} +
- ) :
; - }; - \ No newline at end of file + ) : ( +
+ ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Settings + } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 62a66e72c..229fc9b5f 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -2,115 +2,127 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; import { - Button, - DeleteActionModal, - UpgradePlanModal -} from "@app/components/v2"; -import { useSubscription,useWorkspace } from "@app/context"; -import { - useDeleteWsEnvironment -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteWsEnvironment } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddEnvironmentModal } from "./AddEnvironmentModal"; import { EnvironmentTable } from "./EnvironmentTable"; import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal"; -export const EnvironmentSection = () => { - const { createNotification } = useNotificationContext(); - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); +export const EnvironmentSection = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + 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 { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "createEnv", - "updateEnv", - "deleteEnv", - "upgradePlan" - ] as const); + const isMoreEnvironmentsAllowed = + subscription?.environmentLimit && currentWorkspace?.environments + ? currentWorkspace.environments.length < subscription.environmentLimit + : true; - const onEnvDeleteSubmit = async (environmentSlug: string) => { - try { - if (!currentWorkspace?._id) return; - - await deleteWsEnvironment.mutateAsync({ - workspaceID: currentWorkspace._id, - environmentSlug - }); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "createEnv", + "updateEnv", + "deleteEnv", + "upgradePlan" + ] as const); - createNotification({ - text: "Successfully deleted environment", - type: "success" - }); - - handlePopUpClose("deleteEnv"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete environment", - type: "error" - }); - } - }; + const onEnvDeleteSubmit = async (environmentSlug: string) => { + try { + if (!currentWorkspace?._id) return; - return ( -
-
-

- Environments -

-
- + await deleteWsEnvironment.mutateAsync({ + workspaceID: currentWorkspace._id, + environmentSlug + }); + + createNotification({ + text: "Successfully deleted environment", + type: "success" + }); + + handlePopUpClose("deleteEnv"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete environment", + type: "error" + }); + } + }; + + return ( +
+
+

Environments

+
+ + {(isAllowed) => ( + + )} + +
+

+ Choose which environments will show up in your dashboard like development, staging, + production +

+ + + + handlePopUpToggle("deleteEnv", isOpen)} + deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""} + onDeleteApproved={() => + onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + />
-

- Choose which environments will show up in your dashboard like development, staging, production -

- - - - handlePopUpToggle("deleteEnv", isOpen)} - deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""} - onDeleteApproved={() => - onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug) - } - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You can add custom environments if you switch to Infisical's Team plan." - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Environments } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx index e799f9e9a..6b2f26881 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx @@ -2,6 +2,7 @@ import { faArrowDown,faArrowUp, faPencil, faXmark } from "@fortawesome/free-soli import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -14,7 +15,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useReorderWsEnvironment } from "@app/hooks/api"; @@ -115,28 +116,42 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => { > - { - handlePopUpOpen("updateEnv", { name, slug }); - }} - colorSchema="primary" - variant="plain" - ariaLabel="update" + - - - { - handlePopUpOpen("deleteEnv", { name, slug }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" + {(isAllowed) => ( + { + handlePopUpOpen("updateEnv", { name, slug }); + }} + isDisabled={!isAllowed} + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + + )} + + - - + {(isAllowed) => ( + { + handlePopUpOpen("deleteEnv", { name, slug }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx index dc8f443ba..6c5502439 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx @@ -1,71 +1,77 @@ import { - decryptAssymmetric, - decryptSymmetric + decryptAssymmetric, + decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { - useGetUserWsKey, - useGetWorkspaceIndexStatus, - useGetWorkspaceSecrets, - useNameWorkspaceSecrets + useGetUserWsKey, + useGetWorkspaceIndexStatus, + useGetWorkspaceSecrets, + useNameWorkspaceSecrets } 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 { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? ""); + const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus( + currentWorkspace?._id ?? "" + ); const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? ""); const nameWorkspaceSecrets = useNameWorkspaceSecrets(); const onEnableBlindIndices = async () => { - if (!currentWorkspace?._id) return; - if (!encryptedSecrets) return; - if (!latestFileKey) return; + if (!currentWorkspace?._id) return; + if (!encryptedSecrets) return; + if (!latestFileKey) return; - const key = decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - 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 + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string }); - return { - secretName, - _id: encryptedSecret._id - }; - }); + const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { + const secretName = decryptSymmetric({ + ciphertext: encryptedSecret.secretKeyCiphertext, + iv: encryptedSecret.secretKeyIV, + tag: encryptedSecret.secretKeyTag, + key + }); - await nameWorkspaceSecrets.mutateAsync({ - workspaceId: currentWorkspace._id, - secretsToUpdate - }); - }; + return { + secretName, + _id: encryptedSecret._id + }; + }); - return (!isBlindIndexedLoading && (isBlindIndexed === false)) ? ( -
-

Blind Indices

-

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

- -
+ await nameWorkspaceSecrets.mutateAsync({ + workspaceId: currentWorkspace._id, + secretsToUpdate + }); + }; + + return !isBlindIndexedLoading && !isBlindIndexed ? ( +
+

Blind Indices

+

+ 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. +

+ +
) : ( -
- ) -} \ No newline at end of file +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx index 66ad4c5b0..137674ef0 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx @@ -4,11 +4,10 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useRenameWorkspace -} from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useRenameWorkspace } from "@app/hooks/api"; const formSchema = yup.object({ name: yup.string().required().label("Project Name") @@ -21,25 +20,20 @@ export const ProjectNameChangeSection = () => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useRenameWorkspace(); - const { - handleSubmit, - control, - reset - } = useForm({ resolver: yupResolver(formSchema) }); + const { handleSubmit, control, reset } = useForm({ resolver: yupResolver(formSchema) }); useEffect(() => { if (currentWorkspace) { - reset({ + reset({ name: currentWorkspace.name }); } - }, [currentWorkspace]); const onFormSubmit = async ({ name }: FormData) => { try { if (!currentWorkspace?._id) return; - + await mutateAsync({ workspaceID: currentWorkspace._id, newWorkspaceName: name @@ -49,7 +43,6 @@ export const ProjectNameChangeSection = () => { text: "Successfully renamed workspace", type: "success" }); - } catch (err) { console.error(err); createNotification({ @@ -60,37 +53,35 @@ export const ProjectNameChangeSection = () => { }; return ( -
-

- Project Name -

-
- ( - - - - )} - control={control} - name="name" - /> -
- +

Project Name

+
+ ( + + + + )} + control={control} + name="name" + /> +
+ + {(isAllowed) => ( + + )} +
); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx index 7d1db7305..6dc894926 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -2,10 +2,10 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - DeleteActionModal -} from "@app/components/v2"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteWsTag } from "@app/hooks/api"; @@ -14,74 +14,80 @@ import { SecretTagsTable } from "./SecretTagsTable"; type DeleteModalData = { name: string; id: string }; -export const SecretTagsSection = (): JSX.Element => { - const { createNotification } = useNotificationContext(); - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ - "CreateSecretTag", - "deleteTagConfirmation" - ] as const); +export const SecretTagsSection = withProjectPermission( + (): JSX.Element => { + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "CreateSecretTag", + "deleteTagConfirmation" + ] as const); - const deleteWsTag = useDeleteWsTag(); + const deleteWsTag = useDeleteWsTag(); - const onDeleteApproved = async () => { - try { - await deleteWsTag.mutateAsync({ - tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id - }); + const onDeleteApproved = async () => { + try { + await deleteWsTag.mutateAsync({ + tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id + }); - createNotification({ - text: "Successfully deleted tag", - type: "success" - }); + createNotification({ + text: "Successfully deleted tag", + type: "success" + }); - handlePopUpClose("deleteTagConfirmation"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete the tag", - type: "error" - }); - } - }; + handlePopUpClose("deleteTagConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete the tag", + type: "error" + }); + } + }; - return ( -
-
-

Secret Tags

- + return ( +
+
+

Secret Tags

+ + {(isAllowed) => ( + + )} + +
+

+ Every secret can be assigned to one or more tags. Here you can add and remove tags for the + current project. +

+ + + handlePopUpToggle("deleteTagConfirmation", isOpen)} + deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteTagConfirmation")} + onDeleteApproved={onDeleteApproved} + />
-

- Every secret can be assigned to one or more tags. Here you can add and remove tags for - the current project. -

- - - handlePopUpToggle("deleteTagConfirmation", isOpen)} - deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name} - onClose={() => handlePopUpClose("deleteTagConfirmation")} - onDeleteApproved={onDeleteApproved} - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Tags } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index cb8a0afd4..e20e605fa 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -1,6 +1,7 @@ import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -13,7 +14,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useGetWsTags } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -53,18 +54,26 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { {name} {slug} - - handlePopUpOpen("deleteTagConfirmation", { - name, - id: _id - }) - } - colorSchema="danger" - ariaLabel="update" + - - + {(isAllowed) => ( + + handlePopUpOpen("deleteTagConfirmation", { + name, + id: _id + }) + } + colorSchema="danger" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index 0c28e446d..b560165f2 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -3,7 +3,10 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteServiceToken } from "@app/hooks/api"; @@ -12,65 +15,76 @@ import { ServiceTokenTable } from "./ServiceTokenTable"; type DeleteModalData = { name: string; id: string }; -export const ServiceTokenSection = () => { - const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); - const deleteServiceToken = useDeleteServiceToken(); +export const ServiceTokenSection = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const deleteServiceToken = useDeleteServiceToken(); - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ - "createAPIToken", - "deleteAPITokenConfirmation" - ] as const); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createAPIToken", + "deleteAPITokenConfirmation" + ] as const); - const onDeleteApproved = async () => { - try { - deleteServiceToken.mutateAsync( - (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id - ); - createNotification({ - text: "Successfully deleted service token", - type: "success" - }); + const onDeleteApproved = async () => { + try { + deleteServiceToken.mutateAsync( + (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id + ); + createNotification({ + text: "Successfully deleted service token", + type: "success" + }); - handlePopUpClose("deleteAPITokenConfirmation"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete service token", - type: "error" - }); - } - }; + handlePopUpClose("deleteAPITokenConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service token", + type: "error" + }); + } + }; - return ( -
-
-

- {t("section.token.service-tokens")} -

- + return ( +
+
+

+ {t("section.token.service-tokens")} +

+ + {(isAllowed) => ( + + )} + +
+

{t("section.token.service-tokens-description")}

+ + + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} + deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} + onDeleteApproved={onDeleteApproved} + />
-

{t("section.token.service-tokens-description")}

- - - handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} - deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} - onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} - onDeleteApproved={onDeleteApproved} - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx index 8c40cef35..9c71e4408 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -1,6 +1,7 @@ import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -13,7 +14,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useGetUserWsServiceTokens } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -70,18 +71,26 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { {row.expiresAt && new Date(row.expiresAt).toUTCString()} - - handlePopUpOpen("deleteAPITokenConfirmation", { - name: row.name, - id: row._id - }) - } - colorSchema="danger" - ariaLabel="delete" + - - + {(isAllowed) => ( + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row._id + }) + } + colorSchema="danger" + ariaLabel="delete" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx index 3b48ac8b3..6fc6b230c 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -17,7 +18,8 @@ import { Tooltip, Tr } 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 { useCreateWebhook, @@ -29,252 +31,291 @@ import { import { AddWebhookForm, TFormSchema } from "./AddWebhookForm"; -export const WebhooksTab = () => { - const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?._id || ""; - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "addWebhook", - "deleteWebhook" - ] as const); +export const WebhooksTab = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWebhook", + "deleteWebhook" + ] as const); - const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId); + const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId); - // mutation - const { mutateAsync: createWebhook } = useCreateWebhook(); - const { - mutateAsync: testWebhook, - variables: testWebhookVars, - isLoading: isTestWebhookSubmitting - } = useTestWebhook(); - const { - mutateAsync: updateWebhook, - variables: updateWebhookVars, - isLoading: isUpdateWebhookSubmitting - } = useUpdateWebhook(); - const { mutateAsync: deleteWebhook } = useDeleteWebhook(); + // mutation + const { mutateAsync: createWebhook } = useCreateWebhook(); + const { + mutateAsync: testWebhook, + variables: testWebhookVars, + isLoading: isTestWebhookSubmitting + } = useTestWebhook(); + const { + mutateAsync: updateWebhook, + variables: updateWebhookVars, + isLoading: isUpdateWebhookSubmitting + } = useUpdateWebhook(); + const { mutateAsync: deleteWebhook } = useDeleteWebhook(); - const handleWebhookCreate = async (data: TFormSchema) => { - try { - await createWebhook({ - ...data, - workspaceId - }); - handlePopUpClose("addWebhook"); - createNotification({ - type: "success", - text: "Successfully created webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create webhook" - }); - } - }; + const handleWebhookCreate = async (data: TFormSchema) => { + try { + await createWebhook({ + ...data, + workspaceId + }); + handlePopUpClose("addWebhook"); + createNotification({ + type: "success", + text: "Successfully created webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create webhook" + }); + } + }; - const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => { - try { - await updateWebhook({ - webhookId, - workspaceId, - isDisabled - }); - createNotification({ - type: "success", - text: "Successfully updated webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to update webhook" - }); - } - }; + const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => { + try { + await updateWebhook({ + webhookId, + workspaceId, + isDisabled + }); + createNotification({ + type: "success", + text: "Successfully updated webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update webhook" + }); + } + }; - const handleWebhookDelete = async () => { - try { - const webhookId = popUp?.deleteWebhook?.data as string; - await deleteWebhook({ - webhookId, - workspaceId - }); - handlePopUpClose("deleteWebhook"); - createNotification({ - type: "success", - text: "Successfully deleted webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to delete webhook" - }); - } - }; + const handleWebhookDelete = async () => { + try { + const webhookId = popUp?.deleteWebhook?.data as string; + await deleteWebhook({ + webhookId, + workspaceId + }); + handlePopUpClose("deleteWebhook"); + createNotification({ + type: "success", + text: "Successfully deleted webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete webhook" + }); + } + }; - const handleWebhookTest = async (webhookId: string) => { - try { - await testWebhook({ - webhookId, - workspaceId - }); - createNotification({ - type: "success", - text: "Successfully triggered webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to trigger webhook" - }); - } - }; + const handleWebhookTest = async (webhookId: string) => { + try { + await testWebhook({ + webhookId, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully triggered webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to trigger webhook" + }); + } + }; - return ( -
-
-

{t("settings.webhooks.title")}

- -
-

{t("settings.webhooks.description")}

-
- - - - - - - - - - - - - {isWebhooksLoading && } - {!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( + return ( +
+
+

{t("settings.webhooks.title")}

+ + {(isAllowed) => ( + + )} + +
+

{t("settings.webhooks.description")}

+
+ +
URLEnvironmentSecret PathStatusAction
+ - + + + + + - )} - {!isWebhooksLoading && - webhooks?.map( - ({ - _id: id, - url, - environment, - secretPath, - lastStatus, - isDisabled, - updatedAt, - lastRunErrorMessage - }) => ( - - - - - - - - ) + + + {isWebhooksLoading && } + {!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( + + + )} - -
- - URLEnvironmentSecret PathStatusAction
- {url} - {environment}{secretPath} - {!lastStatus ? ( - "-" - ) : ( -
- {lastStatus}{" "} - -
- Updated At:{" "} - {format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")} -
- {lastRunErrorMessage && ( -
- Error: {lastRunErrorMessage} -
- )} -
- } - > - - - - )} -
-
- - - -
-
+ +
-
+ {!isWebhooksLoading && + webhooks?.map( + ({ + _id: id, + url, + environment, + secretPath, + lastStatus, + isDisabled, + updatedAt, + lastRunErrorMessage + }) => ( + + + {url} + + {environment} + {secretPath} + + {!lastStatus ? ( + "-" + ) : ( +
+ {lastStatus}{" "} + +
+ Updated At:{" "} + {format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")} +
+ {lastRunErrorMessage && ( +
+ Error: {lastRunErrorMessage} +
+ )} +
+ } + > + + +
+ )} + + +
+ + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
+ + + ) + )} + + + +
+ handlePopUpToggle("addWebhook", isOpen)} + onCreateWebhook={handleWebhookCreate} + /> + handlePopUpToggle("deleteWebhook", isOpen)} + onClose={() => handlePopUpClose("deleteWebhook")} + onDeleteApproved={handleWebhookDelete} + />
- handlePopUpToggle("addWebhook", isOpen)} - onCreateWebhook={handleWebhookCreate} - /> - handlePopUpToggle("deleteWebhook", isOpen)} - onClose={() => handlePopUpClose("deleteWebhook")} - onDeleteApproved={handleWebhookDelete} - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Webhooks } +);