From b09dddec1c51c961ffa2f9b92005f71843c82721 Mon Sep 17 00:00:00 2001 From: kasyap dharanikota <76957823+kasyap1234@users.noreply.github.com> Date: Wed, 17 Jul 2024 15:41:36 +0530 Subject: [PATCH 01/30] Add SMTP2GO credentials for email configuration --- docs/self-hosting/configuration/envars.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 03bea6fb0..8719fa0e9 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -328,6 +328,25 @@ SMTP_FROM_NAME=Infisical + +1. Create an account and configure [SMTP2Go](https://www.smtp2go.com/) to send emails. +2. Turn on SMTP authentication +``` +SMTP server: mail.smtp2go.com +SMTP port: You can use one of the following ports: 2525, 80, 25, 8025, or 587 +Username: Your SMTP2GO account's SMTP username +Password: Your SMTP2GO account's SMTP password +``` +{" "} + + +Optional (for TLS/SSL): + +TLS: Available on the same ports (2525, 80, 25, 8025, or 587) +SSL: Available on ports 465, 8465, and 443 + + + ## Authentication By default, users can only login via email/password based login method. From 8542ec8c3ee52b26fc5d75d2e9300d2f039c198c Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 17 Jul 2024 19:48:04 +0700 Subject: [PATCH 02/30] Complete preliminary user page --- backend/src/lib/api-docs/constants.ts | 7 +- backend/src/server/routes/index.ts | 1 + .../src/server/routes/v1/project-router.ts | 1 + .../server/routes/v2/organization-router.ts | 128 +++++++- .../org-membership/org-membership-dal.ts | 49 +++- backend/src/services/org/org-dal.ts | 5 +- backend/src/services/org/org-service.ts | 58 +++- backend/src/services/org/org-types.ts | 11 +- .../project-membership-dal.ts | 103 ++++++- frontend/src/hooks/api/users/index.tsx | 7 +- frontend/src/hooks/api/users/mutation.tsx | 3 +- frontend/src/hooks/api/users/queries.tsx | 76 ++++- frontend/src/hooks/api/users/types.ts | 12 +- frontend/src/hooks/api/workspace/queries.tsx | 5 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 3 +- .../[id]/memberships/[membershipId]/index.tsx | 20 ++ .../src/pages/org/[id]/overview/index.tsx | 3 +- .../IdentityProjectsTable.tsx | 6 +- .../OrgMembersSection/OrgMembersSection.tsx | 40 ++- .../OrgMembersSection/OrgMembersTable.tsx | 163 ++++++++--- frontend/src/views/Org/UserPage/UserPage.tsx | 277 ++++++++++++++++++ .../components/UserDetailsSection.tsx | 195 ++++++++++++ .../components/UserOrgMembershipModal.tsx | 160 ++++++++++ .../UserAddToProjectModal.tsx | 145 +++++++++ .../UserProjectsSection/UserProjectRow.tsx | 90 ++++++ .../UserProjectsSection.tsx | 98 +++++++ .../UserProjectsSection/UserProjectsTable.tsx | 62 ++++ .../components/UserProjectsSection/index.tsx | 1 + .../views/Org/UserPage/components/index.tsx | 3 + frontend/src/views/Org/UserPage/index.tsx | 1 + .../MembersTab/components/AddMemberModal.tsx | 8 +- 31 files changed, 1661 insertions(+), 80 deletions(-) create mode 100644 frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx create mode 100644 frontend/src/views/Org/UserPage/UserPage.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx create mode 100644 frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx create mode 100644 frontend/src/views/Org/UserPage/components/index.tsx create mode 100644 frontend/src/views/Org/UserPage/index.tsx diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e635d93da..5aae289bf 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -348,10 +348,15 @@ export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." }, + GET_USER_MEMBERSHIP: { + organizationId: "The ID of the organization to get the membership for.", + membershipId: "The ID of the membership to get." + }, UPDATE_USER_MEMBERSHIP: { organizationId: "The ID of the organization to update the membership for.", membershipId: "The ID of the membership to update.", - role: "The new role of the membership." + role: "The new role of the membership.", + isActive: "The active status of the membership" }, DELETE_USER_MEMBERSHIP: { organizationId: "The ID of the organization to delete the membership from.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 18f2d62b1..a0df46dc5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -457,6 +457,7 @@ export const registerRoutes = async ( tokenService, projectDAL, projectMembershipDAL, + orgMembershipDAL, projectKeyDAL, smtpService, userDAL, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 99f05cf94..b6c4ce7bd 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -78,6 +78,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { lastName: true, id: true }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + project: ProjectsSchema.pick({ name: true, id: true }), roles: z.array( z.object({ id: z.string(), diff --git a/backend/src/server/routes/v2/organization-router.ts b/backend/src/server/routes/v2/organization-router.ts index 07074eba3..f87e7a6f0 100644 --- a/backend/src/server/routes/v2/organization-router.ts +++ b/backend/src/server/routes/v2/organization-router.ts @@ -1,6 +1,13 @@ import { z } from "zod"; -import { OrganizationsSchema, OrgMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { + OrganizationsSchema, + OrgMembershipsSchema, + ProjectMembershipsSchema, + ProjectsSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; import { ORGANIZATIONS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -30,6 +37,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { user: UsersSchema.pick({ username: true, email: true, + isEmailVerified: true, firstName: true, lastName: true, id: true @@ -103,6 +111,54 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Get organization user membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.GET_USER_MEMBERSHIP.organizationId), + membershipId: z.string().trim().describe(ORGANIZATIONS.GET_USER_MEMBERSHIP.membershipId) + }), + response: { + 200: z.object({ + membership: OrgMembershipsSchema.merge( + z.object({ + user: UsersSchema.pick({ + username: true, + email: true, + isEmailVerified: true, + firstName: true, + lastName: true, + id: true + }).merge(z.object({ publicKey: z.string().nullable() })) + }) + ).omit({ createdAt: true, updatedAt: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const membership = await server.services.org.getOrgMembership({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + orgId: req.params.organizationId, + membershipId: req.params.membershipId + }); + return { membership }; + } + }); + server.route({ method: "PATCH", url: "/:organizationId/memberships/:membershipId", @@ -121,7 +177,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { membershipId: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.membershipId) }), body: z.object({ - role: z.string().trim().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role) + role: z.string().trim().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role), + isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive) }), response: { 200: z.object({ @@ -135,11 +192,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { const membership = await server.services.org.updateOrgMembership({ userId: req.permission.id, - role: req.body.role, actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, membershipId: req.params.membershipId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + ...req.body }); return { membership }; } @@ -183,6 +240,69 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + // TODO: re-think endpoint structure in future so users only need to pass in membershipId bc organizationId is redundant + method: "GET", + url: "/:organizationId/memberships/:membershipId/project-memberships", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Get project memberships given organization membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.organizationId), + membershipId: z.string().trim().describe(ORGANIZATIONS.DELETE_USER_MEMBERSHIP.membershipId) + }), + response: { + 200: z.object({ + memberships: ProjectMembershipsSchema.extend({ + user: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + project: ProjectsSchema.pick({ name: true, id: true }), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ) + }) + .omit({ createdAt: true, updatedAt: true }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const memberships = await server.services.org.listProjectMembershipsByOrgMembershipId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + orgId: req.params.organizationId, + orgMembershipId: req.params.membershipId + }); + return { memberships }; + } + }); + server.route({ method: "POST", url: "/", diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 9990d9c3d..ccd995296 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -1,5 +1,6 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TOrgMembershipDALFactory = ReturnType; @@ -7,7 +8,51 @@ export type TOrgMembershipDALFactory = ReturnType { const orgMembershipOrm = ormify(db, TableName.OrgMembership); + const findOrgMembershipById = async (membershipId: string) => { + try { + const member = await db + .replicaNode()(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.id`, membershipId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER + .first(); + + if (!member) return undefined; + + const { email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data } = member; + + return { + ...data, + user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey } + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find org membership by id" }); + } + }; + return { - ...orgMembershipOrm + ...orgMembershipOrm, + findOrgMembershipById }; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index cb611031c..051869429 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -76,6 +76,7 @@ export const orgDALFactory = (db: TDbClient) => { db.ref("status").withSchema(TableName.OrgMembership), db.ref("isActive").withSchema(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), @@ -84,9 +85,9 @@ export const orgDALFactory = (db: TDbClient) => { ) .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER - return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({ + return members.map(({ email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data }) => ({ ...data, - user: { email, username, firstName, lastName, id: userId, publicKey } + user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey } })); } catch (error) { throw new DatabaseError({ error, name: "Find all org members" }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index fe67c9041..3e30d8c52 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -15,9 +15,10 @@ import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; -import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { isDisposableEmail } from "@app/lib/validator"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; @@ -38,7 +39,9 @@ import { TFindAllWorkspacesDTO, TFindOrgMembersByEmailDTO, TGetOrgGroupsDTO, + TGetOrgMembershipDTO, TInviteUserToOrgDTO, + TListProjectMembershipsByOrgMembershipIdDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, TVerifyUserToOrgDTO @@ -54,6 +57,7 @@ type TOrgServiceFactoryDep = { projectDAL: TProjectDALFactory; projectMembershipDAL: Pick; projectKeyDAL: Pick; + orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; smtpService: TSmtpService; @@ -79,6 +83,7 @@ export const orgServiceFactory = ({ projectDAL, projectMembershipDAL, projectKeyDAL, + orgMembershipDAL, tokenService, orgBotDAL, licenseService, @@ -364,6 +369,7 @@ export const orgServiceFactory = ({ * */ const updateOrgMembership = async ({ role, + isActive, orgId, userId, membershipId, @@ -374,7 +380,7 @@ export const orgServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); - if (isCustomRole) { + if (role && isCustomRole) { const customRole = await orgRoleDAL.findOne({ slug: role, orgId }); if (!customRole) throw new BadRequestError({ name: "Update membership", message: "Role not found" }); @@ -394,7 +400,11 @@ export const orgServiceFactory = ({ return membership; } - const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null }); + if (isActive !== undefined) { + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + } + + const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive }); return membership; }; /* @@ -585,6 +595,24 @@ export const orgServiceFactory = ({ return { token, user }; }; + const getOrgMembership = async ({ + membershipId, + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetOrgMembershipDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const membership = await orgMembershipDAL.findOrgMembershipById(membershipId); + if (!membership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (membership.orgId !== orgId) throw new NotFoundError({ message: "Failed to find organization membership" }); + + return membership; + }; + const deleteOrgMembership = async ({ orgId, userId, @@ -608,6 +636,26 @@ export const orgServiceFactory = ({ return deletedMembership; }; + const listProjectMembershipsByOrgMembershipId = async ({ + orgMembershipId, + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListProjectMembershipsByOrgMembershipIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + + const membership = await orgMembershipDAL.findOrgMembershipById(orgMembershipId); + if (!membership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (membership.orgId !== orgId) throw new NotFoundError({ message: "Failed to find organization membership" }); + + const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, membership.user.id); + + return projectMemberships; + }; + /* * CRUD operations of incident contacts * */ @@ -668,6 +716,7 @@ export const orgServiceFactory = ({ findOrgMembersByUsername, createOrganization, deleteOrganizationById, + getOrgMembership, deleteOrgMembership, findAllWorkspaces, addGhostUser, @@ -676,6 +725,7 @@ export const orgServiceFactory = ({ findIncidentContacts, createIncidentContact, deleteIncidentContact, - getOrgGroups + getOrgGroups, + listProjectMembershipsByOrgMembershipId }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 0efc7ffe1..08b4c1c92 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -6,11 +6,16 @@ export type TUpdateOrgMembershipDTO = { userId: string; orgId: string; membershipId: string; - role: string; + role?: string; + isActive?: boolean; actorOrgId: string | undefined; actorAuthMethod: ActorAuthMethod; }; +export type TGetOrgMembershipDTO = { + membershipId: string; +} & TOrgPermission; + export type TDeleteOrgMembershipDTO = { userId: string; orgId: string; @@ -55,3 +60,7 @@ export type TUpdateOrgDTO = { } & TOrgPermission; export type TGetOrgGroupsDTO = TOrgPermission; + +export type TListProjectMembershipsByOrgMembershipIdDTO = { + orgMembershipId: string; +} & TOrgPermission; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 93ec6597e..19503ba3b 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -16,6 +16,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const docs = await db .replicaNode()(TableName.ProjectMembership) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) + .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .where((qb) => { if (filter.usernames) { @@ -58,17 +59,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole) + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project) ) .where({ isGhost: false }); const members = sqlNestRelationships({ data: docs, - parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({ + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId, projectName }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost } + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName + } }), key: "id", childrenMapper: [ @@ -151,14 +157,95 @@ export const projectMembershipDALFactory = (db: TDbClient) => { const findProjectMembershipsByUserId = async (orgId: string, userId: string) => { try { - const memberships = await db + const docs = await db .replicaNode()(TableName.ProjectMembership) - .where({ userId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) - .where({ [`${TableName.Project}.orgId` as "orgId"]: orgId }) - .select(selectAllTableCols(TableName.ProjectMembership)); + .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.Users}.id`, userId) + .where(`${TableName.Project}.orgId`, orgId) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.ProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole), + db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("id").as("projectId").withSchema(TableName.Project) + ) + .where({ isGhost: false }); - return memberships; + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, projectId, projectName }) => ({ + id, + userId, + projectId, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + project: { + id: projectId, + name: projectName + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; } catch (error) { throw new DatabaseError({ error, name: "Find project memberships by user id" }); } diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 521c36468..2026cede1 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -16,6 +16,8 @@ export { useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, + useGetOrgMembership, + useGetOrgMembershipProjectMemberships, useGetOrgUsers, useGetUser, useGetUserAction, @@ -23,6 +25,5 @@ export { useRegisterUserAction, useRevokeMySessions, useUpdateMfaEnabled, - useUpdateOrgUserRole, - useUpdateUserAuthMethods -} from "./queries"; + useUpdateOrgMembership, + useUpdateUserAuthMethods} from "./queries"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 26e932ac6..eb9bb8007 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -57,8 +57,9 @@ export const useAddUserToWsNonE2EE = () => { }); return data; }, - onSuccess: (_, { projectId }) => { + onSuccess: (_, { orgId, projectId }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(projectId)); + queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId)); } }); }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 5ca47fc63..ea3f44676 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -13,7 +13,8 @@ import { OrgUser, RenameUserDTO, TokenVersion, - UpdateOrgUserRoleDTO, + TWorkspaceUser, + UpdateOrgMembershipDTO, User, UserEnc } from "./types"; @@ -23,6 +24,13 @@ export const userKeys = { getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, + getOrgMembership: (orgId: string, orgMembershipId: string) => + [{ orgId, orgMembershipId }, "org-membership"] as const, + allOrgMembershipProjectMemberships: (orgId: string) => [orgId, "all-user-memberships"] as const, + forOrgMembershipProjectMemberships: (orgId: string, orgMembershipId: string) => + [...userKeys.allOrgMembershipProjectMemberships(orgId), { orgMembershipId }] as const, + getOrgMembershipProjectMemberships: (orgId: string, username: string) => + [{ orgId, username }, "org-membership-project-memberships"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], myIp: ["ip"] as const, myAPIKeys: ["api-keys"] as const, @@ -167,6 +175,41 @@ export const useAddUserToOrg = () => { }); }; +export const useGetOrgMembership = (organizationId: string, orgMembershipId: string) => { + return useQuery({ + queryKey: userKeys.getOrgMembership(organizationId, orgMembershipId), + queryFn: async () => { + const { + data: { membership } + } = await apiRequest.get<{ membership: OrgUser }>( + `/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}` + ); + + return membership; + }, + enabled: Boolean(organizationId) && Boolean(orgMembershipId) + }); +}; + +export const useGetOrgMembershipProjectMemberships = ( + organizationId: string, + orgMembershipId: string +) => { + return useQuery({ + queryKey: userKeys.forOrgMembershipProjectMemberships(organizationId, orgMembershipId), + queryFn: async () => { + const { + data: { memberships } + } = await apiRequest.get<{ memberships: TWorkspaceUser[] }>( + `/api/v2/organizations/${organizationId}/memberships/${orgMembershipId}/project-memberships` + ); + + return memberships; + }, + enabled: Boolean(organizationId) && Boolean(orgMembershipId) + }); +}; + export const useDeleteOrgMembership = () => { const queryClient = useQueryClient(); @@ -180,24 +223,43 @@ export const useDeleteOrgMembership = () => { }); }; -export const useUpdateOrgUserRole = () => { +export const useDeactivateOrgMembership = () => { const queryClient = useQueryClient(); - return useMutation<{}, {}, UpdateOrgUserRoleDTO>({ - mutationFn: ({ organizationId, membershipId, role }) => { + return useMutation<{}, {}, DeletOrgMembershipDTO>({ + mutationFn: ({ membershipId, orgId }) => { + return apiRequest.post( + `/api/v2/organizations/${orgId}/memberships/${membershipId}/deactivate` + ); + }, + onSuccess: (_, { orgId, membershipId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(orgId)); + queryClient.invalidateQueries(userKeys.getOrgMembership(orgId, membershipId)); + } + }); +}; + +export const useUpdateOrgMembership = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, UpdateOrgMembershipDTO>({ + mutationFn: ({ organizationId, membershipId, role, isActive }) => { return apiRequest.patch( `/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { - role + role, + isActive } ); }, - onSuccess: (_, { organizationId }) => { + onSuccess: (_, { organizationId, membershipId }) => { queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId)); }, // to remove old states - onError: (_, { organizationId }) => { + onError: (_, { organizationId, membershipId }) => { queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + queryClient.invalidateQueries(userKeys.getOrgMembership(organizationId, membershipId)); } }); }; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index a3793f942..4e2d7d3a2 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -49,6 +49,7 @@ export type OrgUser = { user: { username: string; email?: string; + isEmailVerified: boolean; firstName: string; lastName: string; id: string; @@ -82,6 +83,11 @@ export type TWorkspaceUser = { id: string; publicKey: string; }; + projectId: string; + project: { + id: string; + name: string; + }; inviteEmail: string; organization: string; roles: ( @@ -127,12 +133,14 @@ export type AddUserToWsDTOE2EE = { export type AddUserToWsDTONonE2EE = { projectId: string; usernames: string[]; + orgId: string; }; -export type UpdateOrgUserRoleDTO = { +export type UpdateOrgMembershipDTO = { organizationId: string; membershipId: string; - role: string; + role?: string; + isActive?: boolean; }; export type DeletOrgMembershipDTO = { diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 1b818fd83..11579b760 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -11,6 +11,7 @@ import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { EncryptedSecret } from "../secrets/types"; +import { userKeys } from "../users/queries"; import { TWorkspaceUser } from "../users/types"; import { CreateEnvironmentDTO, @@ -385,6 +386,7 @@ export const useDeleteUserFromWorkspace = () => { }: { workspaceId: string; usernames: string[]; + orgId: string; }) => { const { data: { deletedMembership } @@ -393,8 +395,9 @@ export const useDeleteUserFromWorkspace = () => { }); return deletedMembership; }, - onSuccess: (_, { workspaceId }) => { + onSuccess: (_, { orgId, workspaceId }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + queryClient.invalidateQueries(userKeys.allOrgMembershipProjectMemberships(orgId)); } }); }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index dc0ef370e..be4cf2086 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -264,7 +264,8 @@ export const AppLayout = ({ children }: LayoutProps) => { usernames: orgUsers .map((member) => member.user.username) .filter((username) => username !== user.username), - projectId: newProjectId + projectId: newProjectId, + orgId: currentOrg.id }); } diff --git a/frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx b/frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx new file mode 100644 index 000000000..093b7cd78 --- /dev/null +++ b/frontend/src/pages/org/[id]/memberships/[membershipId]/index.tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { UserPage } from "@app/views/Org/UserPage"; + +export default function User() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +User.requireAuth = true; diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 0dc8d50a5..6956f71a3 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -541,7 +541,8 @@ const OrganizationPage = withPermission( usernames: orgUsers .map((member) => member.user.username) .filter((username) => username !== user.username), - projectId: newProjectId + projectId: newProjectId, + orgId: currentOrg.id }); } diff --git a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx index 504c2405e..a005ad1c8 100644 --- a/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx +++ b/frontend/src/views/Org/IdentityPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx @@ -1,4 +1,4 @@ -import { faKey } from "@fortawesome/free-solid-svg-icons"; +import { faFolder } from "@fortawesome/free-solid-svg-icons"; import { EmptyState, @@ -37,7 +37,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => - {isLoading && } + {isLoading && } {!isLoading && projectMemberships?.map((membership) => { return ( @@ -51,7 +51,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {!isLoading && !projectMemberships?.length && ( - + )} ); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index f9dc72f4e..e4a54f1b5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -16,7 +16,7 @@ import { useOrganization, useSubscription } from "@app/context"; -import { useDeleteOrgMembership } from "@app/hooks/api"; +import { useDeleteOrgMembership, useUpdateOrgMembership } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddOrgMemberModal } from "./AddOrgMemberModal"; @@ -32,11 +32,13 @@ export const OrgMembersSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addMember", "removeMember", + "deactivateMember", "upgradePlan", "setUpEmail" ] as const); const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const isMoreUsersAllowed = subscription?.memberLimit ? subscription.membersUsed < subscription.memberLimit @@ -65,6 +67,29 @@ export const OrgMembersSection = () => { handlePopUpOpen("addMember"); }; + const onDeactivateMemberSubmit = async (orgMembershipId: string) => { + try { + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: false + }); + + createNotification({ + text: "Successfully deactivated user in organization", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to deactivate user in organization", + type: "error" + }); + } + + handlePopUpClose("deactivateMember"); + }; + const onRemoveMemberSubmit = async (orgMembershipId: string) => { try { await deleteMutateAsync({ @@ -128,6 +153,19 @@ export const OrgMembersSection = () => { ) } /> + handlePopUpToggle("deactivateMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeactivateMemberSubmit( + (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + /> handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index c62b31592..ae75c5186 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -1,13 +1,18 @@ import { useCallback, useMemo, useState } from "react"; -import { faMagnifyingGlass, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { useRouter } from "next/router"; +import { faEllipsis,faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, - IconButton, Input, Select, SelectItem, @@ -32,13 +37,13 @@ import { useFetchServerStatus, useGetOrgRoles, useGetOrgUsers, - useUpdateOrgUserRole + useUpdateOrgMembership } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>, + popUpName: keyof UsePopUpState<["removeMember", "deactivateMember", "upgradePlan"]>, data?: { orgMembershipId?: string; username?: string; @@ -49,6 +54,7 @@ type Props = { }; export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => { + const router = useRouter(); const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const { user } = useUser(); @@ -63,14 +69,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); const { mutateAsync: addUserMutateAsync } = useAddUserToOrg(); - const { mutateAsync: updateUserOrgRole } = useUpdateOrgUserRole(); + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const onRoleChange = async (membershipId: string, role: string) => { if (!currentOrg?.id) return; try { // TODO: replace hardcoding default role - const isCustomRole = !["admin", "member"].includes(role); + const isCustomRole = !["admin", "member", "no-access"].includes(role); if (isCustomRole && subscription && !subscription?.rbac) { handlePopUpOpen("upgradePlan", { @@ -79,7 +85,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop return; } - await updateUserOrgRole({ + await updateOrgMembership({ organizationId: currentOrg?.id, membershipId, role @@ -176,7 +182,11 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop const email = u?.email || inviteEmail; const username = u?.username ?? inviteEmail ?? "-"; return ( - + router.push(`/org/${orgId}/memberships/${orgMembershipId}`)} + > {name} {username} @@ -238,34 +248,117 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop {userId !== u?.id && ( - - {(isAllowed) => ( - { - if (currentOrg?.authEnforced) { - createNotification({ - text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", - type: "error" - }); - return; - } - - handlePopUpOpen("removeMember", { orgMembershipId, username }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + + +
+ +
+
+ + - -
- )} -
+ {(isAllowed) => ( + { + e.stopPropagation(); + router.push(`/org/${orgId}/memberships/${orgMembershipId}`); + }} + disabled={!isAllowed} + > + Edit User + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + if (currentOrg?.authEnforced) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + + if (!isActive) { + // activate user + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: true + }); + + return; + } + + // deactivate user + handlePopUpOpen("deactivateMember", { + orgMembershipId, + username + }); + }} + disabled={!isAllowed} + > + {`${isActive ? "Deactivate" : "Activate"} User`} + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + if (currentOrg?.authEnforced) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + + handlePopUpOpen("removeMember", { + orgMembershipId, + username + }); + }} + disabled={!isAllowed} + > + Remove User + + )} + + + )} diff --git a/frontend/src/views/Org/UserPage/UserPage.tsx b/frontend/src/views/Org/UserPage/UserPage.tsx new file mode 100644 index 000000000..7253d1c77 --- /dev/null +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -0,0 +1,277 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { + useDeleteOrgMembership, + useGetOrgMembership, + useUpdateOrgMembership +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { UserDetailsSection, UserOrgMembershipModal,UserProjectsSection } from "./components"; + +export const UserPage = withPermission( + () => { + const router = useRouter(); + const membershipId = router.query.membershipId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: membership } = useGetOrgMembership(orgId, membershipId); + + const { mutateAsync: deleteOrgMembership } = useDeleteOrgMembership(); + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeMember", + "orgMembership", + "deactivateMember", + "upgradePlan" + ] as const); + + const onDeactivateMemberSubmit = async (orgMembershipId: string) => { + try { + await updateOrgMembership({ + organizationId: orgId, + membershipId: orgMembershipId, + isActive: false + }); + + createNotification({ + text: "Successfully deactivated user in organization", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to deactivate user in organization", + type: "error" + }); + } + + handlePopUpClose("deactivateMember"); + }; + + const onRemoveMemberSubmit = async (orgMembershipId: string) => { + try { + await deleteOrgMembership({ + orgId, + membershipId: orgMembershipId + }); + + createNotification({ + text: "Successfully removed user from org", + type: "success" + }); + + handlePopUpClose("removeMember"); + router.push(`/org/${orgId}/members`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove user from the organization", + type: "error" + }); + } + + handlePopUpClose("removeMember"); + }; + + return ( +
+ {membership && ( +
+ +
+

+ {membership.user.firstName || membership.user.lastName + ? `${membership.user.firstName} ${membership.user.lastName}` + : "-"} +

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("orgMembership", { + membershipId: membership.id, + role: membership.role + }) + } + disabled={!isAllowed} + > + Edit User + + )} + + + {(isAllowed) => ( + { + if (currentOrg?.authEnforced) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + + if (!membership.isActive) { + // activate user + await updateOrgMembership({ + organizationId: orgId, + membershipId, + isActive: true + }); + + return; + } + + // deactivate user + handlePopUpOpen("deactivateMember", { + orgMembershipId: membershipId, + username: membership.user.username + }); + }} + disabled={!isAllowed} + > + {`${membership.isActive ? "Deactivate" : "Activate"} User`} + + )} + + + {(isAllowed) => ( + { + if (currentOrg?.authEnforced) { + createNotification({ + text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", + type: "error" + }); + return; + } + + handlePopUpOpen("removeMember", { + orgMembershipId: membershipId, + username: membership.user.username + }); + }} + disabled={!isAllowed} + > + Remove User + + )} + + +
+
+
+
+ +
+ +
+
+ )} + handlePopUpToggle("removeMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveMemberSubmit( + (popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + /> + handlePopUpToggle("deactivateMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeactivateMemberSubmit( + (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } +); diff --git a/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx new file mode 100644 index 000000000..00c605f05 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserDetailsSection.tsx @@ -0,0 +1,195 @@ +import { + faCheck, + faCheckCircle, + faCircleXmark, + faCopy, + faPencil} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, IconButton, Tooltip } from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useUser +} from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { + useAddUserToOrg, + useFetchServerStatus, + useGetOrgMembership, + useGetOrgRoles +} from "@app/hooks/api"; +import { OrgUser } from "@app/hooks/api/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + membershipId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["orgMembership"]>, data?: {}) => void; +}; + +export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) => { + const [copyTextUsername, isCopyingUsername, setCopyTextUsername] = useTimedReset({ + initialState: "Copy username to clipboard" + }); + + const { user } = useUser(); + const { currentOrg } = useOrganization(); + const userId = user?.id || ""; + const orgId = currentOrg?.id || ""; + + const { data: roles } = useGetOrgRoles(orgId); + const { data: serverDetails } = useFetchServerStatus(); + const { data: membership } = useGetOrgMembership(orgId, membershipId); + const { mutateAsync: inviteUser, isLoading } = useAddUserToOrg(); + + const onResendInvite = async (email: string) => { + try { + const { data } = await inviteUser({ + organizationId: orgId, + inviteeEmail: email + }); + + // setCompleteInviteLink(data?.completeInviteLink || ""); + + if (!data.completeInviteLink) { + createNotification({ + text: `Successfully resent invite to ${email}`, + type: "success" + }); + } + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to resend invite to ${email}`, + type: "error" + }); + } + }; + + const getStatus = (m: OrgUser) => { + if (!m.isActive) { + return "Deactivated"; + } + + return m.status === "invited" ? "Invited" : "Active"; + }; + + const roleName = roles?.find((r) => r.slug === membership?.role)?.name; + + return membership ? ( +
+
+

Details

+ {userId !== membership.user.id && ( + + {(isAllowed) => { + return ( + + { + handlePopUpOpen("orgMembership", { + membershipId: membership.id, + role: membership.role + }); + }} + > + + + + ); + }} + + )} +
+
+
+

Name

+

+ {membership.user.firstName || membership.user.lastName + ? `${membership.user.firstName} ${membership.user.lastName}` + : "-"} +

+
+
+

Username

+
+

{membership.user.username}

+
+ + { + navigator.clipboard.writeText(""); + setCopyTextUsername("Copied"); + }} + > + + + +
+
+
+
+

