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..ee668d145 --- /dev/null +++ b/backend/src/db/migrations/20240717184929_add-enforcement-level-secrets-policies.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { EnforcementLevel } from "@app/lib/types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "enforcementLevel"); + if (!hasColumn) { + await knex.schema.table(TableName.SecretApprovalPolicy, (table) => { + table.string("enforcementLevel", 10).notNullable().defaultTo(EnforcementLevel.Hard); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "enforcementLevel"); + if (hasColumn) { + await knex.schema.table(TableName.SecretApprovalPolicy, (table) => { + table.dropColumn("enforcementLevel"); + }); + } +} 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..83ef07201 --- /dev/null +++ b/backend/src/db/migrations/20240717194958_add-enforcement-level-access-policies.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { EnforcementLevel } from "@app/lib/types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "enforcementLevel"); + if (!hasColumn) { + await knex.schema.table(TableName.AccessApprovalPolicy, (table) => { + table.string("enforcementLevel", 10).notNullable().defaultTo(EnforcementLevel.Hard); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "enforcementLevel"); + if (hasColumn) { + await knex.schema.table(TableName.AccessApprovalPolicy, (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/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index d907ef1e0..94aeba050 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -14,7 +14,8 @@ export const SecretApprovalPoliciesSchema = z.object({ approvals: z.number().default(1), envId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + enforcementLevel: z.string().default("hard") }); export type TSecretApprovalPolicies = 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..8a3f93109 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).default(EnforcementLevel.Hard) }) .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).default(EnforcementLevel.Hard) }) .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 4b173cfa7..972289b04 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -99,7 +99,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: z.string().array(), secretPath: z.string().nullish(), - envId: z.string() + envId: z.string(), + enforcementLevel: z.string() }), reviewers: z .object({ 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..25c1bb0b5 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"; @@ -24,11 +25,13 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .string() .optional() .nullable() + .default("/") .transform((val) => (val ? removeTrailingSlash(val) : val)), - approverUserIds: z.string().array().min(1), - approvals: z.number().min(1).default(1) + approvers: z.string().array().min(1), + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) }) - .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." }), @@ -47,7 +50,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 }); return { approval }; } @@ -66,15 +70,17 @@ 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() .optional() .nullable() .transform((val) => (val ? removeTrailingSlash(val) : val)) + .transform((val) => (val === "" ? "/" : val)), + enforcementLevel: z.nativeEnum(EnforcementLevel).optional() }) - .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/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/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 = { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index c3c0d24d0..b293a38d6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -48,6 +48,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"), db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") ) @@ -98,6 +99,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { name: doc.policyName, approvals: doc.policyApprovals, secretPath: doc.policySecretPath, + enforcementLevel: doc.policyEnforcementLevel, envId: doc.policyEnvId }, privilege: doc.privilegeId @@ -165,6 +167,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("projectId").withSchema(TableName.Environment), tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), + tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover) ); @@ -184,7 +187,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { id: el.policyId, name: el.policyName, approvals: el.policyApprovals, - secretPath: el.policySecretPath + secretPath: el.policySecretPath, + enforcementLevel: el.policyEnforcementLevel } }), childrenMapper: [ 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..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 @@ -45,12 +45,13 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actorAuthMethod, approvals, - approverUserIds, + approvers, projectId, secretPath, - environment + environment, + enforcementLevel }: TCreateSapDTO) => { - if (approvals > approverUserIds.length) + if (approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); const { permission } = await permissionService.getProjectPermission( @@ -73,12 +74,13 @@ export const secretApprovalPolicyServiceFactory = ({ envId: env.id, approvals, secretPath, - name + name, + enforcementLevel }, tx ); await secretApprovalPolicyApproverDAL.insertMany( - approverUserIds.map((approverUserId) => ({ + approvers.map((approverUserId) => ({ approverUserId, policyId: doc.id })), @@ -90,7 +92,7 @@ export const secretApprovalPolicyServiceFactory = ({ }; const updateSecretApprovalPolicy = async ({ - approverUserIds, + approvers, secretPath, name, actorId, @@ -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,14 +121,15 @@ export const secretApprovalPolicyServiceFactory = ({ { approvals, secretPath, - name + name, + enforcementLevel }, 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..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,20 +1,22 @@ -import { TProjectPermission } from "@app/lib/types"; +import { EnforcementLevel, TProjectPermission } from "@app/lib/types"; export type TCreateSapDTO = { approvals: number; secretPath?: string | null; environment: string; - approverUserIds: string[]; + approvers: string[]; projectId: string; name: string; + enforcementLevel: EnforcementLevel; } & Omit; export type TUpdateSapDTO = { secretPolicyId: string; approvals?: number; secretPath?: string | null; - approverUserIds: string[]; + approvers: string[]; name?: string; + enforcementLevel?: EnforcementLevel; } & Omit; export type TDeleteSapDTO = { 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..9f824b2b1 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 isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; + + if (!hasMinApproval && !isSoftEnforcement) + 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" }); 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/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" +} 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..49488776b 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({ @@ -129,17 +186,17 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { if (req.auth.actor !== ActorType.USER) return; 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.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..3ffc2e203 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, @@ -373,8 +379,16 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + const foundMembership = await orgMembershipDAL.findOne({ + id: membershipId, + orgId + }); + if (!foundMembership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (foundMembership.userId === userId) + throw new BadRequestError({ message: "Cannot update own organization membership" }); + 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 +408,7 @@ export const orgServiceFactory = ({ return membership; } - const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null }); + const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive }); return membership; }; /* @@ -585,6 +599,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 +640,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 +720,7 @@ export const orgServiceFactory = ({ findOrgMembersByUsername, createOrganization, deleteOrganizationById, + getOrgMembership, deleteOrgMembership, findAllWorkspaces, addGhostUser, @@ -676,6 +729,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/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 03bea6fb0..24ec3a372 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -328,6 +328,27 @@ SMTP_FROM_NAME=Infisical + +1. Create an account and configure [SMTP2Go](https://www.smtp2go.com/) to send emails. +2. Turn on SMTP authentication +``` +SMTP_HOST=mail.smtp2go.com +SMTP_PORT=You can use one of the following ports: 2525, 80, 25, 8025, or 587 +SMTP_USERNAME=username #Your SMTP2GO account's SMTP username +SMTP_PASSWORD=password #Your SMTP2GO account's SMTP password +SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails +SMTP_FROM_NAME=Infisical +``` +{" "} + + +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. diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 2657686de..76804c137 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -25,7 +25,7 @@ export const DeleteActionModal = ({ deleteKey, onDeleteApproved, title, - subTitle = "This action is irreversible!", + subTitle = "This action is irreversible.", buttonText = "Delete" }: Props): JSX.Element => { const [inputData, setInputData] = useState(""); @@ -86,7 +86,7 @@ export const DeleteActionModal = ({ - Type {deleteKey} to delete the resource + Type {deleteKey} to perform this action } className="mb-0" @@ -94,7 +94,7 @@ export const DeleteActionModal = ({ setInputData(e.target.value)} - placeholder="Type to delete..." + placeholder="Type confirm..." /> diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 35f85383d..9711e3e68 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -1,9 +1,11 @@ import { cloneElement, ReactNode } from "react"; -import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; +import { faExclamationTriangle, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Label from "@radix-ui/react-label"; import { twMerge } from "tailwind-merge"; +import { Tooltip } from "../Tooltip"; + export type FormLabelProps = { id?: string; isRequired?: boolean; @@ -11,9 +13,10 @@ export type FormLabelProps = { label?: ReactNode; icon?: ReactNode; className?: string; + tooltipText?: string; }; -export const FormLabel = ({ id, label, isRequired, icon, className,isOptional }: FormLabelProps) => ( +export const FormLabel = ({ id, label, isRequired, icon, className,isOptional, tooltipText }: FormLabelProps) => ( *} {isOptional && - Optional} - {icon && ( + {icon && !tooltipText && ( {icon} )} + {tooltipText && ( + + + + )} ); @@ -64,6 +76,7 @@ export type FormControlProps = { children: JSX.Element; className?: string; icon?: ReactNode; + tooltipText?: string; }; export const FormControl = ({ @@ -76,7 +89,8 @@ export const FormControl = ({ id, isError, icon, - className + className, + tooltipText }: FormControlProps): JSX.Element => { return (
@@ -87,6 +101,7 @@ export const FormControl = ({ isRequired={isRequired} id={id} icon={icon} + tooltipText={tooltipText} /> ) : ( label 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/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 2176b8bc1..e17fe41d0 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 = { @@ -47,6 +53,7 @@ export type TAccessApprovalRequest = { approvers: string[]; secretPath?: string | null; envId: string; + enforcementLevel: EnforcementLevel; }; reviewers: { @@ -119,6 +126,7 @@ export type TCreateAccessPolicyDTO = { approvers?: string[]; approvals?: number; secretPath?: string; + enforcementLevel?: EnforcementLevel; }; export type TUpdateAccessPolicyDTO = { @@ -128,6 +136,7 @@ export type TUpdateAccessPolicyDTO = { secretPath?: string; environment?: string; approvals?: number; + enforcementLevel?: EnforcementLevel; // for invalidating list projectSlug: string; }; 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/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index 991111ef9..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, approverUserIds, secretPath, name }) => { + mutationFn: async ({ environment, workspaceId, approvals, approvers, secretPath, name, enforcementLevel }) => { const { data } = await apiRequest.post("/api/v1/secret-approvals", { environment, workspaceId, approvals, - approverUserIds, + approvers, secretPath, - name + name, + enforcementLevel }); return data; }, @@ -30,12 +31,13 @@ export const useUpdateSecretApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateSecretPolicyDTO>({ - mutationFn: async ({ id, approverUserIds, approvals, secretPath, name }) => { + mutationFn: async ({ id, approvers, approvals, secretPath, name, enforcementLevel }) => { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { approvals, - approverUserIds, + 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 f3b8639f7..06ffad432 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 = { @@ -9,6 +10,8 @@ export type TSecretApprovalPolicy = { secretPath?: string; approvals: number; userApprovers: { userId: string }[]; + updatedAt: Date; + enforcementLevel: EnforcementLevel; }; export type TGetSecretApprovalPoliciesDTO = { @@ -26,16 +29,18 @@ export type TCreateSecretPolicyDTO = { name?: string; environment: string; secretPath?: string | null; - approverUserIds?: string[]; + approvers?: string[]; approvals?: number; + enforcementLevel: EnforcementLevel; }; export type TUpdateSecretPolicyDTO = { id: string; name?: string; - approverUserIds?: string[]; + approvers?: string[]; secretPath?: string | null; approvals?: number; + enforcementLevel?: EnforcementLevel; // for invalidating list workspaceId: string; }; 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..c272145d5 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,20 @@ export const OrgMembersSection = () => { ) } /> + handlePopUpToggle("deactivateMember", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeactivateMemberSubmit( + (popUp?.deactivateMember?.data as { orgMembershipId: string })?.orgMembershipId + ) + } + buttonText="Deactivate" + /> 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..304b104a7 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?.scimEnabled) { + 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?.scimEnabled) { + 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..2858d97df --- /dev/null +++ b/frontend/src/views/Org/UserPage/UserPage.tsx @@ -0,0 +1,288 @@ +/* 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, + useUser +} 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 { user } = useUser(); + const { currentOrg } = useOrganization(); + + const userId = user?.id || ""; + 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}` + : "-"} +

+ {userId !== membership.user.id && ( + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("orgMembership", { + membershipId: membership.id, + role: membership.role + }) + } + disabled={!isAllowed} + > + Edit User + + )} + + + {(isAllowed) => ( + { + if (currentOrg?.scimEnabled) { + createNotification({ + text: "You cannot manage users from Infisical when SCIM is enabled 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?.scimEnabled) { + createNotification({ + text: "You cannot manage users from Infisical when SCIM is enabled 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 + ) + } + buttonText="Deactivate" + /> + 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..c93e1fa8b --- /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 && membership.status !== "invited" && ( + { + 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({ 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" diff --git a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx index 9c273848f..8d7282130 100644 --- a/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx +++ b/frontend/src/views/SecretApprovalPage/SecretApprovalPage.tsx @@ -3,25 +3,31 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { Divider } from "@app/components/v2/Divider"; +import { Badge } from "@app/components/v2/Badge"; import { useWorkspace } from "@app/context"; +import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api"; -import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList"; import { AccessApprovalRequest } from "./components/AccessApprovalRequest"; -import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList"; +import { ApprovalPolicyList } from "./components/ApprovalPolicyList"; import { SecretApprovalRequest } from "./components/SecretApprovalRequest"; 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,25 +51,26 @@ export const SecretApprovalPage = () => {
- + - Secret Requests - Secret Policies - - Access Requests - Access Request Policies + + Secret Requests + {Boolean(secretApprovalReqCount?.open) && ({secretApprovalReqCount?.open})} + + + Access Requests + {Boolean(accessApprovalRequestCount?.pendingCount) && {accessApprovalRequestCount?.pendingCount}} + + Policies - - - - - + +
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"; diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 235737d4a..b57c0c3ab 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -40,6 +40,7 @@ import { useGetAccessRequestsCount } from "@app/hooks/api/accessApproval/queries"; import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { EnforcementLevel } from "@app/hooks/api/policies/enums"; import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types"; import { queryClient } from "@app/reactQuery"; @@ -80,7 +81,12 @@ export const AccessApprovalRequest = ({ projectId: string; }) => { const [selectedRequest, setSelectedRequest] = useState< - (TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }) | null + (TAccessApprovalRequest & { + user: TWorkspaceUser["user"] | null; + isRequestedByCurrentUser: boolean; + isApprover: boolean; + }) + | null >(null); const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([ @@ -141,6 +147,8 @@ export const AccessApprovalRequest = ({ ); const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1; const isAccepted = request.isApproved; + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; + const isRequestedByCurrentUser = request.requestedBy === membership.id; const userReviewStatus = request.reviewers.find( ({ member }) => member === membership.id @@ -178,7 +186,9 @@ export const AccessApprovalRequest = ({ isRejectedByAnyone, isApprover, userReviewStatus, - isAccepted + isAccepted, + isSoftEnforcement, + isRequestedByCurrentUser }; }; @@ -331,16 +341,24 @@ export const AccessApprovalRequest = ({ tabIndex={0} onClick={() => { if ( - !details.isApprover || - details.isReviewedByUser || - details.isRejectedByAnyone || - details.isAccepted + ( + !details.isApprover + || details.isReviewedByUser + || details.isRejectedByAnyone + || details.isAccepted + ) && !( + details.isSoftEnforcement + && details.isRequestedByCurrentUser + && !details.isAccepted + ) ) return; setSelectedRequest({ ...request, - user: membersGroupById?.[request.requestedBy].user! + user: membersGroupById?.[request.requestedBy].user!, + isRequestedByCurrentUser: details.isRequestedByCurrentUser, + isApprover: details.isApprover }); handlePopUpOpen("reviewRequest"); }} @@ -355,7 +373,9 @@ export const AccessApprovalRequest = ({ if (evt.key === "Enter") { setSelectedRequest({ ...request, - user: membersGroupById?.[request.requestedBy].user! + user: membersGroupById?.[request.requestedBy].user!, + isRequestedByCurrentUser: details.isRequestedByCurrentUser, + isApprover: details.isApprover }); handlePopUpOpen("reviewRequest"); } diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index 481cee9c6..1b8e17593 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -2,11 +2,12 @@ import { useCallback, useMemo, useState } from "react"; import ms from "ms"; import { createNotification } from "@app/components/notifications"; -import { Button, Modal, ModalContent } from "@app/components/v2"; +import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions } from "@app/context"; import { useReviewAccessRequest } from "@app/hooks/api"; import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; +import { EnforcementLevel } from "@app/hooks/api/policies/enums"; import { TWorkspaceUser } from "@app/hooks/api/types"; export const ReviewAccessRequestModal = ({ @@ -19,12 +20,18 @@ export const ReviewAccessRequestModal = ({ }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; - request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }; + request: TAccessApprovalRequest & { + user: TWorkspaceUser["user"] | null; + isRequestedByCurrentUser: boolean; + isApprover: boolean; + }; projectSlug: string; selectedRequester: string | undefined; selectedEnvSlug: string | undefined; }) => { const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); + const [byPassApproval, setByPassApproval] = useState(false); + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const accessDetails = { env: request.environmentName, @@ -134,10 +141,14 @@ export const ReviewAccessRequestModal = ({
@@ -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 (bypass policy protection) + + +
+ )}
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..2639f2351 --- /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..c9eb61185 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -0,0 +1,369 @@ +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 { 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 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))} + /> + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+
+ ); +}; + 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..cdfce0a45 --- /dev/null +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + 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 { EnforcementLevel, 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; + enforcementLevel: EnforcementLevel; +}; + +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: () => {} } + ); + } else { + updateSecretApprovalPolicy( + { + workspaceId, + id: policy.id, + approvers: selectedApprovers + }, + { onSettled: () => {} } + ); + } + } 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) => ( + { + e.stopPropagation(); + onEdit(); + }} + disabled={!isAllowed} + > + Edit Policy + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + onDelete(); + }} + disabled={!isAllowed} + > + Delete Policy + + )} + + +
+ + + ); +}; 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"; 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 d3321850a..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, - approverUserIds: 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 b0db2affd..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), - approverUserIds: z.string().array().min(1) - }) - .refine((data) => data.approvals <= data.approverUserIds.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, - approverUserIds: 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"; diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 8e9133e5b..f3b9d7b1e 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -1,20 +1,22 @@ +import React, { useState } from "react"; import { faCheck, faClose, + faLandMineOn, faLockOpen, faSquareCheck, faSquareXmark, - faUserLock -} from "@fortawesome/free-solid-svg-icons"; + faUserLock} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button } from "@app/components/v2"; +import { Button, Checkbox } from "@app/components/v2"; import { usePerformSecretApprovalRequestMerge, useUpdateSecretApprovalRequestStatus } from "@app/hooks/api"; +import { EnforcementLevel } from "@app/hooks/api/policies/enums"; type Props = { approvalRequestId: string; @@ -25,6 +27,7 @@ type Props = { canApprove?: boolean; statusChangeByEmail?: string; workspaceId: string; + enforcementLevel: EnforcementLevel; }; export const SecretApprovalRequestAction = ({ @@ -34,7 +37,8 @@ export const SecretApprovalRequestAction = ({ isMergable, approvals, statusChangeByEmail, - workspaceId, + workspaceId, + enforcementLevel, canApprove }: Props) => { const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } = @@ -43,6 +47,8 @@ export const SecretApprovalRequestAction = ({ const { mutateAsync: updateSecretStatusChange, isLoading: isStatusChanging } = useUpdateSecretApprovalRequestStatus(); + const [byPassApproval, setByPassApproval] = useState(false); + const handleSecretApprovalRequestMerge = async () => { try { await performSecretApprovalMerge({ @@ -82,6 +88,8 @@ export const SecretApprovalRequestAction = ({ } }; + const isSoftEnforcement = enforcementLevel === EnforcementLevel.Soft; + if (!hasMerged && status === "open") { return (
@@ -96,10 +104,25 @@ export const SecretApprovalRequestAction = ({ At least {approvals} approving review required {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} + {!canApprove && isSoftEnforcement && ( +
+ setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""} + > + + Merge without waiting for approval (bypass secret change policy) + + +
+ )}
- {canApprove ? ( + {canApprove || isSoftEnforcement ? ( <>