Email

+
+

{membership.user.email ?? "-"}

+ + + +
+
+
+

Organization Role

+

{roleName ?? "-"}

+
+
+

Status

+

{getStatus(membership)}

+
+ {membership.isActive && + (membership.status === "invited" || membership.status === "verified") && + membership.user.email && + serverDetails?.emailConfigured && ( + + {(isAllowed) => { + return ( + + ); + }} + + )} +
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx new file mode 100644 index 000000000..ce3db38d7 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserOrgMembershipModal.tsx @@ -0,0 +1,160 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + role: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["orgMembership"]>; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>, data?: {}) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["orgMembership"]>, state?: boolean) => void; +}; + +export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { + const { subscription } = useSubscription(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: roles } = useGetOrgRoles(orgId); + + const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const popUpData = popUp?.orgMembership?.data as { + membershipId: string; + role: string; + }; + + useEffect(() => { + if (!roles?.length) return; + + if (popUpData) { + reset({ + role: popUpData.role + }); + } else { + reset({ + role: roles[0].slug + }); + } + }, [popUp?.orgMembership?.data, roles]); + + const onFormSubmit = async ({ role }: FormData) => { + try { + if (!orgId) return; + + await updateOrgMembership({ + organizationId: orgId, + membershipId: popUpData.membershipId, + role + }); + + handlePopUpToggle("orgMembership", false); + + createNotification({ + text: "Successfully updated user organization role", + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to update user organization role"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("orgMembership", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx new file mode 100644 index 000000000..2cef4f5f3 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserAddToProjectModal.tsx @@ -0,0 +1,145 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { useAddUserToWsNonE2EE, useGetOrgMembershipProjectMemberships } from "@app/hooks/api"; +import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z + .object({ + projectId: z.string() + }) + .required(); + +type FormData = z.infer; + +type Props = { + membershipId: string; + popUp: UsePopUpState<["addUserToProject"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["addUserToProject"]>, + state?: boolean + ) => void; +}; + +export const UserAddToProjectModal = ({ membershipId, popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { workspaces } = useWorkspace(); + + const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); + + const popupData = popUp.addUserToProject.data as { + username: string; + }; + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const { data: projectMemberships } = useGetOrgMembershipProjectMemberships(orgId, membershipId); + + const filteredWorkspaces = useMemo(() => { + const wsWorkspaceIds = new Map(); + + projectMemberships?.forEach((projectMembership) => { + wsWorkspaceIds.set(projectMembership.project.id, true); + }); + + return (workspaces || []).filter( + ({ id, orgId: projectOrgId, version }) => + !wsWorkspaceIds.has(id) && projectOrgId === currentOrg?.id && version === ProjectVersion.V2 + ); + }, [workspaces, projectMemberships]); + + const onFormSubmit = async ({ projectId }: FormData) => { + try { + await addUserToWorkspaceNonE2EE({ + projectId, + usernames: [popupData.username], + orgId + }); + + createNotification({ + text: "Successfully added user to project", + type: "success" + }); + + reset(); + handlePopUpToggle("addUserToProject", false); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to add identity to project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("addUserToProject", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx new file mode 100644 index 000000000..31c3184af --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectRow.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { useRouter } from "next/router"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + membership: TWorkspaceUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void; +}; + +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Admin) return "Admin"; + if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.Viewer) return "Viewer"; + if (role === ProjectMembershipRole.NoAccess) return "No Access"; + return role; +}; + +export const UserProjectRow = ({ + membership: { id, project, user, roles }, + handlePopUpOpen +}: Props) => { + const { workspaces } = useWorkspace(); + const router = useRouter(); + + const isAccessible = useMemo(() => { + const workspaceIds = new Map(); + + workspaces?.forEach((workspace) => { + workspaceIds.set(workspace.id, true); + }); + + return workspaceIds.has(project.id); + }, [workspaces, project]); + + return ( + { + if (isAccessible) { + router.push(`/project/${project.id}/members`); + return; + } + + createNotification({ + text: "Unable to access project", + type: "error" + }); + }} + > + {project.name} + {`${formatRoleName(roles[0].role, roles[0].customRoleName)}${ + roles.length > 1 ? ` (+${roles.length - 1})` : "" + }`} + + {isAccessible && ( +
+ + { + e.stopPropagation(); + handlePopUpOpen("removeUserFromProject", { + username: user.username, + projectId: project.id, + projectName: project.name + }); + }} + > + + + +
+ )} + + + ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx new file mode 100644 index 000000000..ffd118abf --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsSection.tsx @@ -0,0 +1,98 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useOrganization,useUser } from "@app/context"; +import { useDeleteUserFromWorkspace, useGetOrgMembership } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { UserAddToProjectModal } from "./UserAddToProjectModal"; +import { UserProjectsTable } from "./UserProjectsTable"; + +type Props = { + membershipId: string; +}; + +export const UserProjectsSection = ({ membershipId }: Props) => { + const { user } = useUser(); + const { currentOrg } = useOrganization(); + + const userId = user?.id || ""; + const orgId = currentOrg?.id || ""; + + const { data: membership } = useGetOrgMembership(orgId, membershipId); + + const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addUserToProject", + "removeUserFromProject" + ] as const); + + const handleRemoveUser = async (projectId: string, username: string) => { + try { + await removeUserFromWorkspace({ workspaceId: projectId, usernames: [username], orgId }); + createNotification({ + text: "Successfully removed user from project", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove user from the project", + type: "error" + }); + } + handlePopUpClose("removeUserFromProject"); + }; + + return membership ? ( +
+
+

Projects

+ {userId !== membership.user.id && ( + { + handlePopUpOpen("addUserToProject", { + username: membership.user.username + }); + }} + > + + + )} +
+
+ +
+ + handlePopUpToggle("removeUserFromProject", isOpen)} + onDeleteApproved={() => { + const popupData = popUp?.removeUserFromProject?.data as { + username: string; + projectId: string; + projectName: string; + }; + + return handleRemoveUser(popupData.projectId, popupData.username); + }} + /> +
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx new file mode 100644 index 000000000..88e1a2925 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/UserProjectsTable.tsx @@ -0,0 +1,62 @@ +import { faFolder } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { UserProjectRow } from "./UserProjectRow"; + +type Props = { + membershipId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void; +}; + +export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships( + orgId, + membershipId + ); + + return ( + + + + + + + + + + {isLoading && } + {!isLoading && + projectMemberships?.map((membership) => { + return ( + + ); + })} + +
NameRole +
+ {!isLoading && !projectMemberships?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx b/frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx new file mode 100644 index 000000000..b298679d5 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/UserProjectsSection/index.tsx @@ -0,0 +1 @@ +export { UserProjectsSection } from "./UserProjectsSection"; diff --git a/frontend/src/views/Org/UserPage/components/index.tsx b/frontend/src/views/Org/UserPage/components/index.tsx new file mode 100644 index 000000000..6b13786d4 --- /dev/null +++ b/frontend/src/views/Org/UserPage/components/index.tsx @@ -0,0 +1,3 @@ +export { UserDetailsSection } from "./UserDetailsSection"; +export { UserOrgMembershipModal } from "./UserOrgMembershipModal"; +export { UserProjectsSection } from "./UserProjectsSection"; diff --git a/frontend/src/views/Org/UserPage/index.tsx b/frontend/src/views/Org/UserPage/index.tsx new file mode 100644 index 000000000..3338fc5e4 --- /dev/null +++ b/frontend/src/views/Org/UserPage/index.tsx @@ -0,0 +1 @@ +export { UserPage } from "./UserPage"; diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx index df76520c2..cd8137d8b 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/AddMemberModal.tsx @@ -6,14 +6,15 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button,FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; import { useAddUserToWsE2EE, useAddUserToWsNonE2EE, useGetOrgUsers, useGetUserWsKey, - useGetWorkspaceUsers} from "@app/hooks/api"; + useGetWorkspaceUsers +} from "@app/hooks/api"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -76,7 +77,8 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { } else if (currentWorkspace.version === ProjectVersion.V2) { await addUserToWorkspaceNonE2EE({ projectId: workspaceId, - usernames: [orgUser.user.username] + usernames: [orgUser.user.username], + orgId }); } else { createNotification({ From 6818c8730f0ed59434368386f4983ed4311a5429 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 16:49:54 +0200 Subject: [PATCH 03/30] chore: rename approverUserIds to approvers in registerSecretApprovalPolicy --- .../ee/routes/v1/secret-approval-policy-router.ts | 8 ++++---- .../secret-approval-policy-service.ts | 12 ++++++------ .../secret-approval-policy-types.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index ee10131dd..f25ae42f3 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -25,10 +25,10 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .optional() .nullable() .transform((val) => (val ? removeTrailingSlash(val) : val)), - approverUserIds: z.string().array().min(1), + approvers: z.string().array().min(1), approvals: z.number().min(1).default(1) }) - .refine((data) => data.approvals <= data.approverUserIds.length, { + .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], message: "The number of approvals should be lower than the number of approvers." }), @@ -66,7 +66,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi body: z .object({ name: z.string().optional(), - approverUserIds: z.string().array().min(1), + approvers: z.string().array().min(1), approvals: z.number().min(1).default(1), secretPath: z .string() @@ -74,7 +74,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .nullable() .transform((val) => (val ? removeTrailingSlash(val) : val)) }) - .refine((data) => data.approvals <= data.approverUserIds.length, { + .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], message: "The number of approvals should be lower than the number of approvers." }), diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 2db825c88..be6a334e2 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -45,12 +45,12 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actorAuthMethod, approvals, - approverUserIds, + approvers, projectId, secretPath, environment }: TCreateSapDTO) => { - if (approvals > approverUserIds.length) + if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); const { permission } = await permissionService.getProjectPermission( @@ -78,7 +78,7 @@ export const secretApprovalPolicyServiceFactory = ({ tx ); await secretApprovalPolicyApproverDAL.insertMany( - approverUserIds.map((approverUserId) => ({ + approvers.map((approverUserId) => ({ approverUserId, policyId: doc.id })), @@ -90,7 +90,7 @@ export const secretApprovalPolicyServiceFactory = ({ }; const updateSecretApprovalPolicy = async ({ - approverUserIds, + approvers, secretPath, name, actorId, @@ -122,10 +122,10 @@ export const secretApprovalPolicyServiceFactory = ({ }, tx ); - if (approverUserIds) { + if (approvers) { await secretApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); await secretApprovalPolicyApproverDAL.insertMany( - approverUserIds.map((approverUserId) => ({ + approvers.map((approverUserId) => ({ approverUserId, policyId: doc.id })), diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index 1a527289c..2ddd9b51b 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -4,7 +4,7 @@ export type TCreateSapDTO = { approvals: number; secretPath?: string | null; environment: string; - approverUserIds: string[]; + approvers: string[]; projectId: string; name: string; } & Omit; @@ -13,7 +13,7 @@ export type TUpdateSapDTO = { secretPolicyId: string; approvals?: number; secretPath?: string | null; - approverUserIds: string[]; + approvers: string[]; name?: string; } & Omit; From ada0033bd026e46bd1ee83228aa95f59d11e48f0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 17 Jul 2024 23:13:04 +0700 Subject: [PATCH 04/30] Fix type issue frontend --- .../MembersTab/components/MembersSection.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx index b4c230e19..db69fc6e6 100644 --- a/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MembersTab/components/MembersSection.tsx @@ -4,7 +4,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub , useOrganization, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteUserFromWorkspace } from "@app/hooks/api"; @@ -30,7 +35,11 @@ export const MembersSection = () => { if (!currentWorkspace?.id) return; try { - await removeUserFromWorkspace({ workspaceId: currentWorkspace.id, usernames: [username] }); + await removeUserFromWorkspace({ + workspaceId: currentWorkspace.id, + usernames: [username], + orgId: currentOrg.id + }); createNotification({ text: "Successfully removed user from project", type: "success" From d19c856e9b4f917dcf37e67c1f8335195bdb0acb Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 20:05:34 +0200 Subject: [PATCH 05/30] chore(frontend): rename approverUserIds to approvers in registerSecretApprovalPolicy --- frontend/src/hooks/api/secretApproval/mutation.tsx | 8 ++++---- frontend/src/hooks/api/secretApproval/types.ts | 5 +++-- .../components/SecretApprovalPolicyRow.tsx | 2 +- .../components/SecretPolicyForm.tsx | 8 ++++---- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index 991111ef9..e0a8df95d 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -9,12 +9,12 @@ export const useCreateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretPolicyDTO>({ - mutationFn: async ({ environment, workspaceId, approvals, approverUserIds, secretPath, name }) => { + mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name }) => { const { data } = await apiRequest.post("/api/v1/secret-approvals", { environment, workspaceId, approvals, - approverUserIds, + approvers, secretPath, name }); @@ -30,10 +30,10 @@ export const useUpdateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ - mutationFn: async ({ id, approverUserIds, approvals, secretPath, name }) => { + mutationFn: async ({ id, approvers, approvals, secretPath, name }) => { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { approvals, - approverUserIds, + approvers, secretPath, name }); diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index f3b8639f7..bc0d57d40 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -9,6 +9,7 @@ export type TSecretApprovalPolicy = { secretPath?: string; approvals: number; userApprovers: { userId: string }[]; + updatedAt: Date; }; export type TGetSecretApprovalPoliciesDTO = { @@ -26,14 +27,14 @@ export type TCreateSecretPolicyDTO = { name?: string; environment: string; secretPath?: string | null; - approverUserIds?: string[]; + approvers?: string[]; approvals?: number; }; export type TUpdateSecretPolicyDTO = { id: string; name?: string; - approverUserIds?: string[]; + approvers?: string[]; secretPath?: string | null; approvals?: number; // for invalidating list diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx index d3321850a..9f8a08c44 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx @@ -51,7 +51,7 @@ export const SecretApprovalPolicyRow = ({ { workspaceId, id: policy.id, - approverUserIds: selectedApprovers + approvers: selectedApprovers }, { onSettled: () => { diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx index b0db2affd..18b0b87c8 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -37,9 +37,9 @@ const formSchema = z name: z.string().optional(), secretPath: z.string().optional().nullable(), approvals: z.number().min(1), - approverUserIds: z.string().array().min(1) + approvers: z.string().array().min(1) }) - .refine((data) => data.approvals <= data.approverUserIds.length, { + .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], message: "The number of approvals should be lower than the number of approvers." }); @@ -62,7 +62,7 @@ export const SecretPolicyForm = ({ values: editValues ? { ...editValues, - approverUserIds: editValues.userApprovers.map(({ userId }) => userId), + approvers: editValues.userApprovers.map(({ userId }) => userId), environment: editValues.environment.slug } : undefined @@ -183,7 +183,7 @@ export const SecretPolicyForm = ({ /> ( Date: Wed, 17 Jul 2024 20:22:43 +0200 Subject: [PATCH 06/30] feat(frontend): welcome ApprovalPolicyList --- frontend/src/helpers/policies.ts | 12 + .../src/hooks/api/accessApproval/types.ts | 6 + frontend/src/hooks/api/policies/enums.ts | 9 + .../SecretApprovalPage/SecretApprovalPage.tsx | 28 +- .../ApprovalPolicyList/ApprovalPolicyList.tsx | 262 ++++++++++++ .../components/AccessPolicyModal.tsx | 376 ++++++++++++++++++ .../components/ApprovalPolicyRow.tsx | 191 +++++++++ .../components/ApprovalPolicyList/index.tsx | 1 + 8 files changed, 881 insertions(+), 4 deletions(-) create mode 100644 frontend/src/helpers/policies.ts create mode 100644 frontend/src/hooks/api/policies/enums.ts create mode 100644 frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx create mode 100644 frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/index.tsx diff --git a/frontend/src/helpers/policies.ts b/frontend/src/helpers/policies.ts new file mode 100644 index 000000000..c6d7c935a --- /dev/null +++ b/frontend/src/helpers/policies.ts @@ -0,0 +1,12 @@ +import { PolicyType } from "@app/hooks/api/policies/enums"; + +export const policyDetails: Record = { + [PolicyType.AccessPolicy]: { + className: "bg-lime-900 text-lime-100", + name: "Access Policy" + }, + [PolicyType.ChangePolicy]: { + className: "bg-indigo-900 text-indigo-100", + name: "Change Policy" + } +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 2176b8bc1..a6d09a227 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -1,3 +1,4 @@ +import { EnforcementLevel, PolicyType } from "../policies/enums"; import { TProjectPermission } from "../roles/types"; import { WorkspaceEnv } from "../workspace/types"; @@ -11,6 +12,11 @@ export type TAccessApprovalPolicy = { environment: WorkspaceEnv; projectId: string; approvers: string[]; + policyType: PolicyType; + approversRequired: boolean; + enforcementLevel: EnforcementLevel; + updatedAt: Date; + userApprovers?: { userId: string }[]; }; export type TAccessApprovalRequest = { diff --git a/frontend/src/hooks/api/policies/enums.ts b/frontend/src/hooks/api/policies/enums.ts new file mode 100644 index 000000000..f91bcb98c --- /dev/null +++ b/frontend/src/hooks/api/policies/enums.ts @@ -0,0 +1,9 @@ +export enum EnforcementLevel { + Hard = "hard", + Soft = "soft" +} + +export enum PolicyType { + ChangePolicy = "change", + AccessPolicy = "access" +} diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 9c273848f..f8d056cce 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -3,11 +3,14 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; import { Divider } from "@app/components/v2/Divider"; import { useWorkspace } from "@app/context"; +import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api"; import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; import { AccessApprovalRequest } from "./components/AccessApprovalRequest"; +import { ApprovalPolicyList } from "./components/ApprovalPolicyList"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; @@ -15,13 +18,19 @@ enum TabSection { SecretApprovalRequests = "approval-requests", SecretPolicies = "approval-rules", ResourcePolicies = "resource-rules", - ResourceApprovalRequests = "resource-requests" + ResourceApprovalRequests = "resource-requests", + Policies = "policies" } export const SecretApprovalPage = () => { const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; + const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId: projectId }); + const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); + const defaultTab = (accessApprovalRequestCount?.pendingCount || 0) > (secretApprovalReqCount?.open || 0) + ? TabSection.ResourceApprovalRequests + : TabSection.SecretApprovalRequests; return (
@@ -45,13 +54,21 @@ export const SecretApprovalPage = () => {
- + - Secret Requests + + Secret Requests + {Boolean(secretApprovalReqCount?.open) && ({secretApprovalReqCount?.open})} + Secret Policies - Access Requests + + Access Requests + {Boolean(accessApprovalRequestCount?.pendingCount) && {accessApprovalRequestCount?.pendingCount}} + Access Request Policies + + Policies @@ -65,6 +82,9 @@ export const SecretApprovalPage = () => { + + +
); diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx new file mode 100644 index 000000000..fb21521c9 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -0,0 +1,262 @@ +import { useMemo,useState } from "react"; +import { faCheckCircle,faChevronDown, faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + TProjectPermission, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, useGetSecretApprovalPolicies, useGetWorkspaceUsers } from "@app/hooks/api"; +import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; +import { PolicyType } from "@app/hooks/api/policies/enums"; +import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; + +import { AccessPolicyForm } from "./components/AccessPolicyModal"; +import { ApprovalPolicyRow } from "./components/ApprovalPolicyRow"; + +interface IProps { + workspaceId: string; +} + +const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => { + const { data: accessPolicies, isLoading: isAccessPoliciesLoading } = useGetAccessApprovalPolicies({ + projectSlug: currentWorkspace?.slug as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.slug + } + }); + const { data: secretPolicies, isLoading: isSecretPoliciesLoading } = useGetSecretApprovalPolicies({ + workspaceId: currentWorkspace?.id as string, + options: { + enabled: + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.id + } + }); + + // merge data sorted by updatedAt + const policies = [ + ...(accessPolicies?.map(policy => ({ ...policy, policyType: PolicyType.AccessPolicy })) || []), + ...(secretPolicies?.map(policy => ({ ...policy, policyType: PolicyType.ChangePolicy })) || []) + ].sort((a, b) => { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); + + return { + policies, + isLoading: isAccessPoliciesLoading || isSecretPoliciesLoading + }; +}; + +export const ApprovalPolicyList = ({ workspaceId }: IProps) => { + const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ + "policyForm", + "deletePolicy", + "upgradePlan" + ] as const); + const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: members } = useGetWorkspaceUsers(workspaceId); + const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(permission, currentWorkspace); + + const [filterType, setFilterType] = useState(null); + + const filteredPolicies = useMemo(() => { + return filterType + ? policies.filter(policy => policy.policyType === filterType) + : policies; + }, [policies, filterType]); + + const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); + const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy(); + + const handleDeletePolicy = async () => { + const { id, policyType } = popUp.deletePolicy.data as TAccessApprovalPolicy; + if (!currentWorkspace?.slug) return; + + try { + if (policyType === PolicyType.ChangePolicy) { + await deleteSecretApprovalPolicy({ + workspaceId, + id + }); + } else { + await deleteAccessApprovalPolicy({ + projectSlug: currentWorkspace?.slug, + id + }); + } + createNotification({ + type: "success", + text: "Successfully deleted policy" + }); + handlePopUpClose("deletePolicy"); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete policy" + }); + } + }; + + return ( +
+
+
+ Policies +
+ Implement granular policies for access requests and secrets management. +
+
+
+ + {(isAllowed) => ( + + )} + +
+
+ + + + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !filteredPolicies?.length && ( + + + + )} + {!!currentWorkspace && + filteredPolicies?.map((policy) => ( + handlePopUpOpen("policyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
NameEnvironmentSecret PathEligible ApproversApproval Required + + + + + + Select a type + setFilterType(null)} + icon={!filterType && } + iconPos="right" + > + All + + setFilterType(PolicyType.AccessPolicy)} + icon={filterType === PolicyType.AccessPolicy && } + iconPos="right" + > + Access Policy + + setFilterType(PolicyType.ChangePolicy)} + icon={filterType === PolicyType.ChangePolicy && } + iconPos="right" + > + Change Policy + + + + +
+ +
+
+ handlePopUpToggle("policyForm", isOpen)} + members={members} + editValues={popUp.policyForm.data as TAccessApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret approval policy if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx new file mode 100644 index 000000000..9ebd36661 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -0,0 +1,376 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Alert, + AlertDescription, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { policyDetails } from "@app/helpers/policies"; +import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { + useCreateAccessApprovalPolicy, + useUpdateAccessApprovalPolicy +} from "@app/hooks/api/accessApproval"; +import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + members?: TWorkspaceUser[]; + projectSlug: string; + editValues?: TAccessApprovalPolicy; +}; + +const formSchema = z +.object({ + environment: z.string(), + name: z.string().optional(), + secretPath: z.string().optional(), + approvals: z.number().min(1), + approvers: z.string().array().min(1), + policyType: z.nativeEnum(PolicyType), + enforcementLevel: z.nativeEnum(EnforcementLevel) +}) +.refine((data) => data.approvals <= data.approvers.length, { + path: ["approvals"], + message: "The number of approvals should be lower than the number of approvers." +}); + +type TFormSchema = z.infer; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + values: editValues ? { + ...editValues, + environment: editValues.environment.slug, + approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers + } : undefined + }); + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + const isEditMode = Boolean(editValues); + + useEffect(() => { + if (!isOpen || !isEditMode) reset({}); + }, [isOpen, isEditMode]); + + const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); + const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); + + const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); + const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); + + const enforcementLevel = watch("enforcementLevel"); + const policyName = policyDetails[watch("policyType")]?.name || "Policy"; + + const handleCreatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + + try { + if (data.policyType === PolicyType.ChangePolicy) { + await createSecretApprovalPolicy({ + ...data, + workspaceId: currentWorkspace?.id || "" + }); + } else { + await createAccessApprovalPolicy({ + ...data, + projectSlug + }); + } + createNotification({ + type: "success", + text: "Successfully created policy" + }); + onToggle(false); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create policy" + }); + } + }; + + const handleUpdatePolicy = async (data: TFormSchema) => { + if (!projectSlug) return; + if (!editValues?.id) return; + + try { + if (data.policyType === PolicyType.ChangePolicy) { + await updateSecretApprovalPolicy({ + id: editValues?.id, + ...data, + workspaceId: currentWorkspace?.id || "" + }); + } else { + await updateAccessApprovalPolicy({ + id: editValues?.id, + ...data, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); + } + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "failed to update policy" + }); + } + }; + + const handleFormSubmit = async (data: TFormSchema) => { + if (isEditMode) { + await handleUpdatePolicy(data); + } else { + await handleCreatePolicy(data); + } + }; + + const formatEnforcementLevel = (level: EnforcementLevel) => { + if (level === EnforcementLevel.Hard) return "Hard"; + if (level === EnforcementLevel.Soft) return "Soft"; + return level; + }; + + return ( + + +
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + + + + + Select members that are allowed to approve requests + + {members.map(({ id, user }) => { + const userId = watch("policyType") === PolicyType.ChangePolicy ? user.id : id; + const isChecked = value?.includes(userId); + return ( + { + evt.preventDefault(); + onChange( + isChecked ? value?.filter((el: string) => el !== userId) : [...(value || []), userId] + ); + }} + key={`create-policy-members-${userId}`} + iconPos="right" + icon={isChecked && } + > + {user.username} + + ); + })} + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + ( + + + + )} + /> + {enforcementLevel === EnforcementLevel.Soft && ( + + + Soft enforcement allows requesters to bypass approval, which may reduce system security and stability. + + + )} +
+ + +
+ +
+
+
+ ); +}; + diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx new file mode 100644 index 000000000..e251f88ca --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; +import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Input, + Td, + Tr +} from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { policyDetails } from "@app/helpers/policies"; +import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { PolicyType } from "@app/hooks/api/policies/enums"; +import { WorkspaceEnv } from "@app/hooks/api/types"; +import { TWorkspaceUser } from "@app/hooks/api/users/types"; + +interface IPolicy { + id: string; + name: string; + environment: WorkspaceEnv; + projectId?: string; + secretPath?: string; + approvals: number; + approvers?: string[]; + userApprovers?: { userId: string }[]; + updatedAt: Date; + policyType: PolicyType; +}; + +type Props = { + policy: IPolicy; + members?: TWorkspaceUser[]; + projectSlug: string; + workspaceId: string; + onEdit: () => void; + onDelete: () => void; +}; + +export const ApprovalPolicyRow = ({ + policy, + members = [], + projectSlug, + workspaceId, + onEdit, + onDelete +}: Props) => { + const [selectedApprovers, setSelectedApprovers] = useState(policy.userApprovers?.map(({ userId }) => userId) || policy.approvers || []); + const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); + const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); + const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; + + const { permission } = useProjectPermission(); + + return ( + + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + { + if (!isOpen) { + if (policy.policyType === PolicyType.AccessPolicy) { + updateAccessApprovalPolicy( + { + projectSlug, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + // No changes needed here + } + } + ); + } else { + updateSecretApprovalPolicy( + { + workspaceId, + id: policy.id, + approvers: selectedApprovers + }, + { + onSettled: () => { + // No changes needed here + } + } + ); + } + } else { + setSelectedApprovers(policy.policyType === PolicyType.ChangePolicy + ? policy?.userApprovers?.map(({ userId }) => userId) || [] + : policy?.approvers || [] + ); + } + }} + > + + + + + + Select members that are allowed to approve changes + + {members?.map(({ id, user }) => { + const userId = policy.policyType === PolicyType.ChangePolicy ? user.id : id; + const isChecked = selectedApprovers.includes(userId); + return ( + { + evt.preventDefault(); + setSelectedApprovers((state) => + isChecked ? state.filter((el) => el !== userId) : [...state, userId] + ); + }} + key={`create-policy-members-${userId}`} + iconPos="right" + icon={isChecked && } + > + {user.username} + + ); + })} + + + + {policy.approvals} + + + {policyDetails[policy.policyType].name} + + + +
+ + {(isAllowed) => ( + + + + )} + + + {(isAllowed) => ( + + + + )} + +
+ + + ); +}; diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/index.tsx new file mode 100644 index 000000000..2817ec627 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { ApprovalPolicyList } from "./ApprovalPolicyList"; From 7b3e116bf8f14bd2996b9881977a47025723c68f Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 20:26:27 +0200 Subject: [PATCH 07/30] feat(frontend): remove AccessApprovalPolicyList --- .../SecretApprovalPage/SecretApprovalPage.tsx | 6 - .../AccessApprovalPolicyList.tsx | 174 ------------ .../components/AccessApprovalPolicyRow.tsx | 146 ---------- .../components/AccessPolicyModal.tsx | 266 ------------------ .../AccessApprovalPolicyList/index.tsx | 1 - 5 files changed, 593 deletions(-) delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index f8d056cce..df0ba55f9 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -8,7 +8,6 @@ import { Divider } from "@app/components/v2/Divider"; import { useWorkspace } from "@app/context"; import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api"; -import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; import { AccessApprovalRequest } from "./components/AccessApprovalRequest"; import { ApprovalPolicyList } from "./components/ApprovalPolicyList"; import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; @@ -66,8 +65,6 @@ export const SecretApprovalPage = () => { Access Requests {Boolean(accessApprovalRequestCount?.pendingCount) && {accessApprovalRequestCount?.pendingCount}} - Access Request Policies - Policies @@ -79,9 +76,6 @@ export const SecretApprovalPage = () => { - - - diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx deleted file mode 100644 index aa47cce80..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/AccessApprovalPolicyList.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - DeleteActionModal, - EmptyState, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr, - UpgradePlanModal -} from "@app/components/v2"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - useProjectPermission, - useSubscription, - useWorkspace -} from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useDeleteAccessApprovalPolicy, useGetWorkspaceUsers } from "@app/hooks/api"; -import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; -import { TAccessApprovalPolicy } from "@app/hooks/api/types"; - -import { AccessApprovalPolicyRow } from "./components/AccessApprovalPolicyRow"; -import { AccessPolicyForm } from "./components/AccessPolicyModal"; - -interface IProps { - workspaceId: string; -} - -export const AccessApprovalPolicyList = ({ workspaceId }: IProps) => { - const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ - "secretPolicyForm", - "deletePolicy", - "upgradePlan" - ] as const); - const { permission } = useProjectPermission(); - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - - const { data: members } = useGetWorkspaceUsers(workspaceId); - const { data: policies, isLoading: isPoliciesLoading } = useGetAccessApprovalPolicies({ - projectSlug: currentWorkspace?.slug as string, - options: { - enabled: - permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && - !!currentWorkspace?.slug - } - }); - - const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteAccessApprovalPolicy(); - - const handleDeletePolicy = async () => { - const { id } = popUp.deletePolicy.data as TAccessApprovalPolicy; - if (!currentWorkspace?.slug) return; - - try { - await deleteSecretApprovalPolicy({ - projectSlug: currentWorkspace?.slug, - id - }); - createNotification({ - type: "success", - text: "Successfully deleted policy" - }); - handlePopUpClose("deletePolicy"); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to delete policy" - }); - } - }; - - return ( -
-
-
- Access Request Policies -
- Implement secret request policies for specific secrets and environments. -
-
-
- - {(isAllowed) => ( - - )} - -
-
- - - - - - - - - - - - - {isPoliciesLoading && ( - - )} - {!isPoliciesLoading && !policies?.length && ( - - - - )} - {!!currentWorkspace && - policies?.map((policy) => ( - handlePopUpOpen("secretPolicyForm", policy)} - onDelete={() => handlePopUpOpen("deletePolicy", policy)} - /> - ))} - -
NameEnvironmentSecret PathEligible ApproversApproval Required -
- -
-
- handlePopUpToggle("secretPolicyForm", isOpen)} - members={members} - editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy} - /> - handlePopUpToggle("deletePolicy", isOpen)} - onDeleteApproved={handleDeletePolicy} - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You can add secret approval policy if you switch to Infisical's Enterprise plan." - /> -
- ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx deleted file mode 100644 index 8476bac8d..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessApprovalPolicyRow.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useState } from "react"; -import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - IconButton, - Input, - Td, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; -import { useUpdateAccessApprovalPolicy } from "@app/hooks/api"; -import { TAccessApprovalPolicy } from "@app/hooks/api/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - policy: TAccessApprovalPolicy; - members?: TWorkspaceUser[]; - projectSlug: string; - onEdit: () => void; - onDelete: () => void; -}; - -export const AccessApprovalPolicyRow = ({ - policy, - members = [], - projectSlug, - onEdit, - onDelete -}: Props) => { - const [selectedApprovers, setSelectedApprovers] = useState([]); - const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy(); - const { permission } = useProjectPermission(); - - return ( - - {policy.name} - {policy.environment.slug} - {policy.secretPath || "*"} - - { - if (!isOpen) { - updateAccessApprovalPolicy( - { - projectSlug, - id: policy.id, - approvers: selectedApprovers - }, - { - onSettled: () => { - setSelectedApprovers([]); - } - } - ); - } else { - setSelectedApprovers(policy.approvers); - } - }} - > - - - - - - Select members that are allowed to approve changes - - {members?.map(({ id, user }) => { - const isChecked = selectedApprovers.includes(id); - return ( - { - evt.preventDefault(); - setSelectedApprovers((state) => - isChecked ? state.filter((el) => el !== id) : [...state, id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - - - {policy.approvals} - -
- - {(isAllowed) => ( - - - - )} - - - {(isAllowed) => ( - - - - )} - -
- - - ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx deleted file mode 100644 index 6c0ee3fb6..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/components/AccessPolicyModal.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useCreateAccessApprovalPolicy, - useUpdateAccessApprovalPolicy -} from "@app/hooks/api/accessApproval"; -import { TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - projectSlug: string; - editValues?: TAccessApprovalPolicy; -}; - -const formSchema = z - .object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional(), - approvals: z.number().min(1), - approvers: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." - }); - -type TFormSchema = z.infer; - -export const AccessPolicyForm = ({ - isOpen, - onToggle, - members = [], - projectSlug, - editValues -}: Props) => { - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined - }); - const { currentWorkspace } = useWorkspace(); - - const environments = currentWorkspace?.environments || []; - useEffect(() => { - if (!isOpen) reset({}); - }, [isOpen]); - - const isEditMode = Boolean(editValues); - - const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); - const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); - - const handleCreatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - - try { - await createAccessApprovalPolicy({ - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully created policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create policy" - }); - } - }; - - const handleUpdatePolicy = async (data: TFormSchema) => { - if (!projectSlug) return; - if (!editValues?.id) return; - - try { - await updateAccessApprovalPolicy({ - id: editValues?.id, - ...data, - projectSlug - }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "failed to update policy" - }); - } - }; - - const handleFormSubmit = async (data: TFormSchema) => { - if (isEditMode) { - await handleUpdatePolicy(data); - } else { - await handleCreatePolicy(data); - } - }; - - return ( - - -
- ( - - - - )} - /> - ( - - - - )} - /> - - ( - - - - )} - /> - - ( - - - - - - - - Select members that are allowed to approve changes - - {members.map(({ id, user }) => { - const isChecked = value?.includes(id); - return ( - { - evt.preventDefault(); - onChange( - isChecked ? value?.filter((el) => el !== id) : [...(value || []), id] - ); - }} - key={`create-policy-members-${id}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> -
- - -
- -
-
- ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx deleted file mode 100644 index f6db07c94..000000000 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalPolicyList/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList"; From 869fcd6541698cd965a52858b691f8bc18830bc9 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 21:30:55 +0200 Subject: [PATCH 08/30] feat(frontend): remove SecretApprovalPolicyList --- .../SecretApprovalPage/SecretApprovalPage.tsx | 7 - .../SecretApprovalPolicyList.tsx | 178 ------------ .../components/SecretApprovalPolicyRow.tsx | 148 ---------- .../components/SecretPolicyForm.tsx | 262 ------------------ .../SecretApprovalPolicyList/index.tsx | 1 - 5 files changed, 596 deletions(-) delete mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx delete mode 100644 frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index df0ba55f9..8d7282130 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -4,13 +4,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; -import { Divider } from "@app/components/v2/Divider"; import { useWorkspace } from "@app/context"; import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api"; import { AccessApprovalRequest } from "./components/AccessApprovalRequest"; import { ApprovalPolicyList } from "./components/ApprovalPolicyList"; -import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; enum TabSection { @@ -59,17 +57,12 @@ export const SecretApprovalPage = () => { Secret Requests {Boolean(secretApprovalReqCount?.open) && ({secretApprovalReqCount?.open})} - Secret Policies - Access Requests {Boolean(accessApprovalRequestCount?.pendingCount) && {accessApprovalRequestCount?.pendingCount}} Policies - - - diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx deleted file mode 100644 index f377f44e9..000000000 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/SecretApprovalPolicyList.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { faFileShield, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - DeleteActionModal, - EmptyState, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr, - UpgradePlanModal -} from "@app/components/v2"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - useProjectPermission, - useSubscription -} from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { - useDeleteSecretApprovalPolicy, - useGetSecretApprovalPolicies, - useGetWorkspaceUsers -} from "@app/hooks/api"; -import { TSecretApprovalPolicy } from "@app/hooks/api/types"; - -import { SecretApprovalPolicyRow } from "./components/SecretApprovalPolicyRow"; -import { SecretPolicyForm } from "./components/SecretPolicyForm"; - -type Props = { - workspaceId: string; -}; - -export const SecretApprovalPolicyList = ({ workspaceId }: Props) => { - const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([ - "secretPolicyForm", - "deletePolicy", - "upgradePlan" - ] as const); - const { permission } = useProjectPermission(); - const { subscription } = useSubscription(); - - const { data: members } = useGetWorkspaceUsers(workspaceId); - const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({ - workspaceId, - options: { - enabled: permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) - } - }); - - const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); - - const handleDeletePolicy = async () => { - const { id } = popUp.deletePolicy.data as TSecretApprovalPolicy; - try { - await deleteSecretApprovalPolicy({ - workspaceId, - id - }); - createNotification({ - type: "success", - text: "Successfully deleted policy" - }); - handlePopUpClose("deletePolicy"); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to delete policy" - }); - } - }; - - return ( -
-
-
- Approval Policies -
- Implement policies to prevent unauthorized secret changes. -
-
-
- - {(isAllowed) => ( - - )} - -
-
- - - - - - - - - - - - - {isPoliciesLoading && ( - - )} - {!isPoliciesLoading && !policies?.length && ( - - - - )} - {policies?.map((policy) => ( - handlePopUpOpen("secretPolicyForm", policy)} - onDelete={() => handlePopUpOpen("deletePolicy", policy)} - /> - ))} - -
NameEnvironmentSecret PathEligible ApproversApproval Required
- -
-
- handlePopUpToggle("secretPolicyForm", isOpen)} - > - - handlePopUpToggle("secretPolicyForm", isOpen)} - members={members} - editValues={popUp.secretPolicyForm.data as TSecretApprovalPolicy} - /> - - - handlePopUpToggle("deletePolicy", isOpen)} - onDeleteApproved={handleDeletePolicy} - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You can add secret approval policy if you switch to Infisical's Enterprise plan." - /> -
- ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx deleted file mode 100644 index 9f8a08c44..000000000 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretApprovalPolicyRow.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useState } from "react"; -import { faCheckCircle, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - IconButton, - Input, - Td, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; -import { useUpdateSecretApprovalPolicy } from "@app/hooks/api"; -import { TSecretApprovalPolicy } from "@app/hooks/api/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - policy: TSecretApprovalPolicy; - members?: TWorkspaceUser[]; - workspaceId: string; - onEdit: () => void; - onDelete: () => void; -}; - -export const SecretApprovalPolicyRow = ({ - policy, - members = [], - workspaceId, - onEdit, - onDelete -}: Props) => { - const [selectedApprovers, setSelectedApprovers] = useState([]); - const { mutate: updateSecretApprovalPolicy, isLoading } = useUpdateSecretApprovalPolicy(); - const { permission } = useProjectPermission(); - - return ( - - {policy.name} - {policy.environment.slug} - {policy.secretPath || "*"} - - { - if (!isOpen) { - updateSecretApprovalPolicy( - { - workspaceId, - id: policy.id, - approvers: selectedApprovers - }, - { - onSettled: () => { - setSelectedApprovers([]); - } - } - ); - } else { - setSelectedApprovers(policy.userApprovers.map(({ userId }) => userId)); - } - }} - > - - - - - - Select members that are allowed to approve changes - - {members?.map(({ user }) => { - const isChecked = selectedApprovers.includes(user.id); - return ( - { - evt.preventDefault(); - setSelectedApprovers((state) => - isChecked ? state.filter((el) => el !== user.id) : [...state, user.id] - ); - }} - key={`create-policy-members-${user.id}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - - - {policy.approvals} - -
- - {(isAllowed) => ( - - - - )} - - - {(isAllowed) => ( - - - - )} - -
- - - ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx deleted file mode 100644 index 18b0b87c8..000000000 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { Controller, useForm } from "react-hook-form"; -import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - FormControl, - Input, - Select, - SelectItem -} from "@app/components/v2"; -import { SecretPathInput } from "@app/components/v2/SecretPathInput"; -import { useWorkspace } from "@app/context"; -import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; -import { TSecretApprovalPolicy } from "@app/hooks/api/types"; -import { TWorkspaceUser } from "@app/hooks/api/users/types"; - -type Props = { - isOpen?: boolean; - onToggle: (isOpen: boolean) => void; - members?: TWorkspaceUser[]; - workspaceId: string; - editValues?: TSecretApprovalPolicy; -}; - -const formSchema = z - .object({ - environment: z.string(), - name: z.string().optional(), - secretPath: z.string().optional().nullable(), - approvals: z.number().min(1), - approvers: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." - }); - -type TFormSchema = z.infer; - -export const SecretPolicyForm = ({ - onToggle, - members = [], - workspaceId, - editValues -}: Props) => { - const { - control, - handleSubmit, - watch, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - values: editValues - ? { - ...editValues, - approvers: editValues.userApprovers.map(({ userId }) => userId), - environment: editValues.environment.slug - } - : undefined - }); - const { currentWorkspace } = useWorkspace(); - const selectedEnvironment = watch("environment"); - - const environments = currentWorkspace?.environments || []; - - const isEditMode = Boolean(editValues); - - const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy(); - const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); - - const handleCreatePolicy = async (data: TFormSchema) => { - try { - await createSecretApprovalPolicy({ - ...data, - workspaceId - }); - createNotification({ - type: "success", - text: "Successfully created policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create policy" - }); - } - }; - - const handleUpdatePolicy = async (data: TFormSchema) => { - if (!editValues?.id) return; - try { - await updateSecretApprovalPolicy({ - id: editValues?.id, - ...data, - secretPath: data.secretPath || null, - workspaceId - }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "failed to update policy" - }); - } - }; - - const handleFormSubmit = async (data: TFormSchema) => { - if (isEditMode) { - await handleUpdatePolicy(data); - } else { - await handleCreatePolicy(data); - } - }; - - return ( -
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - - - - - Select members that are allowed to approve changes - - {members.map(({ user }) => { - const isChecked = value?.includes(user.id); - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter((el) => el !== user.id) - : [...(value || []), user.id] - ); - }} - key={`create-policy-members-${user.id}`} - iconPos="right" - icon={isChecked && } - > - {user.username} - - ); - })} - - - - )} - /> - ( - - field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> -
- - -
- - - ); -}; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx deleted file mode 100644 index f204264b4..000000000 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SecretApprovalPolicyList } from "./SecretApprovalPolicyList"; From 2c57bd94fb00f27a316626b292834b4030e00751 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 21:39:33 +0200 Subject: [PATCH 09/30] feat(backend): add enforcementLevel into secret_approval_policies --- ...4929_add-enforcement-level-secrets-policies.ts | 15 +++++++++++++++ .../src/db/schemas/secret-approval-policies.ts | 3 +++ .../ee/routes/v1/secret-approval-policy-router.ts | 10 +++++++--- .../secret-approval-policy-service.ts | 12 ++++++++---- .../secret-approval-policy-types.ts | 4 +++- backend/src/lib/types/index.ts | 5 +++++ 6 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 backend/src/db/migrations/20240717184929_add-enforcement-level-secrets-policies.ts diff --git a/backend/src/db/migrations/20240717184929_add-enforcement-level-secrets-policies.ts b/backend/src/db/migrations/20240717184929_add-enforcement-level-secrets-policies.ts new file mode 100644 index 000000000..4a1df888a --- /dev/null +++ b/backend/src/db/migrations/20240717184929_add-enforcement-level-secrets-policies.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { EnforcementLevel } from "@app/lib/types"; + +export async function up(knex: Knex): Promise { + await knex.schema.table("secret_approval_policies", (table) => { + table.specificType("enforcementLevel", "VARCHAR(10)").notNullable().defaultTo(EnforcementLevel.Hard); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table("secret_approval_policies", (table) => { + table.dropColumn("enforcementLevel"); + }); +} diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index d907ef1e0..bac9c4b2e 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { EnforcementLevel } from "@app/lib/types"; + import { TImmutableDBKeys } from "./models"; export const SecretApprovalPoliciesSchema = z.object({ @@ -12,6 +14,7 @@ export const SecretApprovalPoliciesSchema = z.object({ name: z.string(), secretPath: z.string().nullable().optional(), approvals: z.number().default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel), envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date() diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index f25ae42f3..305c588a6 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -2,6 +2,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { removeTrailingSlash } from "@app/lib/fn"; +import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; @@ -26,7 +27,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .nullable() .transform((val) => (val ? removeTrailingSlash(val) : val)), approvers: z.string().array().min(1), - approvals: z.number().min(1).default(1) + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel) }) .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], @@ -47,7 +49,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi actorOrgId: req.permission.orgId, projectId: req.body.workspaceId, ...req.body, - name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` + name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`, + enforcementLevel: req.body.enforcementLevel ?? EnforcementLevel.Hard }); return { approval }; } @@ -72,7 +75,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .string() .optional() .nullable() - .transform((val) => (val ? removeTrailingSlash(val) : val)) + .transform((val) => (val ? removeTrailingSlash(val) : val)), + enforcementLevel: z.nativeEnum(EnforcementLevel) }) .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index be6a334e2..366ad69ea 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -48,7 +48,8 @@ export const secretApprovalPolicyServiceFactory = ({ approvers, projectId, secretPath, - environment + environment, + enforcementLevel }: TCreateSapDTO) => { if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); @@ -73,7 +74,8 @@ export const secretApprovalPolicyServiceFactory = ({ envId: env.id, approvals, secretPath, - name + name, + enforcementLevel }, tx ); @@ -98,7 +100,8 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actorAuthMethod, approvals, - secretPolicyId + secretPolicyId, + enforcementLevel }: TUpdateSapDTO) => { const secretApprovalPolicy = await secretApprovalPolicyDAL.findById(secretPolicyId); if (!secretApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); @@ -118,7 +121,8 @@ export const secretApprovalPolicyServiceFactory = ({ { approvals, secretPath, - name + name, + enforcementLevel }, tx ); diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index 2ddd9b51b..8e7099c98 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -1,4 +1,4 @@ -import { TProjectPermission } from "@app/lib/types"; +import { EnforcementLevel, TProjectPermission } from "@app/lib/types"; export type TCreateSapDTO = { approvals: number; @@ -7,6 +7,7 @@ export type TCreateSapDTO = { approvers: string[]; projectId: string; name: string; + enforcementLevel: EnforcementLevel; } & Omit; export type TUpdateSapDTO = { @@ -15,6 +16,7 @@ export type TUpdateSapDTO = { secretPath?: string | null; approvers: string[]; name?: string; + enforcementLevel?: EnforcementLevel; } & Omit; export type TDeleteSapDTO = { diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 2c41f4d23..382762aaa 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -42,3 +42,8 @@ export type RequiredKeys = { }[keyof T]; export type PickRequired = Pick>; + +export enum EnforcementLevel { + Hard = "hard", + Soft = "soft" +} From f3f87cfd84460e3aea5309c55fa35b32f681728f Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 21:43:53 +0200 Subject: [PATCH 10/30] feat(frontend): add enforcementLevel into SecretPolicy --- .../src/hooks/api/secretApproval/mutation.tsx | 10 ++++++---- frontend/src/hooks/api/secretApproval/types.ts | 3 +++ .../components/AccessPolicyModal.tsx | 10 +++++----- .../components/ApprovalPolicyRow.tsx | 15 ++++----------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index e0a8df95d..2ad79932b 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -9,14 +9,15 @@ export const useCreateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretPolicyDTO>({ - mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name }) => { + mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name, enforcementLevel }) => { const { data } = await apiRequest.post("/api/v1/secret-approvals", { environment, workspaceId, approvals, approvers, secretPath, - name + name, + enforcementLevel }); return data; }, @@ -30,12 +31,13 @@ export const useUpdateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ - mutationFn: async ({ id, approvers, approvals, secretPath, name }) => { + mutationFn: async ({ id, approvers, approvals, secretPath, name, enforcementLevel }) => { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { approvals, approvers, secretPath, - name + name, + enforcementLevel }); return data; }, diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index bc0d57d40..2a64ccdc9 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -1,3 +1,4 @@ +import { EnforcementLevel } from "../policies/enums"; import { WorkspaceEnv } from "../workspace/types"; export type TSecretApprovalPolicy = { @@ -29,6 +30,7 @@ export type TCreateSecretPolicyDTO = { secretPath?: string | null; approvers?: string[]; approvals?: number; + enforcementLevel: EnforcementLevel; }; export type TUpdateSecretPolicyDTO = { @@ -37,6 +39,7 @@ export type TUpdateSecretPolicyDTO = { approvers?: string[]; secretPath?: string | null; approvals?: number; + enforcementLevel?: EnforcementLevel; // for invalidating list workspaceId: string; }; diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 9ebd36661..54d88ed08 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -143,12 +143,12 @@ export const AccessPolicyForm = ({ ...data, projectSlug }); - createNotification({ - type: "success", - text: "Successfully updated policy" - }); - onToggle(false); } + createNotification({ + type: "success", + text: "Successfully updated policy" + }); + onToggle(false); } catch (err) { console.log(err); createNotification({ diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index e251f88ca..3aa5ce486 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -18,7 +18,7 @@ import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { policyDetails } from "@app/helpers/policies"; import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; -import { PolicyType } from "@app/hooks/api/policies/enums"; +import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { WorkspaceEnv } from "@app/hooks/api/types"; import { TWorkspaceUser } from "@app/hooks/api/users/types"; @@ -33,6 +33,7 @@ interface IPolicy { userApprovers?: { userId: string }[]; updatedAt: Date; policyType: PolicyType; + enforcementLevel: EnforcementLevel; }; type Props = { @@ -75,11 +76,7 @@ export const ApprovalPolicyRow = ({ id: policy.id, approvers: selectedApprovers }, - { - onSettled: () => { - // No changes needed here - } - } + { onSettled: () => {} } ); } else { updateSecretApprovalPolicy( @@ -88,11 +85,7 @@ export const ApprovalPolicyRow = ({ id: policy.id, approvers: selectedApprovers }, - { - onSettled: () => { - // No changes needed here - } - } + { onSettled: () => {} } ); } } else { From a538e37a62174cee0d22f69038ce0aad4e1d9716 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 21:48:57 +0200 Subject: [PATCH 11/30] chore(backend): schema secret_approval_policies --- backend/src/db/schemas/secret-approval-policies.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index bac9c4b2e..94aeba050 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -5,8 +5,6 @@ import { z } from "zod"; -import { EnforcementLevel } from "@app/lib/types"; - import { TImmutableDBKeys } from "./models"; export const SecretApprovalPoliciesSchema = z.object({ @@ -14,10 +12,10 @@ export const SecretApprovalPoliciesSchema = z.object({ name: z.string(), secretPath: z.string().nullable().optional(), approvals: z.number().default(1), - enforcementLevel: z.nativeEnum(EnforcementLevel), envId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + enforcementLevel: z.string().default("hard") }); export type TSecretApprovalPolicies = z.infer; From d4dd684f320e4c3b2bd287ddd231244be461bf9f Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 22:30:55 +0200 Subject: [PATCH 12/30] feat(backend): add enforcementLevel into access_approval_policies --- ...94958_add-enforcement-level-access-policies.ts | 15 +++++++++++++++ .../src/db/schemas/access-approval-policies.ts | 5 ++++- .../ee/routes/v1/access-approval-policy-router.ts | 10 +++++++--- .../access-approval-policy-service.ts | 12 ++++++++---- .../access-approval-policy-types.ts | 4 +++- 5 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 backend/src/db/migrations/20240717194958_add-enforcement-level-access-policies.ts diff --git a/backend/src/db/migrations/20240717194958_add-enforcement-level-access-policies.ts b/backend/src/db/migrations/20240717194958_add-enforcement-level-access-policies.ts new file mode 100644 index 000000000..9aeb8d51e --- /dev/null +++ b/backend/src/db/migrations/20240717194958_add-enforcement-level-access-policies.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { EnforcementLevel } from "@app/lib/types"; + +export async function up(knex: Knex): Promise { + await knex.schema.table("access_approval_policies", (table) => { + table.specificType("enforcementLevel", "VARCHAR(10)").notNullable().defaultTo(EnforcementLevel.Hard); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table("access_approval_policies", (table) => { + table.dropColumn("enforcementLevel"); + }); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index 69068d23b..c05f22b31 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { EnforcementLevel } from "@app/lib/types"; + import { TImmutableDBKeys } from "./models"; export const AccessApprovalPoliciesSchema = z.object({ @@ -14,7 +16,8 @@ export const AccessApprovalPoliciesSchema = z.object({ secretPath: z.string().nullable().optional(), envId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 3b8949d3b..fe3a16f2b 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; +import { EnforcementLevel } from "@app/lib/types"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -17,7 +18,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi secretPath: z.string().trim().default("/"), environment: z.string(), approvers: z.string().array().min(1), - approvals: z.number().min(1).default(1) + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel) }) .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], @@ -38,7 +40,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi actorOrgId: req.permission.orgId, ...req.body, projectSlug: req.body.projectSlug, - name: req.body.name ?? `${req.body.environment}-${nanoid(3)}` + name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`, + enforcementLevel: req.body.enforcementLevel }); return { approval }; } @@ -115,7 +118,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .optional() .transform((val) => (val === "" ? "/" : val)), approvers: z.string().array().min(1), - approvals: z.number().min(1).default(1) + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel) }) .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 51a51abb5..c91fbf970 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -47,7 +47,8 @@ export const accessApprovalPolicyServiceFactory = ({ approvals, approvers, projectSlug, - environment + environment, + enforcementLevel }: TCreateAccessApprovalPolicy) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new BadRequestError({ message: "Project not found" }); @@ -94,7 +95,8 @@ export const accessApprovalPolicyServiceFactory = ({ envId: env.id, approvals, secretPath, - name + name, + enforcementLevel }, tx ); @@ -143,7 +145,8 @@ export const accessApprovalPolicyServiceFactory = ({ actor, actorOrgId, actorAuthMethod, - approvals + approvals, + enforcementLevel }: TUpdateAccessApprovalPolicy) => { const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" }); @@ -163,7 +166,8 @@ export const accessApprovalPolicyServiceFactory = ({ { approvals, secretPath, - name + name, + enforcementLevel }, tx ); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 601561b68..fdb6fc8bb 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -1,4 +1,4 @@ -import { TProjectPermission } from "@app/lib/types"; +import { EnforcementLevel, TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod } from "@app/services/auth/auth-type"; import { TPermissionServiceFactory } from "../permission/permission-service"; @@ -20,6 +20,7 @@ export type TCreateAccessApprovalPolicy = { approvers: string[]; projectSlug: string; name: string; + enforcementLevel: EnforcementLevel; } & Omit; export type TUpdateAccessApprovalPolicy = { @@ -28,6 +29,7 @@ export type TUpdateAccessApprovalPolicy = { approvers?: string[]; secretPath?: string; name?: string; + enforcementLevel?: EnforcementLevel; } & Omit; export type TDeleteAccessApprovalPolicy = { From c331af5345ea8871551f3634780ff16daee42258 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Wed, 17 Jul 2024 22:32:30 +0200 Subject: [PATCH 13/30] feat(frontend): add enforcementLevel into AccessPolicy --- frontend/src/hooks/api/accessApproval/mutation.tsx | 10 ++++++---- frontend/src/hooks/api/accessApproval/types.ts | 2 ++ frontend/src/hooks/api/secretApproval/types.ts | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 5f595c8a2..251e1c626 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -16,14 +16,15 @@ export const useCreateAccessApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateAccessPolicyDTO>({ - mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => { + mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath, enforcementLevel }) => { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { environment, projectSlug, approvals, approvers, secretPath, - name + name, + enforcementLevel }); return data; }, @@ -37,12 +38,13 @@ export const useUpdateAccessApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ - mutationFn: async ({ id, approvers, approvals, name, secretPath }) => { + mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, approvers, secretPath, - name + name, + enforcementLevel }); return data; }, diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index a6d09a227..f023b3555 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -125,6 +125,7 @@ export type TCreateAccessPolicyDTO = { approvers?: string[]; approvals?: number; secretPath?: string; + enforcementLevel?: EnforcementLevel; }; export type TUpdateAccessPolicyDTO = { @@ -134,6 +135,7 @@ export type TUpdateAccessPolicyDTO = { secretPath?: string; environment?: string; approvals?: number; + enforcementLevel?: EnforcementLevel; // for invalidating list projectSlug: string; }; diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 2a64ccdc9..06ffad432 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -11,6 +11,7 @@ export type TSecretApprovalPolicy = { approvals: number; userApprovers: { userId: string }[]; updatedAt: Date; + enforcementLevel: EnforcementLevel; }; export type TGetSecretApprovalPoliciesDTO = { From aed310b9ee1efadb144efaf579c6659300136bc6 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Thu, 18 Jul 2024 01:02:57 +0200 Subject: [PATCH 14/30] feat(backemd): accept soft approvals on secret requests --- .../src/ee/routes/v1/secret-approval-request-router.ts | 6 ++++-- .../secret-approval-request-dal.ts | 8 ++++++-- .../secret-approval-request-service.ts | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 8e72597bd..8e0b8a682 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -49,7 +49,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: z.string().array(), - secretPath: z.string().optional().nullable() + secretPath: z.string().optional().nullable(), + enforcementLevel: z.string() }), committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), @@ -248,7 +249,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: approvalRequestUser.array(), - secretPath: z.string().optional().nullable() + secretPath: z.string().optional().nullable(), + enforcementLevel: z.string() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 06c48ac8b..4ce174d26 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -94,6 +94,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("projectId").withSchema(TableName.Environment), tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), + tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") ); @@ -128,7 +129,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { id: el.policyId, name: el.policyName, approvals: el.policyApprovals, - secretPath: el.policySecretPath + secretPath: el.policySecretPath, + enforcementLevel: el.policyEnforcementLevel } }), childrenMapper: [ @@ -282,6 +284,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `DENSE_RANK() OVER (partition by ${TableName.Environment}."projectId" ORDER BY ${TableName.SecretApprovalRequest}."id" DESC) as rank` ), db.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), + db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("email").withSchema("committerUser").as("committerUserEmail"), @@ -308,7 +311,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { id: el.policyId, name: el.policyName, approvals: el.policyApprovals, - secretPath: el.policySecretPath + secretPath: el.policySecretPath, + enforcementLevel: el.policyEnforcementLevel }, committerUser: { userId: el.committerUserId, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a519af4fd..ae9e54b46 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -11,6 +11,7 @@ import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { groupBy, pick, unique } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { EnforcementLevel } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -289,7 +290,10 @@ export const secretApprovalRequestServiceFactory = ({ ({ userId: approverId }) => reviewers[approverId.toString()] === ApprovalStatus.APPROVED ).length; - if (!hasMinApproval) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); + const isSoftPolicy = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; + + if (!hasMinApproval && !isSoftPolicy) + throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); const secretApprovalSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" }); From 8a5a295a0100faf37b66af3d16d39f1093287a03 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Thu, 18 Jul 2024 01:30:40 +0200 Subject: [PATCH 15/30] feat(frontend): accept soft approvals on secret requests --- .../ApprovalPolicyList/ApprovalPolicyList.tsx | 2 +- .../SecretApprovalRequestAction.tsx | 39 +++++++++++++++---- .../SecretApprovalRequestChanges.tsx | 1 + 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index fb21521c9..2639f2351 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -177,7 +177,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { @@ -151,6 +162,21 @@ export const ReviewAccessRequestModal = ({ Reject Request + {isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && ( +
+ setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""} + > + + Approve without waiting for requirements to be met (by pass secrets protection) + + +
+ )} From d301f74feba71e4919664e0712cc7ea226e75978 Mon Sep 17 00:00:00 2001 From: Alfonso Hernandez Date: Thu, 18 Jul 2024 02:46:50 +0200 Subject: [PATCH 18/30] fix(frontend): interactions SecretApprovalRequestAction --- .../components/SecretApprovalRequestAction.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 05db75a9d..f56695764 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -134,8 +134,11 @@ export const SecretApprovalRequestAction = ({ Close request