mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 14:28:20 +00:00
Fix merge conflict
This commit is contained in:
@@ -38,6 +38,14 @@ jobs:
|
|||||||
rm added_files.txt
|
rm added_files.txt
|
||||||
git commit -m "chore: renamed new migration files to latest timestamp (gh-action)"
|
git commit -m "chore: renamed new migration files to latest timestamp (gh-action)"
|
||||||
|
|
||||||
|
- name: Get the username of the person who closed the PR
|
||||||
|
run: |
|
||||||
|
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||||
|
PR_CLOSER=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.closed_by.login')
|
||||||
|
echo "PR Number: $PR_NUMBER"
|
||||||
|
echo "PR Closer: $PR_CLOSER"
|
||||||
|
echo "pr_closer=$PR_CLOSER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: env.SKIP_RENAME != 'true'
|
if: env.SKIP_RENAME != 'true'
|
||||||
uses: peter-evans/create-pull-request@v6
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/Identit
|
|||||||
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206
|
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206
|
||||||
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292
|
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292
|
||||||
docs/self-hosting/configuration/envars.mdx:generic-api-key:106
|
docs/self-hosting/configuration/envars.mdx:generic-api-key:106
|
||||||
|
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
"libsodium-wrappers": "^0.7.13",
|
"libsodium-wrappers": "^0.7.13",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"mysql2": "^3.9.4",
|
"mysql2": "^3.9.7",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"nodemailer": "^6.9.9",
|
"nodemailer": "^6.9.9",
|
||||||
"ora": "^7.0.1",
|
"ora": "^7.0.1",
|
||||||
|
|||||||
Vendored
+4
@@ -1,6 +1,8 @@
|
|||||||
import "fastify";
|
import "fastify";
|
||||||
|
|
||||||
import { TUsers } from "@app/db/schemas";
|
import { TUsers } from "@app/db/schemas";
|
||||||
|
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
|
||||||
|
import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
|
||||||
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||||
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
|
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
|
||||||
@@ -115,6 +117,8 @@ declare module "fastify" {
|
|||||||
identityProject: TIdentityProjectServiceFactory;
|
identityProject: TIdentityProjectServiceFactory;
|
||||||
identityUa: TIdentityUaServiceFactory;
|
identityUa: TIdentityUaServiceFactory;
|
||||||
identityAwsIamAuth: TIdentityAwsIamAuthServiceFactory;
|
identityAwsIamAuth: TIdentityAwsIamAuthServiceFactory;
|
||||||
|
accessApprovalPolicy: TAccessApprovalPolicyServiceFactory;
|
||||||
|
accessApprovalRequest: TAccessApprovalRequestServiceFactory;
|
||||||
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
|
secretApprovalPolicy: TSecretApprovalPolicyServiceFactory;
|
||||||
secretApprovalRequest: TSecretApprovalRequestServiceFactory;
|
secretApprovalRequest: TSecretApprovalRequestServiceFactory;
|
||||||
secretRotation: TSecretRotationServiceFactory;
|
secretRotation: TSecretRotationServiceFactory;
|
||||||
|
|||||||
Vendored
+37
@@ -2,6 +2,18 @@ import { Knex } from "knex";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
TableName,
|
TableName,
|
||||||
|
TAccessApprovalPolicies,
|
||||||
|
TAccessApprovalPoliciesApprovers,
|
||||||
|
TAccessApprovalPoliciesApproversInsert,
|
||||||
|
TAccessApprovalPoliciesApproversUpdate,
|
||||||
|
TAccessApprovalPoliciesInsert,
|
||||||
|
TAccessApprovalPoliciesUpdate,
|
||||||
|
TAccessApprovalRequests,
|
||||||
|
TAccessApprovalRequestsInsert,
|
||||||
|
TAccessApprovalRequestsReviewers,
|
||||||
|
TAccessApprovalRequestsReviewersInsert,
|
||||||
|
TAccessApprovalRequestsReviewersUpdate,
|
||||||
|
TAccessApprovalRequestsUpdate,
|
||||||
TApiKeys,
|
TApiKeys,
|
||||||
TApiKeysInsert,
|
TApiKeysInsert,
|
||||||
TApiKeysUpdate,
|
TApiKeysUpdate,
|
||||||
@@ -352,6 +364,31 @@ declare module "knex/types/tables" {
|
|||||||
TIdentityProjectAdditionalPrivilegeInsert,
|
TIdentityProjectAdditionalPrivilegeInsert,
|
||||||
TIdentityProjectAdditionalPrivilegeUpdate
|
TIdentityProjectAdditionalPrivilegeUpdate
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
[TableName.AccessApprovalPolicy]: Knex.CompositeTableType<
|
||||||
|
TAccessApprovalPolicies,
|
||||||
|
TAccessApprovalPoliciesInsert,
|
||||||
|
TAccessApprovalPoliciesUpdate
|
||||||
|
>;
|
||||||
|
|
||||||
|
[TableName.AccessApprovalPolicyApprover]: Knex.CompositeTableType<
|
||||||
|
TAccessApprovalPoliciesApprovers,
|
||||||
|
TAccessApprovalPoliciesApproversInsert,
|
||||||
|
TAccessApprovalPoliciesApproversUpdate
|
||||||
|
>;
|
||||||
|
|
||||||
|
[TableName.AccessApprovalRequest]: Knex.CompositeTableType<
|
||||||
|
TAccessApprovalRequests,
|
||||||
|
TAccessApprovalRequestsInsert,
|
||||||
|
TAccessApprovalRequestsUpdate
|
||||||
|
>;
|
||||||
|
|
||||||
|
[TableName.AccessApprovalRequestReviewer]: Knex.CompositeTableType<
|
||||||
|
TAccessApprovalRequestsReviewers,
|
||||||
|
TAccessApprovalRequestsReviewersInsert,
|
||||||
|
TAccessApprovalRequestsReviewersUpdate
|
||||||
|
>;
|
||||||
|
|
||||||
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
||||||
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
||||||
TSecretApprovalPolicies,
|
TSecretApprovalPolicies,
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { TableName } from "../schemas";
|
|||||||
export async function up(knex: Knex): Promise<void> {
|
export async function up(knex: Knex): Promise<void> {
|
||||||
const isUsersTablePresent = await knex.schema.hasTable(TableName.Users);
|
const isUsersTablePresent = await knex.schema.hasTable(TableName.Users);
|
||||||
if (isUsersTablePresent) {
|
if (isUsersTablePresent) {
|
||||||
|
const hasIsEmailVerifiedColumn = await knex.schema.hasColumn(TableName.Users, "isEmailVerified");
|
||||||
|
|
||||||
|
if (!hasIsEmailVerifiedColumn) {
|
||||||
await knex.schema.alterTable(TableName.Users, (t) => {
|
await knex.schema.alterTable(TableName.Users, (t) => {
|
||||||
t.boolean("isEmailVerified");
|
t.boolean("isEmailVerified").defaultTo(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfilling the isEmailVerified to true where isAccepted is true
|
||||||
|
await knex(TableName.Users).update({ isEmailVerified: true }).where("isAccepted", true);
|
||||||
|
}
|
||||||
|
|
||||||
const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases);
|
const isUserAliasTablePresent = await knex.schema.hasTable(TableName.UserAliases);
|
||||||
if (isUserAliasTablePresent) {
|
if (isUserAliasTablePresent) {
|
||||||
await knex.schema.alterTable(TableName.UserAliases, (t) => {
|
await knex.schema.alterTable(TableName.UserAliases, (t) => {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicy))) {
|
||||||
|
await knex.schema.createTable(TableName.AccessApprovalPolicy, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.string("name").notNullable();
|
||||||
|
t.integer("approvals").defaultTo(1).notNullable();
|
||||||
|
t.string("secretPath");
|
||||||
|
|
||||||
|
t.uuid("envId").notNullable();
|
||||||
|
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyApprover))) {
|
||||||
|
await knex.schema.createTable(TableName.AccessApprovalPolicyApprover, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.uuid("approverId").notNullable();
|
||||||
|
t.foreign("approverId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
|
||||||
|
|
||||||
|
t.uuid("policyId").notNullable();
|
||||||
|
t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyApprover);
|
||||||
|
await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicy);
|
||||||
|
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyApprover);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicy);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasTable(TableName.AccessApprovalRequest))) {
|
||||||
|
await knex.schema.createTable(TableName.AccessApprovalRequest, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
|
||||||
|
t.uuid("policyId").notNullable();
|
||||||
|
t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE");
|
||||||
|
|
||||||
|
t.uuid("privilegeId").nullable();
|
||||||
|
t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE");
|
||||||
|
|
||||||
|
t.uuid("requestedBy").notNullable();
|
||||||
|
t.foreign("requestedBy").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
|
||||||
|
|
||||||
|
// We use these values to create the actual privilege at a later point in time.
|
||||||
|
t.boolean("isTemporary").notNullable();
|
||||||
|
t.string("temporaryRange").nullable();
|
||||||
|
|
||||||
|
t.jsonb("permissions").notNullable();
|
||||||
|
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await createOnUpdateTrigger(knex, TableName.AccessApprovalRequest);
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasTable(TableName.AccessApprovalRequestReviewer))) {
|
||||||
|
await knex.schema.createTable(TableName.AccessApprovalRequestReviewer, (t) => {
|
||||||
|
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||||
|
t.uuid("member").notNullable();
|
||||||
|
t.foreign("member").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
|
||||||
|
t.string("status").notNullable();
|
||||||
|
t.uuid("requestId").notNullable();
|
||||||
|
t.foreign("requestId").references("id").inTable(TableName.AccessApprovalRequest).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await createOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.AccessApprovalRequestReviewer);
|
||||||
|
await knex.schema.dropTableIfExists(TableName.AccessApprovalRequest);
|
||||||
|
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequestReviewer);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.AccessApprovalRequest);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const AccessApprovalPoliciesApproversSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
approverId: z.string().uuid(),
|
||||||
|
policyId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessApprovalPoliciesApprovers = z.infer<typeof AccessApprovalPoliciesApproversSchema>;
|
||||||
|
export type TAccessApprovalPoliciesApproversInsert = Omit<
|
||||||
|
z.input<typeof AccessApprovalPoliciesApproversSchema>,
|
||||||
|
TImmutableDBKeys
|
||||||
|
>;
|
||||||
|
export type TAccessApprovalPoliciesApproversUpdate = Partial<
|
||||||
|
Omit<z.input<typeof AccessApprovalPoliciesApproversSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const AccessApprovalPoliciesSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
approvals: z.number().default(1),
|
||||||
|
envId: z.string().uuid(),
|
||||||
|
secretPath: z.string().nullable().optional(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
|
||||||
|
export type TAccessApprovalPoliciesInsert = Omit<z.input<typeof AccessApprovalPoliciesSchema>, TImmutableDBKeys>;
|
||||||
|
export type TAccessApprovalPoliciesUpdate = Partial<
|
||||||
|
Omit<z.input<typeof AccessApprovalPoliciesSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const AccessApprovalRequestsReviewersSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
member: z.string().uuid(),
|
||||||
|
status: z.string(),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessApprovalRequestsReviewers = z.infer<typeof AccessApprovalRequestsReviewersSchema>;
|
||||||
|
export type TAccessApprovalRequestsReviewersInsert = Omit<
|
||||||
|
z.input<typeof AccessApprovalRequestsReviewersSchema>,
|
||||||
|
TImmutableDBKeys
|
||||||
|
>;
|
||||||
|
export type TAccessApprovalRequestsReviewersUpdate = Partial<
|
||||||
|
Omit<z.input<typeof AccessApprovalRequestsReviewersSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const AccessApprovalRequestsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
policyId: z.string().uuid(),
|
||||||
|
privilegeId: z.string().uuid().nullable().optional(),
|
||||||
|
requestedBy: z.string().uuid(),
|
||||||
|
isTemporary: z.boolean(),
|
||||||
|
temporaryRange: z.string().nullable().optional(),
|
||||||
|
permissions: z.unknown(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
|
||||||
|
export type TAccessApprovalRequestsInsert = Omit<z.input<typeof AccessApprovalRequestsSchema>, TImmutableDBKeys>;
|
||||||
|
export type TAccessApprovalRequestsUpdate = Partial<
|
||||||
|
Omit<z.input<typeof AccessApprovalRequestsSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
export * from "./access-approval-policies";
|
||||||
|
export * from "./access-approval-policies-approvers";
|
||||||
|
export * from "./access-approval-requests";
|
||||||
|
export * from "./access-approval-requests-reviewers";
|
||||||
export * from "./api-keys";
|
export * from "./api-keys";
|
||||||
export * from "./audit-log-streams";
|
export * from "./audit-log-streams";
|
||||||
export * from "./audit-logs";
|
export * from "./audit-logs";
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export enum TableName {
|
|||||||
IdentityProjectMembershipRole = "identity_project_membership_role",
|
IdentityProjectMembershipRole = "identity_project_membership_role",
|
||||||
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
|
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
|
||||||
ScimToken = "scim_tokens",
|
ScimToken = "scim_tokens",
|
||||||
|
AccessApprovalPolicy = "access_approval_policies",
|
||||||
|
AccessApprovalPolicyApprover = "access_approval_policies_approvers",
|
||||||
|
AccessApprovalRequest = "access_approval_requests",
|
||||||
|
AccessApprovalRequestReviewer = "access_approval_requests_reviewers",
|
||||||
SecretApprovalPolicy = "secret_approval_policies",
|
SecretApprovalPolicy = "secret_approval_policies",
|
||||||
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
||||||
SecretApprovalRequest = "secret_approval_requests",
|
SecretApprovalRequest = "secret_approval_requests",
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
body: z
|
||||||
|
.object({
|
||||||
|
projectSlug: z.string().trim(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
secretPath: z.string().trim().default("/"),
|
||||||
|
environment: z.string(),
|
||||||
|
approvers: z.string().array().min(1),
|
||||||
|
approvals: z.number().min(1).default(1)
|
||||||
|
})
|
||||||
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
|
path: ["approvals"],
|
||||||
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: sapPubSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.body,
|
||||||
|
projectSlug: req.body.projectSlug,
|
||||||
|
name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`
|
||||||
|
});
|
||||||
|
return { approval };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approvals: sapPubSchema.extend({ approvers: z.string().array(), secretPath: z.string().optional() }).array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const approvals = await server.services.accessApprovalPolicy.getAccessApprovalPolicyByProjectSlug({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectSlug: req.query.projectSlug
|
||||||
|
});
|
||||||
|
return { approvals };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/count",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string(),
|
||||||
|
envSlug: z.string()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
count: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { count } = await server.services.accessApprovalPolicy.getAccessPolicyCountByEnvSlug({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
projectSlug: req.query.projectSlug,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
envSlug: req.query.envSlug
|
||||||
|
});
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:policyId",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
policyId: z.string()
|
||||||
|
}),
|
||||||
|
body: z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
secretPath: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val === "" ? "/" : val)),
|
||||||
|
approvers: z.string().array().min(1),
|
||||||
|
approvals: z.number().min(1).default(1)
|
||||||
|
})
|
||||||
|
.refine((data) => data.approvals <= data.approvers.length, {
|
||||||
|
path: ["approvals"],
|
||||||
|
message: "The number of approvals should be lower than the number of approvers."
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: sapPubSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({
|
||||||
|
policyId: req.params.policyId,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:policyId",
|
||||||
|
method: "DELETE",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
policyId: z.string()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: sapPubSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
policyId: req.params.policyId
|
||||||
|
});
|
||||||
|
return { approval };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema } from "@app/db/schemas";
|
||||||
|
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
export const registerAccessApprovalRequestRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
permissions: z.any().array(),
|
||||||
|
isTemporary: z.boolean(),
|
||||||
|
temporaryRange: z.string().optional()
|
||||||
|
}),
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: AccessApprovalRequestsSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { request } = await server.services.accessApprovalRequest.createAccessApprovalRequest({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
permissions: req.body.permissions,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectSlug: req.query.projectSlug,
|
||||||
|
temporaryRange: req.body.temporaryRange,
|
||||||
|
isTemporary: req.body.isTemporary
|
||||||
|
});
|
||||||
|
return { approval: request };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/count",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
pendingCount: z.number(),
|
||||||
|
finalizedCount: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { count } = await server.services.accessApprovalRequest.getCount({
|
||||||
|
projectSlug: req.query.projectSlug,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorAuthMethod: req.permission.authMethod
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...count };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
projectSlug: z.string().trim(),
|
||||||
|
authorProjectMembershipId: z.string().trim().optional(),
|
||||||
|
envSlug: z.string().trim().optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
requests: AccessApprovalRequestsSchema.extend({
|
||||||
|
environmentName: z.string(),
|
||||||
|
isApproved: z.boolean(),
|
||||||
|
privilege: z
|
||||||
|
.object({
|
||||||
|
membershipId: z.string(),
|
||||||
|
isTemporary: z.boolean(),
|
||||||
|
temporaryMode: z.string().nullish(),
|
||||||
|
temporaryRange: z.string().nullish(),
|
||||||
|
temporaryAccessStartTime: z.date().nullish(),
|
||||||
|
temporaryAccessEndTime: z.date().nullish(),
|
||||||
|
permissions: z.unknown()
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
policy: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
approvals: z.number(),
|
||||||
|
approvers: z.string().array(),
|
||||||
|
secretPath: z.string().nullish(),
|
||||||
|
envId: z.string()
|
||||||
|
}),
|
||||||
|
reviewers: z
|
||||||
|
.object({
|
||||||
|
member: z.string(),
|
||||||
|
status: z.string()
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
}).array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({
|
||||||
|
projectSlug: req.query.projectSlug,
|
||||||
|
authorProjectMembershipId: req.query.authorProjectMembershipId,
|
||||||
|
envSlug: req.query.envSlug,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorAuthMethod: req.permission.authMethod
|
||||||
|
});
|
||||||
|
|
||||||
|
return { requests };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:requestId/review",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
requestId: z.string().trim()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED])
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
review: AccessApprovalRequestsReviewersSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const review = await server.services.accessApprovalRequest.reviewAccessRequest({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
requestId: req.params.requestId,
|
||||||
|
status: req.body.status
|
||||||
|
});
|
||||||
|
|
||||||
|
return { review };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
|
||||||
|
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
|
||||||
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
|
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
|
||||||
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
|
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
|
||||||
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
|
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
|
||||||
@@ -41,6 +43,9 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
|||||||
prefix: "/secret-rotation-providers"
|
prefix: "/secret-rotation-providers"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" });
|
||||||
|
await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" });
|
||||||
|
|
||||||
await server.register(
|
await server.register(
|
||||||
async (dynamicSecretRouter) => {
|
async (dynamicSecretRouter) => {
|
||||||
await dynamicSecretRouter.register(registerDynamicSecretRouter);
|
await dynamicSecretRouter.register(registerDynamicSecretRouter);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicyApproverDALFactory = ReturnType<typeof accessApprovalPolicyApproverDALFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => {
|
||||||
|
const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover);
|
||||||
|
return { ...accessApprovalPolicyApproverOrm };
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName, TAccessApprovalPolicies } from "@app/db/schemas";
|
||||||
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
|
import { buildFindFilter, mergeOneToManyRelation, ormify, selectAllTableCols, TFindFilter } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicyDALFactory = ReturnType<typeof accessApprovalPolicyDALFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||||
|
const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy);
|
||||||
|
|
||||||
|
const accessApprovalPolicyFindQuery = async (tx: Knex, filter: TFindFilter<TAccessApprovalPolicies>) => {
|
||||||
|
const result = await tx(TableName.AccessApprovalPolicy)
|
||||||
|
// eslint-disable-next-line
|
||||||
|
.where(buildFindFilter(filter))
|
||||||
|
.join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.join(
|
||||||
|
TableName.AccessApprovalPolicyApprover,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`,
|
||||||
|
`${TableName.AccessApprovalPolicyApprover}.policyId`
|
||||||
|
)
|
||||||
|
.select(tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover))
|
||||||
|
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
|
||||||
|
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
|
||||||
|
.select(tx.ref("id").withSchema(TableName.Environment).as("envId"))
|
||||||
|
.select(tx.ref("projectId").withSchema(TableName.Environment))
|
||||||
|
.select(selectAllTableCols(TableName.AccessApprovalPolicy));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findById = async (id: string, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const doc = await accessApprovalPolicyFindQuery(tx || db, {
|
||||||
|
[`${TableName.AccessApprovalPolicy}.id` as "id"]: id
|
||||||
|
});
|
||||||
|
const formatedDoc = mergeOneToManyRelation(
|
||||||
|
doc,
|
||||||
|
"id",
|
||||||
|
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
|
||||||
|
...el,
|
||||||
|
envId,
|
||||||
|
environment: { id: envId, name, slug }
|
||||||
|
}),
|
||||||
|
({ approverId }) => approverId,
|
||||||
|
"approvers"
|
||||||
|
);
|
||||||
|
return formatedDoc?.[0];
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "FindById" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const find = async (filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const docs = await accessApprovalPolicyFindQuery(tx || db, filter);
|
||||||
|
const formatedDoc = mergeOneToManyRelation(
|
||||||
|
docs,
|
||||||
|
"id",
|
||||||
|
({ approverId, envId, envName: name, envSlug: slug, ...el }) => ({
|
||||||
|
...el,
|
||||||
|
envId,
|
||||||
|
environment: { id: envId, name, slug }
|
||||||
|
}),
|
||||||
|
({ approverId }) => approverId,
|
||||||
|
"approvers"
|
||||||
|
);
|
||||||
|
return formatedDoc.map((policy) => ({ ...policy, secretPath: policy.secretPath || undefined }));
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...accessApprovalPolicyOrm, find, findById };
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { ActorType } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||||
|
import { TVerifyApprovers } from "./access-approval-policy-types";
|
||||||
|
|
||||||
|
export const verifyApprovers = async ({
|
||||||
|
userIds,
|
||||||
|
projectId,
|
||||||
|
orgId,
|
||||||
|
envSlug,
|
||||||
|
actorAuthMethod,
|
||||||
|
secretPath,
|
||||||
|
permissionService
|
||||||
|
}: TVerifyApprovers) => {
|
||||||
|
for await (const userId of userIds) {
|
||||||
|
try {
|
||||||
|
const { permission: approverPermission } = await permissionService.getProjectPermission(
|
||||||
|
ActorType.USER,
|
||||||
|
userId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(approverPermission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: envSlug, secretPath })
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestError({ message: "One or more approvers doesn't have access to be specified secret path" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
|
||||||
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
|
||||||
|
import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal";
|
||||||
|
import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal";
|
||||||
|
import { verifyApprovers } from "./access-approval-policy-fns";
|
||||||
|
import {
|
||||||
|
TCreateAccessApprovalPolicy,
|
||||||
|
TDeleteAccessApprovalPolicy,
|
||||||
|
TGetAccessPolicyCountByEnvironmentDTO,
|
||||||
|
TListAccessApprovalPoliciesDTO,
|
||||||
|
TUpdateAccessApprovalPolicy
|
||||||
|
} from "./access-approval-policy-types";
|
||||||
|
|
||||||
|
type TSecretApprovalPolicyServiceFactoryDep = {
|
||||||
|
projectDAL: TProjectDALFactory;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory;
|
||||||
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "find" | "findOne">;
|
||||||
|
accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicyServiceFactory = ReturnType<typeof accessApprovalPolicyServiceFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalPolicyServiceFactory = ({
|
||||||
|
accessApprovalPolicyDAL,
|
||||||
|
accessApprovalPolicyApproverDAL,
|
||||||
|
permissionService,
|
||||||
|
projectEnvDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
}: TSecretApprovalPolicyServiceFactoryDep) => {
|
||||||
|
const createAccessApprovalPolicy = async ({
|
||||||
|
name,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
secretPath,
|
||||||
|
actorAuthMethod,
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
projectSlug,
|
||||||
|
environment
|
||||||
|
}: TCreateAccessApprovalPolicy) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
if (approvals > approvers.length)
|
||||||
|
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
ProjectPermissionSub.SecretApproval
|
||||||
|
);
|
||||||
|
const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id });
|
||||||
|
if (!env) throw new BadRequestError({ message: "Environment not found" });
|
||||||
|
|
||||||
|
const secretApprovers = await projectMembershipDAL.find({
|
||||||
|
projectId: project.id,
|
||||||
|
$in: { id: approvers }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (secretApprovers.length !== approvers.length) {
|
||||||
|
throw new BadRequestError({ message: "Approver not found in project" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await verifyApprovers({
|
||||||
|
projectId: project.id,
|
||||||
|
orgId: actorOrgId,
|
||||||
|
envSlug: environment,
|
||||||
|
secretPath,
|
||||||
|
actorAuthMethod,
|
||||||
|
permissionService,
|
||||||
|
userIds: secretApprovers.map((approver) => approver.userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
|
||||||
|
const doc = await accessApprovalPolicyDAL.create(
|
||||||
|
{
|
||||||
|
envId: env.id,
|
||||||
|
approvals,
|
||||||
|
secretPath,
|
||||||
|
name
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
await accessApprovalPolicyApproverDAL.insertMany(
|
||||||
|
secretApprovers.map(({ id }) => ({
|
||||||
|
approverId: id,
|
||||||
|
policyId: doc.id
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
return { ...accessApproval, environment: env, projectId: project.id };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccessApprovalPolicyByProjectSlug = async ({
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
projectSlug
|
||||||
|
}: TListAccessApprovalPoliciesDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
// Anyone in the project should be able to get the policies.
|
||||||
|
/* const { permission } = */ await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
// ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||||
|
|
||||||
|
const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id });
|
||||||
|
return accessApprovalPolicies;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAccessApprovalPolicy = async ({
|
||||||
|
policyId,
|
||||||
|
approvers,
|
||||||
|
secretPath,
|
||||||
|
name,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
approvals
|
||||||
|
}: TUpdateAccessApprovalPolicy) => {
|
||||||
|
const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId);
|
||||||
|
if (!accessApprovalPolicy) throw new BadRequestError({ message: "Secret approval policy not found" });
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
accessApprovalPolicy.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval);
|
||||||
|
|
||||||
|
const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
|
||||||
|
const doc = await accessApprovalPolicyDAL.updateById(
|
||||||
|
accessApprovalPolicy.id,
|
||||||
|
{
|
||||||
|
approvals,
|
||||||
|
secretPath,
|
||||||
|
name
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
if (approvers) {
|
||||||
|
// Find the workspace project memberships of the users passed in the approvers array
|
||||||
|
const secretApprovers = await projectMembershipDAL.find(
|
||||||
|
{
|
||||||
|
projectId: accessApprovalPolicy.projectId,
|
||||||
|
$in: { id: approvers }
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
|
||||||
|
await verifyApprovers({
|
||||||
|
projectId: accessApprovalPolicy.projectId,
|
||||||
|
orgId: actorOrgId,
|
||||||
|
envSlug: accessApprovalPolicy.environment.slug,
|
||||||
|
secretPath: doc.secretPath!,
|
||||||
|
actorAuthMethod,
|
||||||
|
permissionService,
|
||||||
|
userIds: secretApprovers.map((approver) => approver.userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (secretApprovers.length !== approvers.length)
|
||||||
|
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
|
||||||
|
await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx);
|
||||||
|
await accessApprovalPolicyApproverDAL.insertMany(
|
||||||
|
secretApprovers.map(({ id }) => ({
|
||||||
|
approverId: id,
|
||||||
|
policyId: doc.id
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...updatedPolicy,
|
||||||
|
environment: accessApprovalPolicy.environment,
|
||||||
|
projectId: accessApprovalPolicy.projectId
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccessApprovalPolicy = async ({
|
||||||
|
policyId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
}: TDeleteAccessApprovalPolicy) => {
|
||||||
|
const policy = await accessApprovalPolicyDAL.findById(policyId);
|
||||||
|
if (!policy) throw new BadRequestError({ message: "Secret approval policy not found" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
policy.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
ProjectPermissionSub.SecretApproval
|
||||||
|
);
|
||||||
|
|
||||||
|
await accessApprovalPolicyDAL.deleteById(policyId);
|
||||||
|
return policy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAccessPolicyCountByEnvSlug = async ({
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
projectSlug,
|
||||||
|
actorId,
|
||||||
|
envSlug
|
||||||
|
}: TGetAccessPolicyCountByEnvironmentDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const { membership } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
if (!membership) throw new BadRequestError({ message: "User not found in project" });
|
||||||
|
|
||||||
|
const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug });
|
||||||
|
if (!environment) throw new BadRequestError({ message: "Environment not found" });
|
||||||
|
|
||||||
|
const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id });
|
||||||
|
if (!policies) throw new BadRequestError({ message: "No policies found" });
|
||||||
|
|
||||||
|
return { count: policies.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAccessPolicyCountByEnvSlug,
|
||||||
|
createAccessApprovalPolicy,
|
||||||
|
deleteAccessApprovalPolicy,
|
||||||
|
updateAccessApprovalPolicy,
|
||||||
|
getAccessApprovalPolicyByProjectSlug
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
import { ActorAuthMethod } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||||
|
|
||||||
|
export type TVerifyApprovers = {
|
||||||
|
userIds: string[];
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
envSlug: string;
|
||||||
|
actorAuthMethod: ActorAuthMethod;
|
||||||
|
secretPath: string;
|
||||||
|
projectId: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateAccessApprovalPolicy = {
|
||||||
|
approvals: number;
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
approvers: string[];
|
||||||
|
projectSlug: string;
|
||||||
|
name: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessApprovalPolicy = {
|
||||||
|
policyId: string;
|
||||||
|
approvals?: number;
|
||||||
|
approvers?: string[];
|
||||||
|
secretPath?: string;
|
||||||
|
name?: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TDeleteAccessApprovalPolicy = {
|
||||||
|
policyId: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TGetAccessPolicyCountByEnvironmentDTO = {
|
||||||
|
envSlug: string;
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TListAccessApprovalPoliciesDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests } from "@app/db/schemas";
|
||||||
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
|
import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex";
|
||||||
|
|
||||||
|
import { ApprovalStatus } from "./access-approval-request-types";
|
||||||
|
|
||||||
|
export type TAccessApprovalRequestDALFactory = ReturnType<typeof accessApprovalRequestDALFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||||
|
const accessApprovalRequestOrm = ormify(db, TableName.AccessApprovalRequest);
|
||||||
|
|
||||||
|
const findRequestsWithPrivilegeByPolicyIds = async (policyIds: string[]) => {
|
||||||
|
try {
|
||||||
|
const docs = await db(TableName.AccessApprovalRequest)
|
||||||
|
.whereIn(`${TableName.AccessApprovalRequest}.policyId`, policyIds)
|
||||||
|
|
||||||
|
.leftJoin(
|
||||||
|
TableName.ProjectUserAdditionalPrivilege,
|
||||||
|
`${TableName.AccessApprovalRequest}.privilegeId`,
|
||||||
|
`${TableName.ProjectUserAdditionalPrivilege}.id`
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.AccessApprovalPolicy,
|
||||||
|
`${TableName.AccessApprovalRequest}.policyId`,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin(
|
||||||
|
TableName.AccessApprovalRequestReviewer,
|
||||||
|
`${TableName.AccessApprovalRequest}.id`,
|
||||||
|
`${TableName.AccessApprovalRequestReviewer}.requestId`
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.AccessApprovalPolicyApprover,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`,
|
||||||
|
`${TableName.AccessApprovalPolicyApprover}.policyId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
||||||
|
|
||||||
|
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
||||||
|
.select(
|
||||||
|
db.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"),
|
||||||
|
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("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId")
|
||||||
|
)
|
||||||
|
|
||||||
|
.select(db.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover))
|
||||||
|
|
||||||
|
.select(
|
||||||
|
db.ref("projectId").withSchema(TableName.Environment),
|
||||||
|
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
|
||||||
|
db.ref("name").withSchema(TableName.Environment).as("envName")
|
||||||
|
)
|
||||||
|
|
||||||
|
.select(
|
||||||
|
db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"),
|
||||||
|
db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")
|
||||||
|
)
|
||||||
|
|
||||||
|
.select(
|
||||||
|
db
|
||||||
|
.ref("projectMembershipId")
|
||||||
|
.withSchema(TableName.ProjectUserAdditionalPrivilege)
|
||||||
|
.as("privilegeMembershipId"),
|
||||||
|
db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeIsTemporary"),
|
||||||
|
db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryMode"),
|
||||||
|
db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegeTemporaryRange"),
|
||||||
|
db
|
||||||
|
.ref("temporaryAccessStartTime")
|
||||||
|
.withSchema(TableName.ProjectUserAdditionalPrivilege)
|
||||||
|
.as("privilegeTemporaryAccessStartTime"),
|
||||||
|
db
|
||||||
|
.ref("temporaryAccessEndTime")
|
||||||
|
.withSchema(TableName.ProjectUserAdditionalPrivilege)
|
||||||
|
.as("privilegeTemporaryAccessEndTime"),
|
||||||
|
|
||||||
|
db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("privilegePermissions")
|
||||||
|
)
|
||||||
|
.orderBy(`${TableName.AccessApprovalRequest}.createdAt`, "desc");
|
||||||
|
|
||||||
|
const formattedDocs = sqlNestRelationships({
|
||||||
|
data: docs,
|
||||||
|
key: "id",
|
||||||
|
parentMapper: (doc) => ({
|
||||||
|
...AccessApprovalRequestsSchema.parse(doc),
|
||||||
|
projectId: doc.projectId,
|
||||||
|
environment: doc.envSlug,
|
||||||
|
environmentName: doc.envName,
|
||||||
|
policy: {
|
||||||
|
id: doc.policyId,
|
||||||
|
name: doc.policyName,
|
||||||
|
approvals: doc.policyApprovals,
|
||||||
|
secretPath: doc.policySecretPath,
|
||||||
|
envId: doc.policyEnvId
|
||||||
|
},
|
||||||
|
privilege: doc.privilegeId
|
||||||
|
? {
|
||||||
|
membershipId: doc.privilegeMembershipId,
|
||||||
|
isTemporary: doc.privilegeIsTemporary,
|
||||||
|
temporaryMode: doc.privilegeTemporaryMode,
|
||||||
|
temporaryRange: doc.privilegeTemporaryRange,
|
||||||
|
temporaryAccessStartTime: doc.privilegeTemporaryAccessStartTime,
|
||||||
|
temporaryAccessEndTime: doc.privilegeTemporaryAccessEndTime,
|
||||||
|
permissions: doc.privilegePermissions
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
|
||||||
|
isApproved: !!doc.privilegeId
|
||||||
|
}),
|
||||||
|
childrenMapper: [
|
||||||
|
{
|
||||||
|
key: "reviewerMemberId",
|
||||||
|
label: "reviewers" as const,
|
||||||
|
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined)
|
||||||
|
},
|
||||||
|
{ key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!formattedDocs) return [];
|
||||||
|
|
||||||
|
return formattedDocs.map((doc) => ({
|
||||||
|
...doc,
|
||||||
|
policy: { ...doc.policy, approvers: doc.approvers }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findQuery = (filter: TFindFilter<TAccessApprovalRequests>, tx: Knex) =>
|
||||||
|
tx(TableName.AccessApprovalRequest)
|
||||||
|
.where(filter)
|
||||||
|
.join(
|
||||||
|
TableName.AccessApprovalPolicy,
|
||||||
|
`${TableName.AccessApprovalRequest}.policyId`,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`
|
||||||
|
)
|
||||||
|
|
||||||
|
.join(
|
||||||
|
TableName.AccessApprovalPolicyApprover,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`,
|
||||||
|
`${TableName.AccessApprovalPolicyApprover}.policyId`
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.AccessApprovalRequestReviewer,
|
||||||
|
`${TableName.AccessApprovalRequest}.id`,
|
||||||
|
`${TableName.AccessApprovalRequestReviewer}.requestId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
||||||
|
.select(
|
||||||
|
tx.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"),
|
||||||
|
tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"),
|
||||||
|
tx.ref("id").withSchema(TableName.AccessApprovalPolicy).as("policyId"),
|
||||||
|
tx.ref("name").withSchema(TableName.AccessApprovalPolicy).as("policyName"),
|
||||||
|
tx.ref("projectId").withSchema(TableName.Environment),
|
||||||
|
tx.ref("slug").withSchema(TableName.Environment).as("environment"),
|
||||||
|
tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"),
|
||||||
|
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
|
||||||
|
tx.ref("approverId").withSchema(TableName.AccessApprovalPolicyApprover)
|
||||||
|
);
|
||||||
|
|
||||||
|
const findById = async (id: string, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db);
|
||||||
|
const docs = await sql;
|
||||||
|
const formatedDoc = sqlNestRelationships({
|
||||||
|
data: docs,
|
||||||
|
key: "id",
|
||||||
|
parentMapper: (el) => ({
|
||||||
|
...AccessApprovalRequestsSchema.parse(el),
|
||||||
|
projectId: el.projectId,
|
||||||
|
environment: el.environment,
|
||||||
|
policy: {
|
||||||
|
id: el.policyId,
|
||||||
|
name: el.policyName,
|
||||||
|
approvals: el.policyApprovals,
|
||||||
|
secretPath: el.policySecretPath
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
childrenMapper: [
|
||||||
|
{
|
||||||
|
key: "reviewerMemberId",
|
||||||
|
label: "reviewers" as const,
|
||||||
|
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined)
|
||||||
|
},
|
||||||
|
{ key: "approverId", label: "approvers" as const, mapper: ({ approverId }) => approverId }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
if (!formatedDoc?.[0]) return;
|
||||||
|
return {
|
||||||
|
...formatedDoc[0],
|
||||||
|
policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCount = async ({ projectId }: { projectId: string }) => {
|
||||||
|
try {
|
||||||
|
const accessRequests = await db(TableName.AccessApprovalRequest)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.AccessApprovalPolicy,
|
||||||
|
`${TableName.AccessApprovalRequest}.policyId`,
|
||||||
|
`${TableName.AccessApprovalPolicy}.id`
|
||||||
|
)
|
||||||
|
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.ProjectUserAdditionalPrivilege,
|
||||||
|
`${TableName.AccessApprovalRequest}.privilegeId`,
|
||||||
|
`${TableName.ProjectUserAdditionalPrivilege}.id`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin(
|
||||||
|
TableName.AccessApprovalRequestReviewer,
|
||||||
|
`${TableName.AccessApprovalRequest}.id`,
|
||||||
|
`${TableName.AccessApprovalRequestReviewer}.requestId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.where(`${TableName.Environment}.projectId`, projectId)
|
||||||
|
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
||||||
|
.select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"))
|
||||||
|
.select(db.ref("member").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerMemberId"));
|
||||||
|
|
||||||
|
const formattedRequests = sqlNestRelationships({
|
||||||
|
data: accessRequests,
|
||||||
|
key: "id",
|
||||||
|
parentMapper: (doc) => ({
|
||||||
|
...AccessApprovalRequestsSchema.parse(doc)
|
||||||
|
}),
|
||||||
|
childrenMapper: [
|
||||||
|
{
|
||||||
|
key: "reviewerMemberId",
|
||||||
|
label: "reviewers" as const,
|
||||||
|
mapper: ({ reviewerMemberId: member, reviewerStatus: status }) => (member ? { member, status } : undefined)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// an approval is pending if there is no reviewer rejections and no privilege ID is set
|
||||||
|
const pendingApprovals = formattedRequests.filter(
|
||||||
|
(req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED)
|
||||||
|
);
|
||||||
|
|
||||||
|
// an approval is finalized if there are any rejections or a privilege ID is set
|
||||||
|
const finalizedApprovals = formattedRequests.filter(
|
||||||
|
(req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length };
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "GetCountAccessApprovalRequest" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount };
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { PackRule, unpackRules } from "@casl/ability/extra";
|
||||||
|
|
||||||
|
import { UnauthorizedError } from "@app/lib/errors";
|
||||||
|
|
||||||
|
import { TVerifyPermission } from "./access-approval-request-types";
|
||||||
|
|
||||||
|
function filterUnique(value: string, index: number, array: string[]) {
|
||||||
|
return array.indexOf(value) === index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifyRequestedPermissions = ({ permissions }: TVerifyPermission) => {
|
||||||
|
const permission = unpackRules(
|
||||||
|
permissions as PackRule<{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
conditions?: Record<string, any>;
|
||||||
|
action: string;
|
||||||
|
subject: [string];
|
||||||
|
}>[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!permission || !permission.length) {
|
||||||
|
throw new UnauthorizedError({ message: "No permission provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedPermissions: string[] = [];
|
||||||
|
|
||||||
|
for (const p of permission) {
|
||||||
|
if (p.action[0] === "read") requestedPermissions.push("Read Access");
|
||||||
|
if (p.action[0] === "create") requestedPermissions.push("Create Access");
|
||||||
|
if (p.action[0] === "delete") requestedPermissions.push("Delete Access");
|
||||||
|
if (p.action[0] === "edit") requestedPermissions.push("Edit Access");
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPermission = permission[0];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
const permissionSecretPath = firstPermission.conditions?.secretPath?.$glob;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-unsafe-assignment
|
||||||
|
const permissionEnv = firstPermission.conditions?.environment;
|
||||||
|
|
||||||
|
if (!permissionEnv || typeof permissionEnv !== "string") {
|
||||||
|
throw new UnauthorizedError({ message: "Permission environment is not a string" });
|
||||||
|
}
|
||||||
|
if (!permissionSecretPath || typeof permissionSecretPath !== "string") {
|
||||||
|
throw new UnauthorizedError({ message: "Permission path is not a string" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
envSlug: permissionEnv,
|
||||||
|
secretPath: permissionSecretPath,
|
||||||
|
accessTypes: requestedPermissions.filter(filterUnique)
|
||||||
|
};
|
||||||
|
};
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TAccessApprovalRequestReviewerDALFactory = ReturnType<typeof accessApprovalRequestReviewerDALFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalRequestReviewerDALFactory = (db: TDbClient) => {
|
||||||
|
const secretApprovalRequestReviewerOrm = ormify(db, TableName.AccessApprovalRequestReviewer);
|
||||||
|
return secretApprovalRequestReviewerOrm;
|
||||||
|
};
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
import ms from "ms";
|
||||||
|
|
||||||
|
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||||
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||||
|
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||||
|
|
||||||
|
import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal";
|
||||||
|
import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
|
||||||
|
import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns";
|
||||||
|
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||||
|
import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
|
||||||
|
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
|
||||||
|
import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal";
|
||||||
|
import { verifyRequestedPermissions } from "./access-approval-request-fns";
|
||||||
|
import { TAccessApprovalRequestReviewerDALFactory } from "./access-approval-request-reviewer-dal";
|
||||||
|
import {
|
||||||
|
ApprovalStatus,
|
||||||
|
TCreateAccessApprovalRequestDTO,
|
||||||
|
TGetAccessRequestCountDTO,
|
||||||
|
TListApprovalRequestsDTO,
|
||||||
|
TReviewAccessRequestDTO
|
||||||
|
} from "./access-approval-request-types";
|
||||||
|
|
||||||
|
type TSecretApprovalRequestServiceFactoryDep = {
|
||||||
|
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "create" | "findById">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
accessApprovalPolicyApproverDAL: Pick<TAccessApprovalPolicyApproverDALFactory, "find">;
|
||||||
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findProjectBySlug">;
|
||||||
|
accessApprovalRequestDAL: Pick<
|
||||||
|
TAccessApprovalRequestDALFactory,
|
||||||
|
| "create"
|
||||||
|
| "find"
|
||||||
|
| "findRequestsWithPrivilegeByPolicyIds"
|
||||||
|
| "findById"
|
||||||
|
| "transaction"
|
||||||
|
| "updateById"
|
||||||
|
| "findOne"
|
||||||
|
| "getCount"
|
||||||
|
>;
|
||||||
|
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find">;
|
||||||
|
accessApprovalRequestReviewerDAL: Pick<
|
||||||
|
TAccessApprovalRequestReviewerDALFactory,
|
||||||
|
"create" | "find" | "findOne" | "transaction"
|
||||||
|
>;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
userDAL: Pick<TUserDALFactory, "findUserByProjectMembershipId" | "findUsersByProjectMembershipIds">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAccessApprovalRequestServiceFactory = ReturnType<typeof accessApprovalRequestServiceFactory>;
|
||||||
|
|
||||||
|
export const accessApprovalRequestServiceFactory = ({
|
||||||
|
projectDAL,
|
||||||
|
projectEnvDAL,
|
||||||
|
permissionService,
|
||||||
|
accessApprovalRequestDAL,
|
||||||
|
accessApprovalRequestReviewerDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
accessApprovalPolicyDAL,
|
||||||
|
accessApprovalPolicyApproverDAL,
|
||||||
|
additionalPrivilegeDAL,
|
||||||
|
smtpService,
|
||||||
|
userDAL
|
||||||
|
}: TSecretApprovalRequestServiceFactoryDep) => {
|
||||||
|
const createAccessApprovalRequest = async ({
|
||||||
|
isTemporary,
|
||||||
|
temporaryRange,
|
||||||
|
actorId,
|
||||||
|
permissions: requestedPermissions,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
projectSlug
|
||||||
|
}: TCreateAccessApprovalRequestDTO) => {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new UnauthorizedError({ message: "Project not found" });
|
||||||
|
|
||||||
|
// Anyone can create an access approval request.
|
||||||
|
const { membership } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" });
|
||||||
|
|
||||||
|
const requestedByUser = await userDAL.findUserByProjectMembershipId(membership.id);
|
||||||
|
if (!requestedByUser) throw new UnauthorizedError({ message: "User not found" });
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(project.id);
|
||||||
|
|
||||||
|
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ permissions: requestedPermissions });
|
||||||
|
const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug });
|
||||||
|
|
||||||
|
if (!environment) throw new UnauthorizedError({ message: "Environment not found" });
|
||||||
|
|
||||||
|
const policy = await accessApprovalPolicyDAL.findOne({
|
||||||
|
envId: environment.id,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." });
|
||||||
|
|
||||||
|
const approvers = await accessApprovalPolicyApproverDAL.find({
|
||||||
|
policyId: policy.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const approverUsers = await userDAL.findUsersByProjectMembershipIds(
|
||||||
|
approvers.map((approver) => approver.approverId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateRequests = await accessApprovalRequestDAL.find({
|
||||||
|
policyId: policy.id,
|
||||||
|
requestedBy: membership.id,
|
||||||
|
permissions: JSON.stringify(requestedPermissions),
|
||||||
|
isTemporary
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateRequests?.length > 0) {
|
||||||
|
for await (const duplicateRequest of duplicateRequests) {
|
||||||
|
if (duplicateRequest.privilegeId) {
|
||||||
|
const privilege = await additionalPrivilegeDAL.findById(duplicateRequest.privilegeId);
|
||||||
|
|
||||||
|
const isExpired = new Date() > new Date(privilege.temporaryAccessEndTime || ("" as string));
|
||||||
|
|
||||||
|
if (!isExpired || !privilege.isTemporary) {
|
||||||
|
throw new BadRequestError({ message: "You already have an active privilege with the same criteria" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const reviewers = await accessApprovalRequestReviewerDAL.find({
|
||||||
|
requestId: duplicateRequest.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED);
|
||||||
|
|
||||||
|
if (!isRejected) {
|
||||||
|
throw new BadRequestError({ message: "You already have a pending access request with the same criteria" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
|
||||||
|
const approvalRequest = await accessApprovalRequestDAL.create(
|
||||||
|
{
|
||||||
|
policyId: policy.id,
|
||||||
|
requestedBy: membership.id,
|
||||||
|
temporaryRange: temporaryRange || null,
|
||||||
|
permissions: JSON.stringify(requestedPermissions),
|
||||||
|
isTemporary
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!),
|
||||||
|
subjectLine: "Access Approval Request",
|
||||||
|
|
||||||
|
substitutions: {
|
||||||
|
projectName: project.name,
|
||||||
|
requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`,
|
||||||
|
requesterEmail: requestedByUser.email,
|
||||||
|
isTemporary,
|
||||||
|
...(isTemporary && {
|
||||||
|
expiresIn: ms(ms(temporaryRange || ""), { long: true })
|
||||||
|
}),
|
||||||
|
secretPath,
|
||||||
|
environment: envSlug,
|
||||||
|
permissions: accessTypes,
|
||||||
|
approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval`
|
||||||
|
},
|
||||||
|
template: SmtpTemplates.AccessApprovalRequest
|
||||||
|
});
|
||||||
|
|
||||||
|
return approvalRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { request: approval };
|
||||||
|
};
|
||||||
|
|
||||||
|
const listApprovalRequests = async ({
|
||||||
|
projectSlug,
|
||||||
|
authorProjectMembershipId,
|
||||||
|
envSlug,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod
|
||||||
|
}: TListApprovalRequestsDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new UnauthorizedError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const { membership } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" });
|
||||||
|
|
||||||
|
const policies = await accessApprovalPolicyDAL.find({ projectId: project.id });
|
||||||
|
let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id));
|
||||||
|
|
||||||
|
if (authorProjectMembershipId) {
|
||||||
|
requests = requests.filter((request) => request.requestedBy === authorProjectMembershipId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envSlug) {
|
||||||
|
requests = requests.filter((request) => request.environment === envSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requests };
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewAccessRequest = async ({
|
||||||
|
requestId,
|
||||||
|
actor,
|
||||||
|
status,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
}: TReviewAccessRequestDTO) => {
|
||||||
|
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
|
||||||
|
if (!accessApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" });
|
||||||
|
|
||||||
|
const { policy } = accessApprovalRequest;
|
||||||
|
const { membership, hasRole } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
accessApprovalRequest.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!membership) throw new UnauthorizedError({ message: "You are not a member of this project" });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasRole(ProjectMembershipRole.Admin) &&
|
||||||
|
accessApprovalRequest.requestedBy !== membership.id && // The request wasn't made by the current user
|
||||||
|
!policy.approvers.find((approverId) => approverId === membership.id) // The request isn't performed by an assigned approver
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedError({ message: "You are not authorized to approve this request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id);
|
||||||
|
|
||||||
|
await verifyApprovers({
|
||||||
|
projectId: accessApprovalRequest.projectId,
|
||||||
|
orgId: actorOrgId,
|
||||||
|
envSlug: accessApprovalRequest.environment,
|
||||||
|
secretPath: accessApprovalRequest.policy.secretPath!,
|
||||||
|
actorAuthMethod,
|
||||||
|
permissionService,
|
||||||
|
userIds: [reviewerProjectMembership.userId]
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id });
|
||||||
|
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
|
||||||
|
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
|
||||||
|
const review = await accessApprovalRequestReviewerDAL.findOne(
|
||||||
|
{
|
||||||
|
requestId: accessApprovalRequest.id,
|
||||||
|
member: membership.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
if (!review) {
|
||||||
|
const newReview = await accessApprovalRequestReviewerDAL.create(
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
requestId: accessApprovalRequest.id,
|
||||||
|
member: membership.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const allReviews = [...existingReviews, newReview];
|
||||||
|
|
||||||
|
const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED);
|
||||||
|
|
||||||
|
// approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved.
|
||||||
|
if (approvedReviews.length === policy.approvals) {
|
||||||
|
if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
|
||||||
|
throw new BadRequestError({ message: "Temporary range is required for temporary access" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let privilegeId: string | null = null;
|
||||||
|
|
||||||
|
if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
|
||||||
|
// Permanent access
|
||||||
|
const privilege = await additionalPrivilegeDAL.create(
|
||||||
|
{
|
||||||
|
projectMembershipId: accessApprovalRequest.requestedBy,
|
||||||
|
slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`,
|
||||||
|
permissions: JSON.stringify(accessApprovalRequest.permissions)
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
privilegeId = privilege.id;
|
||||||
|
} else {
|
||||||
|
// Temporary access
|
||||||
|
const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!);
|
||||||
|
const startTime = new Date();
|
||||||
|
|
||||||
|
const privilege = await additionalPrivilegeDAL.create(
|
||||||
|
{
|
||||||
|
projectMembershipId: accessApprovalRequest.requestedBy,
|
||||||
|
slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`,
|
||||||
|
permissions: JSON.stringify(accessApprovalRequest.permissions),
|
||||||
|
isTemporary: true,
|
||||||
|
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
|
||||||
|
temporaryRange: accessApprovalRequest.temporaryRange!,
|
||||||
|
temporaryAccessStartTime: startTime,
|
||||||
|
temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs)
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
privilegeId = privilege.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newReview;
|
||||||
|
}
|
||||||
|
throw new BadRequestError({ message: "You have already reviewed this request" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return reviewStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCount = async ({ projectSlug, actor, actorAuthMethod, actorId, actorOrgId }: TGetAccessRequestCountDTO) => {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new UnauthorizedError({ message: "Project not found" });
|
||||||
|
|
||||||
|
const { membership } = await permissionService.getProjectPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
project.id,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
|
if (!membership) throw new BadRequestError({ message: "User not found in project" });
|
||||||
|
|
||||||
|
const count = await accessApprovalRequestDAL.getCount({ projectId: project.id });
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createAccessApprovalRequest,
|
||||||
|
listApprovalRequests,
|
||||||
|
reviewAccessRequest,
|
||||||
|
getCount
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
|
export enum ApprovalStatus {
|
||||||
|
PENDING = "pending",
|
||||||
|
APPROVED = "approved",
|
||||||
|
REJECTED = "rejected"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TVerifyPermission = {
|
||||||
|
permissions: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGetAccessRequestCountDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TReviewAccessRequestDTO = {
|
||||||
|
requestId: string;
|
||||||
|
status: ApprovalStatus;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TCreateAccessApprovalRequestDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
permissions: unknown;
|
||||||
|
isTemporary: boolean;
|
||||||
|
temporaryRange?: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TListApprovalRequestsDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
authorProjectMembershipId?: string;
|
||||||
|
envSlug?: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
@@ -121,8 +121,8 @@ export const licenseServiceFactory = ({
|
|||||||
|
|
||||||
if (isValidOfflineLicense) {
|
if (isValidOfflineLicense) {
|
||||||
onPremFeatures = contents.license.features;
|
onPremFeatures = contents.license.features;
|
||||||
instanceType = InstanceType.EnterpriseOnPrem;
|
instanceType = InstanceType.EnterpriseOnPremOffline;
|
||||||
logger.info(`Instance type: ${InstanceType.EnterpriseOnPrem}`);
|
logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`);
|
||||||
isValidLicense = true;
|
isValidLicense = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TOrgPermission } from "@app/lib/types";
|
|||||||
export enum InstanceType {
|
export enum InstanceType {
|
||||||
OnPrem = "self-hosted",
|
OnPrem = "self-hosted",
|
||||||
EnterpriseOnPrem = "enterprise-self-hosted",
|
EnterpriseOnPrem = "enterprise-self-hosted",
|
||||||
|
EnterpriseOnPremOffline = "enterprise-self-hosted-offline",
|
||||||
Cloud = "cloud"
|
Cloud = "cloud"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export const SECRET_TAGS = {
|
|||||||
export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
export const IDENTITY_ADDITIONAL_PRIVILEGE = {
|
||||||
CREATE: {
|
CREATE: {
|
||||||
projectSlug: "The slug of the project of the identity in.",
|
projectSlug: "The slug of the project of the identity in.",
|
||||||
identityId: "The ID of the identity to delete.",
|
identityId: "The ID of the identity to create.",
|
||||||
slug: "The slug of the privilege to create.",
|
slug: "The slug of the privilege to create.",
|
||||||
permissions: `The permission object for the privilege.
|
permissions: `The permission object for the privilege.
|
||||||
- Read secrets
|
- Read secrets
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => {
|
|||||||
if (req.url.includes("/api/v3/auth/")) {
|
if (req.url.includes("/api/v3/auth/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authMode) return;
|
if (!authMode) return;
|
||||||
|
|
||||||
switch (authMode) {
|
switch (authMode) {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { Knex } from "knex";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
import { registerV1EERoutes } from "@app/ee/routes/v1";
|
||||||
|
import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal";
|
||||||
|
import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal";
|
||||||
|
import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
|
||||||
|
import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal";
|
||||||
|
import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal";
|
||||||
|
import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
|
||||||
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
||||||
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
|
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
|
||||||
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||||
@@ -210,6 +216,12 @@ export const registerRoutes = async (
|
|||||||
const scimDAL = scimDALFactory(db);
|
const scimDAL = scimDALFactory(db);
|
||||||
const ldapConfigDAL = ldapConfigDALFactory(db);
|
const ldapConfigDAL = ldapConfigDALFactory(db);
|
||||||
const ldapGroupMapDAL = ldapGroupMapDALFactory(db);
|
const ldapGroupMapDAL = ldapGroupMapDALFactory(db);
|
||||||
|
|
||||||
|
const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db);
|
||||||
|
const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db);
|
||||||
|
const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db);
|
||||||
|
const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db);
|
||||||
|
|
||||||
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
|
const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db);
|
||||||
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
|
const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db);
|
||||||
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
|
const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db);
|
||||||
@@ -268,6 +280,7 @@ export const registerRoutes = async (
|
|||||||
secretApprovalPolicyDAL
|
secretApprovalPolicyDAL
|
||||||
});
|
});
|
||||||
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
||||||
|
|
||||||
const samlService = samlConfigServiceFactory({
|
const samlService = samlConfigServiceFactory({
|
||||||
permissionService,
|
permissionService,
|
||||||
orgBotDAL,
|
orgBotDAL,
|
||||||
@@ -599,6 +612,30 @@ export const registerRoutes = async (
|
|||||||
secretVersionTagDAL,
|
secretVersionTagDAL,
|
||||||
secretQueueService
|
secretQueueService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
|
||||||
|
accessApprovalPolicyDAL,
|
||||||
|
accessApprovalPolicyApproverDAL,
|
||||||
|
permissionService,
|
||||||
|
projectEnvDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
projectDAL
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessApprovalRequestService = accessApprovalRequestServiceFactory({
|
||||||
|
projectDAL,
|
||||||
|
permissionService,
|
||||||
|
accessApprovalRequestReviewerDAL,
|
||||||
|
additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
accessApprovalPolicyDAL,
|
||||||
|
accessApprovalRequestDAL,
|
||||||
|
projectEnvDAL,
|
||||||
|
userDAL,
|
||||||
|
smtpService,
|
||||||
|
accessApprovalPolicyApproverDAL
|
||||||
|
});
|
||||||
|
|
||||||
const secretRotationQueue = secretRotationQueueFactory({
|
const secretRotationQueue = secretRotationQueueFactory({
|
||||||
telemetryService,
|
telemetryService,
|
||||||
secretRotationDAL,
|
secretRotationDAL,
|
||||||
@@ -744,6 +781,8 @@ export const registerRoutes = async (
|
|||||||
identityUa: identityUaService,
|
identityUa: identityUaService,
|
||||||
identityAwsIamAuth: identityAWSIAMAuthService,
|
identityAwsIamAuth: identityAWSIAMAuthService,
|
||||||
secretApprovalPolicy: sapService,
|
secretApprovalPolicy: sapService,
|
||||||
|
accessApprovalPolicy: accessApprovalPolicyService,
|
||||||
|
accessApprovalRequest: accessApprovalRequestService,
|
||||||
secretApprovalRequest: sarService,
|
secretApprovalRequest: sarService,
|
||||||
secretRotation: secretRotationService,
|
secretRotation: secretRotationService,
|
||||||
dynamicSecret: dynamicSecretService,
|
dynamicSecret: dynamicSecretService,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export enum SmtpTemplates {
|
|||||||
EmailVerification = "emailVerification.handlebars",
|
EmailVerification = "emailVerification.handlebars",
|
||||||
SecretReminder = "secretReminder.handlebars",
|
SecretReminder = "secretReminder.handlebars",
|
||||||
EmailMfa = "emailMfa.handlebars",
|
EmailMfa = "emailMfa.handlebars",
|
||||||
|
AccessApprovalRequest = "accessApprovalRequest.handlebars",
|
||||||
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
|
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
|
||||||
NewDeviceJoin = "newDevice.handlebars",
|
NewDeviceJoin = "newDevice.handlebars",
|
||||||
OrgInvite = "organizationInvitation.handlebars",
|
OrgInvite = "organizationInvitation.handlebars",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||||
|
<title>Access Approval Request</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<h2>Infisical</h2>
|
||||||
|
<h2>New access approval request pending your review</h2>
|
||||||
|
<p>You have a new access approval request pending review in project "{{projectName}}".</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{requesterFullName}}
|
||||||
|
({{requesterEmail}}) has requested
|
||||||
|
{{#if isTemporary}}
|
||||||
|
temporary
|
||||||
|
{{else}}
|
||||||
|
permanent
|
||||||
|
{{/if}}
|
||||||
|
access to
|
||||||
|
{{secretPath}}
|
||||||
|
in the
|
||||||
|
{{environment}}
|
||||||
|
environment.
|
||||||
|
|
||||||
|
{{#if isTemporary}}
|
||||||
|
<br />
|
||||||
|
This access will expire
|
||||||
|
{{expiresIn}}
|
||||||
|
after it has been approved.
|
||||||
|
{{/if}}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The following permissions are requested:
|
||||||
|
<ul>
|
||||||
|
{{#each permissions}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
View the request and approve or deny it
|
||||||
|
<a href="{{approvalUrl}}">here</a>.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -74,6 +74,17 @@ export const userDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findUsersByProjectMembershipIds = async (projectMembershipIds: string[]) => {
|
||||||
|
try {
|
||||||
|
return await db(TableName.ProjectMembership)
|
||||||
|
.whereIn(`${TableName.ProjectMembership}.id`, projectMembershipIds)
|
||||||
|
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.select("*");
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find users by project membership ids" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => {
|
const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*");
|
const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*");
|
||||||
@@ -140,6 +151,7 @@ export const userDALFactory = (db: TDbClient) => {
|
|||||||
findUserEncKeyByUserId,
|
findUserEncKeyByUserId,
|
||||||
updateUserEncryptionByUserId,
|
updateUserEncryptionByUserId,
|
||||||
findUserByProjectMembershipId,
|
findUserByProjectMembershipId,
|
||||||
|
findUsersByProjectMembershipIds,
|
||||||
upsertUserEncryptionKey,
|
upsertUserEncryptionKey,
|
||||||
createUserEncryption,
|
createUserEncryption,
|
||||||
findOneUserAction,
|
findOneUserAction,
|
||||||
|
|||||||
@@ -17,12 +17,8 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
|
|||||||
containerClassName
|
containerClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className={twMerge("rounded-md bg-mineshaft-800 p-16 text-bunker-300", className)}>
|
||||||
className={twMerge(
|
<div className="flex items-end space-x-12">
|
||||||
"flex items-end space-x-12 rounded-md bg-mineshaft-800 p-16 text-bunker-300",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<FontAwesomeIcon icon={faLock} size="6x" />
|
<FontAwesomeIcon icon={faLock} size="6x" />
|
||||||
</div>
|
</div>
|
||||||
@@ -37,5 +33,6 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { cva, VariantProps } from "cva";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
[
|
||||||
|
"inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-xs text-yellow opacity-80 hover:opacity-100"
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: "bg-yellow/20 text-yellow",
|
||||||
|
danger: "bg-red/20 text-red",
|
||||||
|
success: "bg-green/20 text-green"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BadgeProps = VariantProps<typeof badgeVariants> & IProps;
|
||||||
|
|
||||||
|
export const Badge = ({ children, className, variant }: BadgeProps) => {
|
||||||
|
return (
|
||||||
|
<div className={twMerge(badgeVariants({ variant: variant || "primary" }), className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Badge } from "./Badge";
|
||||||
@@ -29,7 +29,7 @@ const buttonVariants = cva(
|
|||||||
colorSchema: {
|
colorSchema: {
|
||||||
primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"],
|
primary: ["bg-primary", "text-black", "border-primary bg-opacity-90 hover:bg-opacity-100"],
|
||||||
secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"],
|
secondary: ["bg-mineshaft", "text-gray-300", "border-mineshaft hover:bg-opacity-80"],
|
||||||
danger: ["bg-red", "text-white", "border-red hover:bg-opacity-90"],
|
danger: ["!bg-red", "!text-white", "!border-red hover:!bg-opacity-90"],
|
||||||
gray: ["bg-bunker-500", "text-bunker-200"]
|
gray: ["bg-bunker-500", "text-bunker-200"]
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Divider = ({ className }: IProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className={twMerge("flex items-center px-2 opacity-50", className)}>
|
||||||
|
<div aria-hidden="true" className="h-5 w-full grow border border-t border-mineshaft-200" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Divider } from "./Divider";
|
||||||
@@ -41,18 +41,22 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
`inline-flex items-center justify-between rounded-md
|
`inline-flex items-center justify-between rounded-md
|
||||||
bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200 focus:bg-mineshaft-700/80`,
|
bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none focus:bg-mineshaft-700/80 data-[placeholder]:text-mineshaft-200`,
|
||||||
className
|
className,
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SelectPrimitive.Value placeholder={placeholder}>
|
<SelectPrimitive.Value placeholder={placeholder}>
|
||||||
{props.icon ? <FontAwesomeIcon icon={props.icon} /> : placeholder}
|
{props.icon ? <FontAwesomeIcon icon={props.icon} /> : placeholder}
|
||||||
</SelectPrimitive.Value>
|
</SelectPrimitive.Value>
|
||||||
{!isDisabled && (
|
|
||||||
<SelectPrimitive.Icon className="ml-3">
|
<SelectPrimitive.Icon className="ml-3">
|
||||||
<FontAwesomeIcon icon={faCaretDown} size="sm" />
|
<FontAwesomeIcon
|
||||||
|
icon={faCaretDown}
|
||||||
|
size="sm"
|
||||||
|
className={twMerge(isDisabled && "opacity-30")}
|
||||||
|
/>
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
)}
|
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export {
|
||||||
|
useCreateAccessApprovalPolicy,
|
||||||
|
useCreateAccessRequest,
|
||||||
|
useDeleteAccessApprovalPolicy,
|
||||||
|
useReviewAccessRequest,
|
||||||
|
useUpdateAccessApprovalPolicy
|
||||||
|
} from "./mutation";
|
||||||
|
export {
|
||||||
|
useGetAccessApprovalPolicies,
|
||||||
|
useGetAccessApprovalRequests,
|
||||||
|
useGetAccessPolicyApprovalCount,
|
||||||
|
useGetAccessRequestsCount
|
||||||
|
} from "./queries";
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { packRules } from "@casl/ability/extra";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { accessApprovalKeys } from "./queries";
|
||||||
|
import {
|
||||||
|
TAccessApproval,
|
||||||
|
TCreateAccessPolicyDTO,
|
||||||
|
TCreateAccessRequestDTO,
|
||||||
|
TDeleteSecretPolicyDTO,
|
||||||
|
TUpdateAccessPolicyDTO
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const useCreateAccessApprovalPolicy = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TCreateAccessPolicyDTO>({
|
||||||
|
mutationFn: async ({ environment, projectSlug, approvals, approvers, name, secretPath }) => {
|
||||||
|
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
|
||||||
|
environment,
|
||||||
|
projectSlug,
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
secretPath,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateAccessApprovalPolicy = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
|
||||||
|
mutationFn: async ({ id, approvers, approvals, name, secretPath }) => {
|
||||||
|
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
|
||||||
|
approvals,
|
||||||
|
approvers,
|
||||||
|
secretPath,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteAccessApprovalPolicy = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{}, {}, TDeleteSecretPolicyDTO>({
|
||||||
|
mutationFn: async ({ id }) => {
|
||||||
|
const { data } = await apiRequest.delete(`/api/v1/access-approvals/policies/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalPolicies(projectSlug));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateAccessRequest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<{}, {}, TCreateAccessRequestDTO>({
|
||||||
|
mutationFn: async ({ projectSlug, ...request }) => {
|
||||||
|
const { data } = await apiRequest.post<TAccessApproval>(
|
||||||
|
"/api/v1/access-approvals/requests",
|
||||||
|
{
|
||||||
|
...request,
|
||||||
|
permissions: request.permissions ? packRules(request.permissions) : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
projectSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useReviewAccessRequest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
requestId: string;
|
||||||
|
status: "approved" | "rejected";
|
||||||
|
projectSlug: string;
|
||||||
|
envSlug?: string;
|
||||||
|
requestedBy?: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
mutationFn: async ({ requestId, status }) => {
|
||||||
|
const { data } = await apiRequest.post(
|
||||||
|
`/api/v1/access-approvals/requests/${requestId}/review`,
|
||||||
|
{
|
||||||
|
status
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug, envSlug, requestedBy }) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy)
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries(accessApprovalKeys.getAccessApprovalRequestCount(projectSlug));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { PackRule, unpackRules } from "@casl/ability/extra";
|
||||||
|
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
|
import { TProjectPermission } from "../roles/types";
|
||||||
|
import {
|
||||||
|
TAccessApprovalPolicy,
|
||||||
|
TAccessApprovalRequest,
|
||||||
|
TAccessRequestCount,
|
||||||
|
TGetAccessApprovalRequestsDTO,
|
||||||
|
TGetAccessPolicyApprovalCountDTO
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const accessApprovalKeys = {
|
||||||
|
getAccessApprovalPolicies: (projectSlug: string) =>
|
||||||
|
[{ projectSlug }, "access-approval-policies"] as const,
|
||||||
|
getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) =>
|
||||||
|
[{ workspaceId, environment }, "access-approval-policy"] as const,
|
||||||
|
|
||||||
|
getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) =>
|
||||||
|
[{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const,
|
||||||
|
getAccessApprovalRequestCount: (projectSlug: string) =>
|
||||||
|
[{ projectSlug }, "access-approval-request-count"] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchPolicyApprovalCount = async ({
|
||||||
|
projectSlug,
|
||||||
|
envSlug
|
||||||
|
}: TGetAccessPolicyApprovalCountDTO) => {
|
||||||
|
const { data } = await apiRequest.get<{ count: number }>(
|
||||||
|
"/api/v1/access-approvals/policies/count",
|
||||||
|
{
|
||||||
|
params: { projectSlug, envSlug }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetAccessPolicyApprovalCount = ({
|
||||||
|
projectSlug,
|
||||||
|
envSlug,
|
||||||
|
options = {}
|
||||||
|
}: TGetAccessPolicyApprovalCountDTO & {
|
||||||
|
options?: UseQueryOptions<
|
||||||
|
number,
|
||||||
|
unknown,
|
||||||
|
number,
|
||||||
|
ReturnType<typeof accessApprovalKeys.getAccessApprovalPolicies>
|
||||||
|
>;
|
||||||
|
}) =>
|
||||||
|
useQuery({
|
||||||
|
queryFn: () => fetchPolicyApprovalCount({ projectSlug, envSlug }),
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequestsDTO) => {
|
||||||
|
const { data } = await apiRequest.get<{ approvals: TAccessApprovalPolicy[] }>(
|
||||||
|
"/api/v1/access-approvals/policies",
|
||||||
|
{ params: { projectSlug } }
|
||||||
|
);
|
||||||
|
return data.approvals;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchApprovalRequests = async ({
|
||||||
|
projectSlug,
|
||||||
|
envSlug,
|
||||||
|
authorProjectMembershipId
|
||||||
|
}: TGetAccessApprovalRequestsDTO) => {
|
||||||
|
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
|
||||||
|
"/api/v1/access-approvals/requests",
|
||||||
|
{ params: { projectSlug, envSlug, authorProjectMembershipId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.requests.map((request) => ({
|
||||||
|
...request,
|
||||||
|
|
||||||
|
privilege: request.privilege
|
||||||
|
? {
|
||||||
|
...request.privilege,
|
||||||
|
permissions: unpackRules(
|
||||||
|
request.privilege.permissions as unknown as PackRule<TProjectPermission>[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
permissions: unpackRules(request.permissions as unknown as PackRule<TProjectPermission>[])
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAccessRequestsCount = async (projectSlug: string) => {
|
||||||
|
const { data } = await apiRequest.get<TAccessRequestCount>(
|
||||||
|
"/api/v1/access-approvals/requests/count",
|
||||||
|
{ params: { projectSlug } }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetAccessRequestsCount = ({
|
||||||
|
projectSlug,
|
||||||
|
options = {}
|
||||||
|
}: TGetAccessApprovalRequestsDTO & {
|
||||||
|
options?: UseQueryOptions<
|
||||||
|
TAccessRequestCount,
|
||||||
|
unknown,
|
||||||
|
{ pendingCount: number; finalizedCount: number },
|
||||||
|
ReturnType<typeof accessApprovalKeys.getAccessApprovalRequestCount>
|
||||||
|
>;
|
||||||
|
}) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug),
|
||||||
|
queryFn: () => fetchAccessRequestsCount(projectSlug),
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useGetAccessApprovalPolicies = ({
|
||||||
|
projectSlug,
|
||||||
|
envSlug,
|
||||||
|
authorProjectMembershipId,
|
||||||
|
options = {}
|
||||||
|
}: TGetAccessApprovalRequestsDTO & {
|
||||||
|
options?: UseQueryOptions<
|
||||||
|
TAccessApprovalPolicy[],
|
||||||
|
unknown,
|
||||||
|
TAccessApprovalPolicy[],
|
||||||
|
ReturnType<typeof accessApprovalKeys.getAccessApprovalPolicies>
|
||||||
|
>;
|
||||||
|
}) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
|
||||||
|
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }),
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useGetAccessApprovalRequests = ({
|
||||||
|
projectSlug,
|
||||||
|
envSlug,
|
||||||
|
authorProjectMembershipId,
|
||||||
|
options = {}
|
||||||
|
}: TGetAccessApprovalRequestsDTO & {
|
||||||
|
options?: UseQueryOptions<
|
||||||
|
TAccessApprovalRequest[],
|
||||||
|
unknown,
|
||||||
|
TAccessApprovalRequest[],
|
||||||
|
ReturnType<typeof accessApprovalKeys.getAccessApprovalRequests>
|
||||||
|
>;
|
||||||
|
}) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||||
|
projectSlug,
|
||||||
|
envSlug,
|
||||||
|
authorProjectMembershipId
|
||||||
|
),
|
||||||
|
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
|
||||||
|
...options,
|
||||||
|
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { TProjectPermission } from "../roles/types";
|
||||||
|
import { WorkspaceEnv } from "../workspace/types";
|
||||||
|
|
||||||
|
export type TAccessApprovalPolicy = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
approvals: number;
|
||||||
|
secretPath: string;
|
||||||
|
envId: string;
|
||||||
|
workspace: string;
|
||||||
|
environment: WorkspaceEnv;
|
||||||
|
projectId: string;
|
||||||
|
approvers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAccessApprovalRequest = {
|
||||||
|
id: string;
|
||||||
|
policyId: string;
|
||||||
|
privilegeId: string | null;
|
||||||
|
requestedBy: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
isTemporary: boolean;
|
||||||
|
temporaryRange: string | null | undefined;
|
||||||
|
|
||||||
|
permissions: TProjectPermission[] | null;
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
environmentName: string;
|
||||||
|
isApproved: boolean;
|
||||||
|
|
||||||
|
privilege: {
|
||||||
|
membershipId: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
temporaryMode?: string | null;
|
||||||
|
temporaryRange?: string | null;
|
||||||
|
temporaryAccessStartTime?: Date | null;
|
||||||
|
temporaryAccessEndTime?: Date | null;
|
||||||
|
permissions: TProjectPermission[];
|
||||||
|
isApproved: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
policy: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
approvals: number;
|
||||||
|
approvers: string[];
|
||||||
|
secretPath?: string | null;
|
||||||
|
envId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
reviewers: {
|
||||||
|
member: string;
|
||||||
|
status: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAccessApproval = {
|
||||||
|
id: string;
|
||||||
|
policyId: string;
|
||||||
|
privilegeId: string;
|
||||||
|
requestedBy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAccessRequestCount = {
|
||||||
|
pendingCount: number;
|
||||||
|
finalizedCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TProjectUserPrivilege = {
|
||||||
|
projectMembershipId: string;
|
||||||
|
slug: string;
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
permissions?: TProjectPermission[];
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
isTemporary: true;
|
||||||
|
temporaryMode: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
temporaryAccessStartTime: string;
|
||||||
|
temporaryAccessEndTime?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isTemporary: false;
|
||||||
|
temporaryMode?: null;
|
||||||
|
temporaryRange?: null;
|
||||||
|
temporaryAccessStartTime?: null;
|
||||||
|
temporaryAccessEndTime?: null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TCreateAccessRequestDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
||||||
|
|
||||||
|
export type TGetAccessApprovalRequestsDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
envSlug?: string;
|
||||||
|
authorProjectMembershipId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGetAccessPolicyApprovalCountDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
envSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGetSecretApprovalPolicyOfBoardDTO = {
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateAccessPolicyDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
name?: string;
|
||||||
|
environment: string;
|
||||||
|
approvers?: string[];
|
||||||
|
approvals?: number;
|
||||||
|
secretPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateAccessPolicyDTO = {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
approvers?: string[];
|
||||||
|
secretPath?: string;
|
||||||
|
environment?: string;
|
||||||
|
approvals?: number;
|
||||||
|
// for invalidating list
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDeleteSecretPolicyDTO = {
|
||||||
|
id: string;
|
||||||
|
// for invalidating list
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./accessApproval";
|
||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
export * from "./apiKeys";
|
export * from "./apiKeys";
|
||||||
export * from "./auditLogs";
|
export * from "./auditLogs";
|
||||||
|
|||||||
@@ -94,7 +94,21 @@ export const useGetFoldersByEnv = ({
|
|||||||
[(folders || []).map((folder) => folder.data)]
|
[(folders || []).map((folder) => folder.data)]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { folders, folderNames, isFolderPresentInEnv };
|
const getFolderByNameAndEnv = useCallback(
|
||||||
|
(name: string, env: string) => {
|
||||||
|
const selectedEnvIndex = environments.indexOf(env);
|
||||||
|
if (selectedEnvIndex !== -1) {
|
||||||
|
return folders?.[selectedEnvIndex]?.data?.find(
|
||||||
|
({ name: folderName }) => folderName === name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[(folders || []).map((folder) => folder.data)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateFolder = () => {
|
export const useCreateFolder = () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ZodIssue } from "zod";
|
import { ZodIssue } from "zod";
|
||||||
|
|
||||||
|
export type { TAccessApprovalPolicy } from "./accessApproval/types";
|
||||||
export type { TAuditLogStream } from "./auditLogStreams/types";
|
export type { TAuditLogStream } from "./auditLogStreams/types";
|
||||||
export type { GetAuthTokenAPI } from "./auth/types";
|
export type { GetAuthTokenAPI } from "./auth/types";
|
||||||
export type { IncidentContact } from "./incidentContacts/types";
|
export type { IncidentContact } from "./incidentContacts/types";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
/* eslint-disable no-var */
|
/* eslint-disable no-var */
|
||||||
/* eslint-disable func-names */
|
/* eslint-disable func-names */
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
fetchOrgUsers,
|
fetchOrgUsers,
|
||||||
useAddUserToWsNonE2EE,
|
useAddUserToWsNonE2EE,
|
||||||
useCreateWorkspace,
|
useCreateWorkspace,
|
||||||
|
useGetAccessRequestsCount,
|
||||||
useGetOrgTrialUrl,
|
useGetOrgTrialUrl,
|
||||||
useGetSecretApprovalRequestCount,
|
useGetSecretApprovalRequestCount,
|
||||||
useGetUserAction,
|
useGetUserAction,
|
||||||
@@ -124,9 +125,15 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
const { data: updateClosed } = useGetUserAction("december_update_closed");
|
const { data: updateClosed } = useGetUserAction("december_update_closed");
|
||||||
|
|
||||||
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId });
|
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId });
|
||||||
|
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
|
||||||
|
|
||||||
|
const pendingRequestsCount = useMemo(() => {
|
||||||
|
return (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0);
|
||||||
|
}, [secretApprovalReqCount, accessApprovalRequestCount]);
|
||||||
|
|
||||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||||
@@ -554,10 +561,13 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
|||||||
}
|
}
|
||||||
icon="system-outline-189-domain-verification"
|
icon="system-outline-189-domain-verification"
|
||||||
>
|
>
|
||||||
Secret Approvals
|
Approvals
|
||||||
{Boolean(secretApprovalReqCount?.open) && (
|
{Boolean(
|
||||||
|
secretApprovalReqCount?.open ||
|
||||||
|
accessApprovalRequestCount?.pendingCount
|
||||||
|
) && (
|
||||||
<span className="ml-2 rounded border border-primary-400 bg-primary-600 py-0.5 px-1 text-xs font-semibold text-black">
|
<span className="ml-2 rounded border border-primary-400 bg-primary-600 py-0.5 px-1 text-xs font-semibold text-black">
|
||||||
{secretApprovalReqCount?.open}
|
{pendingRequestsCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export default function LoginPage() {
|
|||||||
const selectOrg = useSelectOrganization();
|
const selectOrg = useSelectOrganization();
|
||||||
const { user, isLoading: userLoading } = useUser();
|
const { user, isLoading: userLoading } = useUser();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams(window.location.search);
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
const logout = useLogoutUser(true);
|
const logout = useLogoutUser(true);
|
||||||
@@ -153,7 +151,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-md text-center text-gray-500">
|
<p className="text-md text-center text-gray-500">
|
||||||
You‘re currently logged in as <strong>{user.email}</strong>
|
You‘re currently logged in as <strong>{user.username}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-md text-center text-gray-500">
|
<p className="text-md text-center text-gray-500">
|
||||||
Not you?{" "}
|
Not you?{" "}
|
||||||
|
|||||||
+204
-23
@@ -1,9 +1,11 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import {
|
import {
|
||||||
faArrowRotateLeft,
|
faArrowRotateLeft,
|
||||||
faCaretDown,
|
faCaretDown,
|
||||||
faCheck,
|
faCheck,
|
||||||
faClock,
|
faClock,
|
||||||
|
faLockOpen,
|
||||||
faPlus,
|
faPlus,
|
||||||
faTrash
|
faTrash
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
@@ -44,11 +46,13 @@ import {
|
|||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import {
|
import {
|
||||||
TProjectUserPrivilege,
|
TProjectUserPrivilege,
|
||||||
|
useCreateAccessRequest,
|
||||||
useCreateProjectUserAdditionalPrivilege,
|
useCreateProjectUserAdditionalPrivilege,
|
||||||
useDeleteProjectUserAdditionalPrivilege,
|
useDeleteProjectUserAdditionalPrivilege,
|
||||||
useListProjectUserPrivileges,
|
useListProjectUserPrivileges,
|
||||||
useUpdateProjectUserAdditionalPrivilege
|
useUpdateProjectUserAdditionalPrivilege
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
|
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||||
|
|
||||||
const secretPermissionSchema = z.object({
|
const secretPermissionSchema = z.object({
|
||||||
secretPath: z.string().optional(),
|
secretPath: z.string().optional(),
|
||||||
@@ -70,23 +74,34 @@ const secretPermissionSchema = z.object({
|
|||||||
])
|
])
|
||||||
});
|
});
|
||||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||||
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
|
export const SpecificPrivilegeSecretForm = ({
|
||||||
|
privilege,
|
||||||
|
policies,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
privilege?: TProjectUserPrivilege;
|
||||||
|
policies?: TAccessApprovalPolicy[];
|
||||||
|
onClose?: () => void;
|
||||||
|
}) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||||
"deletePrivilege"
|
"deletePrivilege",
|
||||||
|
"requestAccess"
|
||||||
] as const);
|
] as const);
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const isMemberEditDisabled = permission.cannot(
|
const isMemberEditDisabled =
|
||||||
ProjectPermissionActions.Edit,
|
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
|
||||||
ProjectPermissionSub.Member
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
|
||||||
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
||||||
|
const requestAccess = useCreateAccessRequest();
|
||||||
|
|
||||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||||
resolver: zodResolver(secretPermissionSchema),
|
resolver: zodResolver(secretPermissionSchema),
|
||||||
values: {
|
values: {
|
||||||
|
...(privilege
|
||||||
|
? {
|
||||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||||
// secret path will be inside $glob operator
|
// secret path will be inside $glob operator
|
||||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
|
||||||
@@ -105,16 +120,59 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
// zod will pick it
|
// zod will pick it
|
||||||
temporaryAccess: privilege
|
temporaryAccess: privilege
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
environmentSlug: currentWorkspace?.environments?.[0].slug!,
|
||||||
|
read: false,
|
||||||
|
edit: false,
|
||||||
|
create: false,
|
||||||
|
delete: false,
|
||||||
|
temporaryAccess: {
|
||||||
|
isTemporary: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||||
const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug");
|
const selectedEnvironment = privilegeForm.watch("environmentSlug");
|
||||||
|
const secretPath = privilegeForm.watch("secretPath");
|
||||||
|
|
||||||
|
const readAccess = privilegeForm.watch("read");
|
||||||
|
const createAccess = privilegeForm.watch("create");
|
||||||
|
const editAccess = privilegeForm.watch("edit");
|
||||||
|
const deleteAccess = privilegeForm.watch("delete");
|
||||||
|
|
||||||
|
const accessSelected = readAccess || createAccess || editAccess || deleteAccess;
|
||||||
|
|
||||||
|
const selectablePaths = useMemo(() => {
|
||||||
|
if (!policies) return [];
|
||||||
|
const environmentPolicies = policies.filter(
|
||||||
|
(policy) => policy.environment.slug === selectedEnvironment
|
||||||
|
);
|
||||||
|
|
||||||
|
privilegeForm.setValue("secretPath", "", {
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...environmentPolicies.map((policy) => policy.secretPath)];
|
||||||
|
}, [policies, selectedEnvironment]);
|
||||||
|
|
||||||
const isTemporary = temporaryAccessField?.isTemporary;
|
const isTemporary = temporaryAccessField?.isTemporary;
|
||||||
const isExpired =
|
const isExpired =
|
||||||
temporaryAccessField.isTemporary &&
|
temporaryAccessField.isTemporary &&
|
||||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||||
|
|
||||||
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
|
||||||
|
if (!privilege) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "No privilege to update found.",
|
||||||
|
title: "Error"
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (updateUserPrivilege.isLoading) return;
|
if (updateUserPrivilege.isLoading) return;
|
||||||
try {
|
try {
|
||||||
const actions = [
|
const actions = [
|
||||||
@@ -152,6 +210,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePrivilege = async () => {
|
const handleDeletePrivilege = async () => {
|
||||||
|
if (!privilege) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "No privilege to delete found.",
|
||||||
|
title: "Error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deleteUserPrivilege.isLoading) return;
|
if (deleteUserPrivilege.isLoading) return;
|
||||||
try {
|
try {
|
||||||
await deleteUserPrivilege.mutateAsync({
|
await deleteUserPrivilege.mutateAsync({
|
||||||
@@ -170,35 +237,100 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This is used for requesting access additional privileges, not directly creating a privilege!
|
||||||
|
const handleRequestAccess = async (data: TSecretPermissionForm) => {
|
||||||
|
if (!policies) return;
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "No workspace found.",
|
||||||
|
title: "Error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.secretPath) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Please select a secret path",
|
||||||
|
title: "Error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||||
|
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||||
|
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||||
|
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||||
|
];
|
||||||
|
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||||
|
if (data.secretPath) {
|
||||||
|
conditions.secretPath = { $glob: data.secretPath };
|
||||||
|
}
|
||||||
|
await requestAccess.mutateAsync({
|
||||||
|
...data,
|
||||||
|
...(data.temporaryAccess.isTemporary && {
|
||||||
|
temporaryRange: data.temporaryAccess.temporaryRange
|
||||||
|
}),
|
||||||
|
projectSlug: currentWorkspace.slug,
|
||||||
|
isTemporary: data.temporaryAccess.isTemporary,
|
||||||
|
permissions: actions
|
||||||
|
.filter(({ allowed }) => allowed)
|
||||||
|
.map(({ action }) => ({
|
||||||
|
action,
|
||||||
|
subject: [ProjectPermissionSub.Secrets],
|
||||||
|
conditions
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully requested access"
|
||||||
|
});
|
||||||
|
privilegeForm.reset();
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (data: TSecretPermissionForm) => {
|
||||||
|
if (privilege) {
|
||||||
|
handleUpdatePrivilege(data);
|
||||||
|
} else {
|
||||||
|
handleRequestAccess(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getAccessLabel = (exactTime = false) => {
|
const getAccessLabel = (exactTime = false) => {
|
||||||
if (isExpired) return "Access expired";
|
if (isExpired) return "Access expired";
|
||||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||||
if (exactTime)
|
|
||||||
|
if (exactTime && !policies) {
|
||||||
return `Until ${format(
|
return `Until ${format(
|
||||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||||
"yyyy-MM-dd HH:mm:ss"
|
"yyyy-MM-dd HH:mm:ss"
|
||||||
)}`;
|
)}`;
|
||||||
|
}
|
||||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4">
|
<div className="mt-4 w-full">
|
||||||
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
|
<form onSubmit={privilegeForm.handleSubmit(handleSubmit)}>
|
||||||
<div className="flex items-start space-x-4">
|
<div className={twMerge("flex items-start gap-4", !privilege && "flex-wrap")}>
|
||||||
<Controller
|
<Controller
|
||||||
control={privilegeForm.control}
|
control={privilegeForm.control}
|
||||||
name="environmentSlug"
|
name="environmentSlug"
|
||||||
render={({ field: { onChange, ...field } }) => (
|
render={({ field: { onChange, ...field } }) => (
|
||||||
<FormControl label="Env">
|
<FormControl label="Environment">
|
||||||
<Select
|
<Select
|
||||||
{...field}
|
{...field}
|
||||||
isDisabled={isMemberEditDisabled}
|
isDisabled={isMemberEditDisabled}
|
||||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||||
onValueChange={(e) => onChange(e)}
|
onValueChange={(e) => onChange(e)}
|
||||||
>
|
>
|
||||||
{currentWorkspace?.environments?.map(({ slug, id }) => (
|
{currentWorkspace?.environments?.map(({ slug, id, name }) => (
|
||||||
<SelectItem value={slug} key={id}>
|
<SelectItem value={slug} key={id}>
|
||||||
{slug}
|
{name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -208,16 +340,43 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
<Controller
|
<Controller
|
||||||
control={privilegeForm.control}
|
control={privilegeForm.control}
|
||||||
name="secretPath"
|
name="secretPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
|
if (policies) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
isDisabled={!!selectablePaths.length}
|
||||||
|
content="The selected environment doesn't have any policies."
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<FormControl label="Secret Path">
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
isDisabled={isMemberEditDisabled || !selectablePaths.length}
|
||||||
|
className="w-48"
|
||||||
|
onValueChange={(e) => field.onChange(e)}
|
||||||
|
>
|
||||||
|
{selectablePaths.map((path) => (
|
||||||
|
<SelectItem value={path} key={path}>
|
||||||
|
{path}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<FormControl label="Secret Path">
|
<FormControl label="Secret Path">
|
||||||
<SecretPathInput
|
<SecretPathInput
|
||||||
{...field}
|
{...field}
|
||||||
isDisabled={isMemberEditDisabled}
|
isDisabled={isMemberEditDisabled}
|
||||||
containerClassName="w-48"
|
containerClassName="w-48"
|
||||||
environment={selectedEnvironmentSlug}
|
environment={selectedEnvironment}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-grow justify-between">
|
<div className="flex flex-grow justify-between">
|
||||||
<Controller
|
<Controller
|
||||||
@@ -285,7 +444,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-7 flex items-center space-x-2">
|
<div className="mt-6 flex items-center space-x-2">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||||
<div>
|
<div>
|
||||||
@@ -301,7 +460,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
isExpired && "text-red-600"
|
isExpired && "text-red-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getAccessLabel()}
|
{getAccessLabel(false)}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,8 +521,9 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
|
{temporaryAccessField.isTemporary && !policies ? "Restart" : "Grant"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{temporaryAccessField.isTemporary && (
|
{temporaryAccessField.isTemporary && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -375,14 +535,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Revoke Access
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{privilegeForm.formState.isDirty ? (
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
|
{privilegeForm.formState.isDirty && privilege ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip content="Cancel" className="mr-4">
|
<Tooltip content="Cancel" className="mr-4">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -413,7 +574,8 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : // eslint-disable-next-line no-nested-ternary
|
||||||
|
privilege ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||||
className="mr-4"
|
className="mr-4"
|
||||||
@@ -428,9 +590,28 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
|
|||||||
<FontAwesomeIcon icon={faTrash} />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!!policies && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isLoading}
|
||||||
|
isDisabled={
|
||||||
|
isMemberEditDisabled ||
|
||||||
|
!policies.length ||
|
||||||
|
!privilegeForm.formState.isValid ||
|
||||||
|
!secretPath ||
|
||||||
|
!accessSelected
|
||||||
|
}
|
||||||
|
className="mt-4"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||||
|
>
|
||||||
|
Request access
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deletePrivilege.isOpen}
|
isOpen={popUp.deletePrivilege.isOpen}
|
||||||
|
|||||||
@@ -3,25 +3,31 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
|
import { Divider } from "@app/components/v2/Divider";
|
||||||
import { useWorkspace } from "@app/context";
|
import { useWorkspace } from "@app/context";
|
||||||
|
|
||||||
|
import { AccessApprovalPolicyList } from "./components/AccessApprovalPolicyList";
|
||||||
|
import { AccessApprovalRequest } from "./components/AccessApprovalRequest";
|
||||||
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
import { SecretApprovalPolicyList } from "./components/SecretApprovalPolicyList";
|
||||||
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
import { SecretApprovalRequest } from "./components/SecretApprovalRequest";
|
||||||
|
|
||||||
enum TabSection {
|
enum TabSection {
|
||||||
ApprovalRequests = "approval-requests",
|
SecretApprovalRequests = "approval-requests",
|
||||||
Rules = "approval-rules"
|
SecretPolicies = "approval-rules",
|
||||||
|
ResourcePolicies = "resource-rules",
|
||||||
|
ResourceApprovalRequests = "resource-requests"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecretApprovalPage = () => {
|
export const SecretApprovalPage = () => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const projectId = currentWorkspace?.id || "";
|
||||||
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
||||||
<div className="flex items-center justify-between py-6">
|
<div className="flex items-center justify-between py-6">
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<h2 className="text-3xl font-semibold text-gray-200">Secret Approval Workflows</h2>
|
<h2 className="text-3xl font-semibold text-gray-200">Approval Workflows</h2>
|
||||||
<p className="text-bunker-300">
|
<p className="text-bunker-300">
|
||||||
Create approval policies for any modifications to secrets in sensitive environments and
|
Create approval policies for any modifications to secrets in sensitive environments and
|
||||||
folders.
|
folders.
|
||||||
@@ -39,16 +45,25 @@ export const SecretApprovalPage = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue={TabSection.ApprovalRequests}>
|
<Tabs defaultValue={TabSection.SecretApprovalRequests}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={TabSection.ApprovalRequests}>Secret PRs</Tab>
|
<Tab value={TabSection.SecretApprovalRequests}>Secret Requests</Tab>
|
||||||
<Tab value={TabSection.Rules}>Policies</Tab>
|
<Tab value={TabSection.SecretPolicies}>Secret Policies</Tab>
|
||||||
|
<Divider />
|
||||||
|
<Tab value={TabSection.ResourceApprovalRequests}>Access Requests</Tab>
|
||||||
|
<Tab value={TabSection.ResourcePolicies}>Access Request Policies</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={TabSection.ApprovalRequests}>
|
<TabPanel value={TabSection.SecretPolicies}>
|
||||||
|
<SecretApprovalPolicyList workspaceId={projectId} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value={TabSection.SecretApprovalRequests}>
|
||||||
<SecretApprovalRequest />
|
<SecretApprovalRequest />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel value={TabSection.Rules}>
|
<TabPanel value={TabSection.ResourceApprovalRequests}>
|
||||||
<SecretApprovalPolicyList workspaceId={workspaceId} />
|
<AccessApprovalRequest projectId={projectId} projectSlug={projectSlug} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value={TabSection.ResourcePolicies}>
|
||||||
|
<AccessApprovalPolicyList workspaceId={projectId} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-end justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xl font-semibold text-mineshaft-100">Access Request Policies</span>
|
||||||
|
<div className="mt-2 text-sm text-bunker-300">
|
||||||
|
Implement secret request policies for specific secrets and environments.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Create}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription && !subscription?.secretApproval) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("secretPolicyForm");
|
||||||
|
}}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Create policy
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Environment</Th>
|
||||||
|
<Th>Secret Path</Th>
|
||||||
|
<Th>Eligible Approvers</Th>
|
||||||
|
<Th>Approval Required</Th>
|
||||||
|
<Th />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isPoliciesLoading && (
|
||||||
|
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
|
||||||
|
)}
|
||||||
|
{!isPoliciesLoading && !policies?.length && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={6}>
|
||||||
|
<EmptyState title="No policies found" icon={faFileShield} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
{!!currentWorkspace &&
|
||||||
|
policies?.map((policy) => (
|
||||||
|
<AccessApprovalPolicyRow
|
||||||
|
projectSlug={currentWorkspace.slug}
|
||||||
|
policy={policy}
|
||||||
|
key={policy.id}
|
||||||
|
members={members}
|
||||||
|
onEdit={() => handlePopUpOpen("secretPolicyForm", policy)}
|
||||||
|
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<AccessPolicyForm
|
||||||
|
projectSlug={currentWorkspace?.slug!}
|
||||||
|
isOpen={popUp.secretPolicyForm.isOpen}
|
||||||
|
onToggle={(isOpen) => handlePopUpToggle("secretPolicyForm", isOpen)}
|
||||||
|
members={members}
|
||||||
|
editValues={popUp.secretPolicyForm.data as TAccessApprovalPolicy}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deletePolicy.isOpen}
|
||||||
|
deleteKey="remove"
|
||||||
|
title="Do you want to remove this policy?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||||
|
onDeleteApproved={handleDeletePolicy}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
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<string[]>([]);
|
||||||
|
const { mutate: updateAccessApprovalPolicy, isLoading } = useUpdateAccessApprovalPolicy();
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td>{policy.name}</Td>
|
||||||
|
<Td>{policy.environment.slug}</Td>
|
||||||
|
<Td>{policy.secretPath || "*"}</Td>
|
||||||
|
<Td>
|
||||||
|
<DropdownMenu
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
updateAccessApprovalPolicy(
|
||||||
|
{
|
||||||
|
projectSlug,
|
||||||
|
id: policy.id,
|
||||||
|
approvers: selectedApprovers
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
setSelectedApprovers([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedApprovers(policy.approvers);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
isReadOnly
|
||||||
|
value={policy.approvers?.length ? `${policy.approvers.length} selected` : "None"}
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Select members that are allowed to approve changes
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{members?.map(({ id, user }) => {
|
||||||
|
const isChecked = selectedApprovers.includes(id);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setSelectedApprovers((state) =>
|
||||||
|
isChecked ? state.filter((el) => el !== id) : [...state, id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
key={`create-policy-members-${id}`}
|
||||||
|
iconPos="right"
|
||||||
|
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Td>
|
||||||
|
<Td>{policy.approvals}</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex items-center justify-end space-x-4">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Edit"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton variant="plain" ariaLabel="edit" onClick={onEdit} isDisabled={!isAllowed}>
|
||||||
|
<FontAwesomeIcon icon={faPencil} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.SecretApproval}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Delete"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
size="lg"
|
||||||
|
ariaLabel="edit"
|
||||||
|
onClick={onDelete}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const AccessPolicyForm = ({
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
members = [],
|
||||||
|
projectSlug,
|
||||||
|
editValues
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<TFormSchema>({
|
||||||
|
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 (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||||
|
<ModalContent title={isEditMode ? "Edit Access Policy" : "Create Access Policy"}>
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Policy Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="environment"
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment"
|
||||||
|
isRequired
|
||||||
|
className="mt-4"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
isDisabled={isEditMode}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(val)}
|
||||||
|
className="w-full border border-mineshaft-500"
|
||||||
|
>
|
||||||
|
{environments.map((sourceEnvironment) => (
|
||||||
|
<SelectItem
|
||||||
|
value={sourceEnvironment.slug}
|
||||||
|
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
|
||||||
|
>
|
||||||
|
{sourceEnvironment.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="secretPath"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} value={field.value || ""} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="approvers"
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Approvers Required"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Input
|
||||||
|
isReadOnly
|
||||||
|
value={value?.length ? `${value.length} selected` : "None"}
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Select members that are allowed to approve changes
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{members.map(({ id, user }) => {
|
||||||
|
const isChecked = value?.includes(id);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
onChange(
|
||||||
|
isChecked ? value?.filter((el) => el !== id) : [...(value || []), id]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
key={`create-policy-members-${id}`}
|
||||||
|
iconPos="right"
|
||||||
|
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="approvals"
|
||||||
|
defaultValue={1}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Approvals Required"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onToggle(false)} variant="outline_bg">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AccessApprovalPolicyList } from "./AccessApprovalPolicyList";
|
||||||
+436
@@ -0,0 +1,436 @@
|
|||||||
|
/* eslint-disable no-nested-ternary */
|
||||||
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faCheckCircle,
|
||||||
|
faChevronDown,
|
||||||
|
faLock,
|
||||||
|
faPlus
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { formatDistance } from "date-fns";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
Tooltip,
|
||||||
|
UpgradePlanModal
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useSubscription,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||||
|
import {
|
||||||
|
accessApprovalKeys,
|
||||||
|
useGetAccessApprovalPolicies,
|
||||||
|
useGetAccessApprovalRequests,
|
||||||
|
useGetAccessRequestsCount
|
||||||
|
} from "@app/hooks/api/accessApproval/queries";
|
||||||
|
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||||
|
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
|
import { queryClient } from "@app/reactQuery";
|
||||||
|
|
||||||
|
import { RequestAccessModal } from "./components/RequestAccessModal";
|
||||||
|
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
|
||||||
|
|
||||||
|
const generateRequestText = (request: TAccessApprovalRequest, membershipId: string) => {
|
||||||
|
const { isTemporary } = request;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
|
||||||
|
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||||
|
{request.policy.secretPath}
|
||||||
|
</code>
|
||||||
|
in
|
||||||
|
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||||
|
{request.environmentName}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{request.requestedBy === membershipId && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
<Badge className="ml-1">Requested By You</Badge>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccessApprovalRequest = ({
|
||||||
|
projectSlug,
|
||||||
|
projectId
|
||||||
|
}: {
|
||||||
|
projectSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
}) => {
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<
|
||||||
|
(TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null }) | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([
|
||||||
|
"requestAccess",
|
||||||
|
"reviewRequest",
|
||||||
|
"upgradePlan"
|
||||||
|
] as const);
|
||||||
|
const { membership, permission } = useProjectPermission();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const { data: members } = useGetWorkspaceUsers(projectId);
|
||||||
|
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
||||||
|
(prev, curr) => ({ ...prev, [curr.id]: curr }),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
|
||||||
|
const [requestedByFilter, setRequestedByFilter] = useState<string | undefined>(undefined);
|
||||||
|
const [envFilter, setEnvFilter] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { data: requestCount } = useGetAccessRequestsCount({
|
||||||
|
projectSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: policies, isLoading: policiesLoading } = useGetAccessApprovalPolicies({
|
||||||
|
projectSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: requests } = useGetAccessApprovalRequests({
|
||||||
|
projectSlug,
|
||||||
|
authorProjectMembershipId: requestedByFilter,
|
||||||
|
envSlug: envFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredRequests = useMemo(() => {
|
||||||
|
if (statusFilter === "open")
|
||||||
|
return requests?.filter(
|
||||||
|
(request) =>
|
||||||
|
!request.isApproved &&
|
||||||
|
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||||
|
);
|
||||||
|
if (statusFilter === "close")
|
||||||
|
return requests?.filter(
|
||||||
|
(request) =>
|
||||||
|
request.isApproved ||
|
||||||
|
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||||
|
);
|
||||||
|
|
||||||
|
return requests;
|
||||||
|
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||||
|
|
||||||
|
const generateRequestDetails = (request: TAccessApprovalRequest) => {
|
||||||
|
const isReviewedByUser =
|
||||||
|
request.reviewers.findIndex(({ member }) => member === membership.id) !== -1;
|
||||||
|
const isRejectedByAnyone = request.reviewers.some(
|
||||||
|
({ status }) => status === ApprovalStatus.REJECTED
|
||||||
|
);
|
||||||
|
const isApprover = request.policy.approvers.indexOf(membership.id || "") !== -1;
|
||||||
|
const isAccepted = request.isApproved;
|
||||||
|
|
||||||
|
const userReviewStatus = request.reviewers.find(
|
||||||
|
({ member }) => member === membership.id
|
||||||
|
)?.status;
|
||||||
|
|
||||||
|
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
|
||||||
|
label: "",
|
||||||
|
type: "primary"
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired =
|
||||||
|
request.privilege &&
|
||||||
|
request.isApproved &&
|
||||||
|
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
|
||||||
|
|
||||||
|
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
|
||||||
|
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
|
||||||
|
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
|
||||||
|
else if (userReviewStatus === ApprovalStatus.APPROVED) {
|
||||||
|
displayData = {
|
||||||
|
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
|
||||||
|
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
|
||||||
|
}`,
|
||||||
|
type: "primary"
|
||||||
|
};
|
||||||
|
} else if (!isReviewedByUser)
|
||||||
|
displayData = {
|
||||||
|
label: "Review Required",
|
||||||
|
type: "primary"
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayData,
|
||||||
|
isReviewedByUser,
|
||||||
|
isRejectedByAnyone,
|
||||||
|
isApprover,
|
||||||
|
userReviewStatus,
|
||||||
|
isAccepted
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-end justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span>
|
||||||
|
<div className="mt-2 text-sm text-bunker-300">
|
||||||
|
Request access to secrets in sensitive environments and folders.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Tooltip
|
||||||
|
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||||
|
isDisabled={policiesLoading || !!policies?.length}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription && !subscription?.secretApproval) {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("requestAccess");
|
||||||
|
}}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
isDisabled={policiesLoading || !policies?.length}
|
||||||
|
>
|
||||||
|
Request access
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
key="approval-changes-list"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
|
className="rounded-md text-gray-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setStatusFilter("open")}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter") setStatusFilter("open");
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||||
|
{!!requestCount && requestCount?.pendingCount} Pending
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setStatusFilter("close")}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter") setStatusFilter("close");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||||
|
{!!requestCount && requestCount.finalizedCount} Completed
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-grow justify-end space-x-8">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="text-bunker-300"
|
||||||
|
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||||
|
>
|
||||||
|
Environments
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||||
|
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||||
|
key={`request-filter-${slug}`}
|
||||||
|
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className={requestedByFilter ? "text-white" : "text-bunker-300"}
|
||||||
|
rightIcon={
|
||||||
|
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Requested By
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||||
|
{members?.map(({ user, id }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
setRequestedByFilter((state) => (state === id ? undefined : id))
|
||||||
|
}
|
||||||
|
key={`request-filter-member-${id}`}
|
||||||
|
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col rounded-b-md border-x border-t border-b border-mineshaft-600 bg-mineshaft-800">
|
||||||
|
{filteredRequests?.length === 0 && (
|
||||||
|
<div className="py-12">
|
||||||
|
<EmptyState title="No more access requests pending." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!filteredRequests?.length &&
|
||||||
|
filteredRequests?.map((request) => {
|
||||||
|
const details = generateRequestDetails(request);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-disabled={
|
||||||
|
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
|
||||||
|
}
|
||||||
|
key={request.id}
|
||||||
|
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
!details.isApprover ||
|
||||||
|
details.isReviewedByUser ||
|
||||||
|
details.isRejectedByAnyone ||
|
||||||
|
details.isAccepted
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setSelectedRequest({
|
||||||
|
...request,
|
||||||
|
user: membersGroupById?.[request.requestedBy].user!
|
||||||
|
});
|
||||||
|
handlePopUpOpen("reviewRequest");
|
||||||
|
}}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (
|
||||||
|
!details.isApprover ||
|
||||||
|
details.isAccepted ||
|
||||||
|
details.isReviewedByUser ||
|
||||||
|
details.isRejectedByAnyone
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (evt.key === "Enter") {
|
||||||
|
setSelectedRequest({
|
||||||
|
...request,
|
||||||
|
user: membersGroupById?.[request.requestedBy].user!
|
||||||
|
});
|
||||||
|
handlePopUpOpen("reviewRequest");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex w-full flex-col justify-between">
|
||||||
|
<div className="mb-1 flex w-full items-center">
|
||||||
|
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||||
|
{generateRequestText(request, membership.id)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{membersGroupById?.[request.requestedBy]?.user && (
|
||||||
|
<>
|
||||||
|
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
|
||||||
|
ago by {membersGroupById?.[request.requestedBy]?.user?.firstName}{" "}
|
||||||
|
{membersGroupById?.[request.requestedBy]?.user?.lastName} (
|
||||||
|
{membersGroupById?.[request.requestedBy]?.user?.email}){" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{details.isApprover && (
|
||||||
|
<Badge variant={details.displayData.type}>
|
||||||
|
{details.displayData.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{!!policies && (
|
||||||
|
<RequestAccessModal
|
||||||
|
policies={policies}
|
||||||
|
isOpen={popUp.requestAccess.isOpen}
|
||||||
|
onOpenChange={() => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
accessApprovalKeys.getAccessApprovalRequests(
|
||||||
|
projectSlug,
|
||||||
|
envFilter,
|
||||||
|
requestedByFilter
|
||||||
|
)
|
||||||
|
);
|
||||||
|
handlePopUpClose("requestAccess");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!selectedRequest && (
|
||||||
|
<ReviewAccessRequestModal
|
||||||
|
selectedEnvSlug={envFilter}
|
||||||
|
selectedRequester={requestedByFilter}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
request={selectedRequest}
|
||||||
|
isOpen={popUp.reviewRequest.isOpen}
|
||||||
|
onOpenChange={() => {
|
||||||
|
handlePopUpClose("reviewRequest");
|
||||||
|
setSelectedRequest(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<UpgradePlanModal
|
||||||
|
text="You need to upgrade your plan to access this feature"
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import { Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||||
|
import { SpecificPrivilegeSecretForm } from "@app/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection";
|
||||||
|
|
||||||
|
export const RequestAccessModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
policies
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
policies: TAccessApprovalPolicy[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
className="max-w-4xl"
|
||||||
|
title="Request Access"
|
||||||
|
subTitle="Your role has limited permissions, please contact your administrator to gain access"
|
||||||
|
>
|
||||||
|
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
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 { 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 { TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
export const ReviewAccessRequestModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
request,
|
||||||
|
projectSlug,
|
||||||
|
selectedRequester,
|
||||||
|
selectedEnvSlug
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
request: TAccessApprovalRequest & { user: TWorkspaceUser["user"] | null };
|
||||||
|
projectSlug: string;
|
||||||
|
selectedRequester: string | undefined;
|
||||||
|
selectedEnvSlug: string | undefined;
|
||||||
|
}) => {
|
||||||
|
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||||
|
|
||||||
|
const accessDetails = {
|
||||||
|
env: request.environmentName,
|
||||||
|
// secret path will be inside $glob operator
|
||||||
|
secretPath: request.policy.secretPath,
|
||||||
|
read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)),
|
||||||
|
edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)),
|
||||||
|
create: request.permissions?.some(({ action }) =>
|
||||||
|
action.includes(ProjectPermissionActions.Create)
|
||||||
|
),
|
||||||
|
delete: request.permissions?.some(({ action }) =>
|
||||||
|
action.includes(ProjectPermissionActions.Delete)
|
||||||
|
),
|
||||||
|
|
||||||
|
temporaryAccess: {
|
||||||
|
isTemporary: request.isTemporary,
|
||||||
|
temporaryRange: request.temporaryRange
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestedAccess = useMemo(() => {
|
||||||
|
const access: string[] = [];
|
||||||
|
if (accessDetails.read) access.push("Read");
|
||||||
|
if (accessDetails.edit) access.push("Edit");
|
||||||
|
if (accessDetails.create) access.push("Create");
|
||||||
|
if (accessDetails.delete) access.push("Delete");
|
||||||
|
|
||||||
|
return access.join(", ");
|
||||||
|
}, [accessDetails]);
|
||||||
|
|
||||||
|
const getAccessLabel = () => {
|
||||||
|
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||||
|
return "Permanent";
|
||||||
|
|
||||||
|
// convert the range to human readable format
|
||||||
|
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge>
|
||||||
|
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||||
|
long: true
|
||||||
|
})} after approval`}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewAccessRequest = useReviewAccessRequest();
|
||||||
|
|
||||||
|
const handleReview = useCallback(async (status: "approved" | "rejected") => {
|
||||||
|
setIsLoading(status);
|
||||||
|
try {
|
||||||
|
await reviewAccessRequest.mutateAsync({
|
||||||
|
requestId: request.id,
|
||||||
|
status,
|
||||||
|
projectSlug,
|
||||||
|
envSlug: selectedEnvSlug,
|
||||||
|
requestedBy: selectedRequester
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setIsLoading(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
title: `Request ${status}`,
|
||||||
|
text: `The request has been ${status}`,
|
||||||
|
type: status === "approved" ? "success" : "info"
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(null);
|
||||||
|
onOpenChange(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
className="max-w-4xl"
|
||||||
|
title="Review Request"
|
||||||
|
subTitle="Review the request and approve or deny access."
|
||||||
|
>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||||
|
</span>{" "}
|
||||||
|
is requesting access to the following resource:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="mt-4 mb-2 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
|
||||||
|
<div className="mb-1 lowercase">
|
||||||
|
<span className="font-bold capitalize">Requested path: </span>
|
||||||
|
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-bold">Permissions: </span>
|
||||||
|
<Badge>{requestedAccess}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-bold">Access Type: </span>
|
||||||
|
<span>{getAccessLabel()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading === "approved"}
|
||||||
|
isDisabled={!!isLoading}
|
||||||
|
onClick={() => handleReview("approved")}
|
||||||
|
className="mt-4"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Approve Request
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading === "rejected"}
|
||||||
|
isDisabled={!!isLoading}
|
||||||
|
onClick={() => handleReview("rejected")}
|
||||||
|
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Reject Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AccessApprovalRequest } from "./AccessApprovalRequest";
|
||||||
-2
@@ -47,7 +47,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
|
|||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
|
|
||||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||||
const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({
|
const { data: policies, isLoading: isPoliciesLoading } = useGetSecretApprovalPolicies({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -120,7 +119,6 @@ export const SecretApprovalPolicyList = ({ workspaceId }: Props) => {
|
|||||||
<Th>Secret Path</Th>
|
<Th>Secret Path</Th>
|
||||||
<Th>Eligible Approvers</Th>
|
<Th>Eligible Approvers</Th>
|
||||||
<Th>Approval Required</Th>
|
<Th>Approval Required</Th>
|
||||||
<Th />
|
|
||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ export const SecretApprovalPolicyRow = ({
|
|||||||
iconPos="right"
|
iconPos="right"
|
||||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.username}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+1
-1
@@ -222,7 +222,7 @@ export const SecretPolicyForm = ({
|
|||||||
iconPos="right"
|
iconPos="right"
|
||||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.username}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+18
-5
@@ -19,7 +19,13 @@ import {
|
|||||||
EmptyState,
|
EmptyState,
|
||||||
Skeleton
|
Skeleton
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useUser, useWorkspace } from "@app/context";
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useUser,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
import {
|
import {
|
||||||
useGetSecretApprovalRequestCount,
|
useGetSecretApprovalRequestCount,
|
||||||
useGetSecretApprovalRequests,
|
useGetSecretApprovalRequests,
|
||||||
@@ -58,6 +64,7 @@ export const SecretApprovalRequest = () => {
|
|||||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||||
useGetSecretApprovalRequestCount({ workspaceId });
|
useGetSecretApprovalRequestCount({ workspaceId });
|
||||||
const { user: presentUser } = useUser();
|
const { user: presentUser } = useUser();
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||||
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
||||||
(prev, curr) => ({ ...prev, [curr.id]: curr }),
|
(prev, curr) => ({ ...prev, [curr.id]: curr }),
|
||||||
@@ -156,13 +163,16 @@ export const SecretApprovalRequest = () => {
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
colorSchema="secondary"
|
colorSchema="secondary"
|
||||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
rightIcon={
|
||||||
|
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Author
|
Author
|
||||||
</Button>
|
</Button>
|
||||||
@@ -171,19 +181,22 @@ export const SecretApprovalRequest = () => {
|
|||||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||||
{members?.map(({ user, id }) => (
|
{members?.map(({ user, id }) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setCommitterFilter((state) => (state === id ? undefined : id))}
|
onClick={() =>
|
||||||
|
setCommitterFilter((state) => (state === id ? undefined : id))
|
||||||
|
}
|
||||||
key={`request-filter-member-${id}`}
|
key={`request-filter-member-${id}`}
|
||||||
icon={committerFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
icon={committerFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
iconPos="right"
|
iconPos="right"
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.username}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col rounded-b-md border-x border-t border-b border-mineshaft-600 border-mineshaft-600 bg-mineshaft-800">
|
<div className="flex flex-col rounded-b-md border-x border-t border-b border-mineshaft-600 bg-mineshaft-800">
|
||||||
{isRequestListEmpty && (
|
{isRequestListEmpty && (
|
||||||
<div className="py-12">
|
<div className="py-12">
|
||||||
<EmptyState title="No more requests pending." />
|
<EmptyState title="No more requests pending." />
|
||||||
|
|||||||
+2
-3
@@ -37,7 +37,6 @@ export const SecretApprovalRequestAction = ({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
canApprove
|
canApprove
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
|
||||||
const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } =
|
const { mutateAsync: performSecretApprovalMerge, isLoading: isMerging } =
|
||||||
usePerformSecretApprovalRequestMerge();
|
usePerformSecretApprovalRequestMerge();
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ export const SecretApprovalRequestAction = ({
|
|||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
|
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
Change request merged
|
Secret approval merged
|
||||||
<span className="inline-block text-xs text-bunker-200">
|
<span className="inline-block text-xs text-bunker-200">
|
||||||
Merged by {statusChangeByEmail}
|
Merged by {statusChangeByEmail}
|
||||||
</span>
|
</span>
|
||||||
@@ -150,7 +149,7 @@ export const SecretApprovalRequestAction = ({
|
|||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
|
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
Change request has been closed
|
Secret approval has been closed
|
||||||
<span className="inline-block text-xs text-bunker-200">
|
<span className="inline-block text-xs text-bunker-200">
|
||||||
Closed by {statusChangeByEmail}
|
Closed by {statusChangeByEmail}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
-2
@@ -83,7 +83,6 @@ export const SecretApprovalRequestChanges = ({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
members = {}
|
members = {}
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||||
const {
|
const {
|
||||||
@@ -94,7 +93,6 @@ export const SecretApprovalRequestChanges = ({
|
|||||||
id: approvalRequestId,
|
id: approvalRequestId,
|
||||||
decryptKey: decryptFileKey!
|
decryptKey: decryptFileKey!
|
||||||
});
|
});
|
||||||
console.log(secretApprovalRequestDetails);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: updateSecretApprovalRequestStatus,
|
mutateAsync: updateSecretApprovalRequestStatus,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -70,6 +70,12 @@ import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSect
|
|||||||
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
|
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
|
||||||
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
|
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
|
||||||
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
|
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
|
||||||
|
import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel";
|
||||||
|
|
||||||
|
export enum EntryType {
|
||||||
|
FOLDER = "folder",
|
||||||
|
SECRET = "secret"
|
||||||
|
}
|
||||||
|
|
||||||
export const SecretOverviewPage = () => {
|
export const SecretOverviewPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -82,15 +88,6 @@ export const SecretOverviewPage = () => {
|
|||||||
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
|
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleParentTableWidthResize = () => {
|
|
||||||
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleParentTableWidthResize);
|
|
||||||
return () => window.removeEventListener("resize", handleParentTableWidthResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parentTableRef.current) {
|
if (parentTableRef.current) {
|
||||||
setExpandableTableWidth(parentTableRef.current.clientWidth);
|
setExpandableTableWidth(parentTableRef.current.clientWidth);
|
||||||
@@ -105,6 +102,56 @@ export const SecretOverviewPage = () => {
|
|||||||
const [searchFilter, setSearchFilter] = useState("");
|
const [searchFilter, setSearchFilter] = useState("");
|
||||||
const secretPath = (router.query?.secretPath as string) || "/";
|
const secretPath = (router.query?.secretPath as string) || "/";
|
||||||
|
|
||||||
|
const [selectedEntries, setSelectedEntries] = useState<{
|
||||||
|
[EntryType.FOLDER]: Record<string, boolean>;
|
||||||
|
[EntryType.SECRET]: Record<string, boolean>;
|
||||||
|
}>({
|
||||||
|
[EntryType.FOLDER]: {},
|
||||||
|
[EntryType.SECRET]: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSelectedEntry = useCallback(
|
||||||
|
(type: EntryType, key: string) => {
|
||||||
|
const isChecked = Boolean(selectedEntries[type]?.[key]);
|
||||||
|
const newChecks = { ...selectedEntries };
|
||||||
|
|
||||||
|
// remove selection if its present else add it
|
||||||
|
if (isChecked) {
|
||||||
|
delete newChecks[type][key];
|
||||||
|
} else {
|
||||||
|
newChecks[type][key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedEntries(newChecks);
|
||||||
|
},
|
||||||
|
[selectedEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetSelectedEntries = useCallback(() => {
|
||||||
|
setSelectedEntries({
|
||||||
|
[EntryType.FOLDER]: {},
|
||||||
|
[EntryType.SECRET]: {}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleParentTableWidthResize = () => {
|
||||||
|
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRouteChangeStart = () => {
|
||||||
|
resetSelectedEntries();
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on("routeChangeStart", onRouteChangeStart);
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleParentTableWidthResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleParentTableWidthResize);
|
||||||
|
router.events.off("routeChangeStart", onRouteChangeStart);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
|
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
|
||||||
router.push(`/org/${currentOrg?.id}/overview`);
|
router.push(`/org/${currentOrg?.id}/overview`);
|
||||||
@@ -129,7 +176,8 @@ export const SecretOverviewPage = () => {
|
|||||||
secretPath,
|
secretPath,
|
||||||
decryptFileKey: latestFileKey!
|
decryptFileKey: latestFileKey!
|
||||||
});
|
});
|
||||||
const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({
|
|
||||||
|
const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({
|
||||||
projectId: workspaceId,
|
projectId: workspaceId,
|
||||||
path: secretPath,
|
path: secretPath,
|
||||||
environments: userAvailableEnvs.map(({ slug }) => slug)
|
environments: userAvailableEnvs.map(({ slug }) => slug)
|
||||||
@@ -543,6 +591,13 @@ export const SecretOverviewPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SelectionPanel
|
||||||
|
secretPath={secretPath}
|
||||||
|
getSecretByKey={getSecretByKey}
|
||||||
|
getFolderByNameAndEnv={getFolderByNameAndEnv}
|
||||||
|
selectedEntries={selectedEntries}
|
||||||
|
resetSelectedEntries={resetSelectedEntries}
|
||||||
|
/>
|
||||||
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
|
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
|
||||||
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
|
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -666,6 +721,8 @@ export const SecretOverviewPage = () => {
|
|||||||
<SecretOverviewFolderRow
|
<SecretOverviewFolderRow
|
||||||
folderName={folderName}
|
folderName={folderName}
|
||||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||||
|
isSelected={selectedEntries.folder[folderName]}
|
||||||
|
onToggleFolderSelect={() => toggleSelectedEntry(EntryType.FOLDER, folderName)}
|
||||||
environments={visibleEnvs}
|
environments={visibleEnvs}
|
||||||
key={`overview-${folderName}-${index + 1}`}
|
key={`overview-${folderName}-${index + 1}`}
|
||||||
onClick={handleFolderClick}
|
onClick={handleFolderClick}
|
||||||
@@ -684,6 +741,8 @@ export const SecretOverviewPage = () => {
|
|||||||
visibleEnvs?.length > 0 &&
|
visibleEnvs?.length > 0 &&
|
||||||
filteredSecretNames.map((key, index) => (
|
filteredSecretNames.map((key, index) => (
|
||||||
<SecretOverviewTableRow
|
<SecretOverviewTableRow
|
||||||
|
isSelected={selectedEntries.secret[key]}
|
||||||
|
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
|
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
|
||||||
onSecretCreate={handleSecretCreate}
|
onSecretCreate={handleSecretCreate}
|
||||||
|
|||||||
+20
-3
@@ -2,20 +2,23 @@ import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { Td, Tr } from "@app/components/v2";
|
import { Checkbox, Td, Tr } from "@app/components/v2";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
folderName: string;
|
folderName: string;
|
||||||
environments: { name: string; slug: string }[];
|
environments: { name: string; slug: string }[];
|
||||||
isFolderPresentInEnv: (name: string, env: string) => boolean;
|
isFolderPresentInEnv: (name: string, env: string) => boolean;
|
||||||
onClick: (path: string) => void;
|
onClick: (path: string) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggleFolderSelect: (folderName: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SecretOverviewFolderRow = ({
|
export const SecretOverviewFolderRow = ({
|
||||||
folderName,
|
folderName,
|
||||||
environments = [],
|
environments = [],
|
||||||
isFolderPresentInEnv,
|
isFolderPresentInEnv,
|
||||||
|
isSelected,
|
||||||
|
onToggleFolderSelect,
|
||||||
onClick
|
onClick
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +26,21 @@ export const SecretOverviewFolderRow = ({
|
|||||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||||
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
||||||
<div className="text-yellow-700">
|
<div className="text-yellow-700">
|
||||||
<FontAwesomeIcon icon={faFolder} />
|
<Checkbox
|
||||||
|
id={`checkbox-${folderName}`}
|
||||||
|
isChecked={isSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
onToggleFolderSelect(folderName);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
|
||||||
|
icon={faFolder}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>{folderName}</div>
|
<div>{folderName}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-3
@@ -11,7 +11,7 @@ import {
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { Button, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
|
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
|
||||||
import { useToggle } from "@app/hooks";
|
import { useToggle } from "@app/hooks";
|
||||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ type Props = {
|
|||||||
secretPath: string;
|
secretPath: string;
|
||||||
environments: { name: string; slug: string }[];
|
environments: { name: string; slug: string }[];
|
||||||
expandableColWidth: number;
|
expandableColWidth: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggleSecretSelect: (key: string) => void;
|
||||||
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
|
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
|
||||||
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
|
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
|
||||||
onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise<void>;
|
onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise<void>;
|
||||||
@@ -39,7 +41,9 @@ export const SecretOverviewTableRow = ({
|
|||||||
onSecretCreate,
|
onSecretCreate,
|
||||||
onSecretDelete,
|
onSecretDelete,
|
||||||
isImportedSecretPresentInEnv,
|
isImportedSecretPresentInEnv,
|
||||||
expandableColWidth
|
expandableColWidth,
|
||||||
|
onToggleSecretSelect,
|
||||||
|
isSelected
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isFormExpanded, setIsFormExpanded] = useToggle();
|
const [isFormExpanded, setIsFormExpanded] = useToggle();
|
||||||
const totalCols = environments.length + 1; // secret key row
|
const totalCols = environments.length + 1; // secret key row
|
||||||
@@ -56,7 +60,21 @@ export const SecretOverviewTableRow = ({
|
|||||||
<div className="h-full w-full border-r border-mineshaft-600 py-2.5 px-5">
|
<div className="h-full w-full border-r border-mineshaft-600 py-2.5 px-5">
|
||||||
<div className="flex items-center space-x-5">
|
<div className="flex items-center space-x-5">
|
||||||
<div className="text-blue-300/70">
|
<div className="text-blue-300/70">
|
||||||
<FontAwesomeIcon icon={isFormExpanded ? faAngleDown : faKey} />
|
<Checkbox
|
||||||
|
id={`checkbox-${secretKey}`}
|
||||||
|
isChecked={isSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
onToggleSecretSelect(secretKey);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
|
||||||
|
icon={isFormExpanded ? faAngleDown : faKey}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div title={secretKey}>{secretKey}</div>
|
<div title={secretKey}>{secretKey}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import { faMinusSquare, faTrash } 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, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||||
|
import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
export enum EntryType {
|
||||||
|
FOLDER = "folder",
|
||||||
|
SECRET = "secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secretPath: string;
|
||||||
|
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
|
||||||
|
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
|
||||||
|
resetSelectedEntries: () => void;
|
||||||
|
selectedEntries: {
|
||||||
|
[EntryType.FOLDER]: Record<string, boolean>;
|
||||||
|
[EntryType.SECRET]: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectionPanel = ({
|
||||||
|
getFolderByNameAndEnv,
|
||||||
|
getSecretByKey,
|
||||||
|
secretPath,
|
||||||
|
resetSelectedEntries,
|
||||||
|
selectedEntries
|
||||||
|
}: Props) => {
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
|
"bulkDeleteEntries"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const selectedCount =
|
||||||
|
Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length;
|
||||||
|
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const userAvailableEnvs = currentWorkspace?.environments || [];
|
||||||
|
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||||
|
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||||
|
|
||||||
|
const isMultiSelectActive = selectedCount > 0;
|
||||||
|
|
||||||
|
// user should have the ability to delete secrets/folders in at least one of the envs
|
||||||
|
const shouldShowDelete = userAvailableEnvs.some((env) =>
|
||||||
|
permission.can(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
let processedEntries = 0;
|
||||||
|
|
||||||
|
const promises = userAvailableEnvs.map(async (env) => {
|
||||||
|
// additional check: ensure that bulk delete is only executed on envs that user has access to
|
||||||
|
if (
|
||||||
|
permission.cannot(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.keys(selectedEntries.folder).map(async (folderName) => {
|
||||||
|
const folder = getFolderByNameAndEnv(folderName, env.slug);
|
||||||
|
if (folder) {
|
||||||
|
processedEntries += 1;
|
||||||
|
await deleteFolder({
|
||||||
|
folderId: folder?.id,
|
||||||
|
path: secretPath,
|
||||||
|
environment: env.slug,
|
||||||
|
projectId: workspaceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
|
||||||
|
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
|
||||||
|
const entry = getSecretByKey(env.slug, secretName);
|
||||||
|
if (entry) {
|
||||||
|
return [
|
||||||
|
...accum,
|
||||||
|
{
|
||||||
|
secretName: entry.key,
|
||||||
|
type: "shared" as "shared"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return accum;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretsToDelete.length > 0) {
|
||||||
|
processedEntries += secretsToDelete.length;
|
||||||
|
await deleteBatchSecretV3({
|
||||||
|
secretPath,
|
||||||
|
workspaceId,
|
||||||
|
environment: env.slug,
|
||||||
|
secrets: secretsToDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
const areEntriesDeleted = results.some((result) => result.status === "fulfilled");
|
||||||
|
if (processedEntries === 0) {
|
||||||
|
handlePopUpClose("bulkDeleteEntries");
|
||||||
|
createNotification({
|
||||||
|
type: "info",
|
||||||
|
text: "You don't have access to delete selected items"
|
||||||
|
});
|
||||||
|
} else if (areEntriesDeleted) {
|
||||||
|
handlePopUpClose("bulkDeleteEntries");
|
||||||
|
resetSelectedEntries();
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted selected secrets and folders"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete selected secrets and folders"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||||
|
isMultiSelectActive && "h-16"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-bunker-300">
|
||||||
|
<Tooltip content="Clear">
|
||||||
|
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedEntries}>
|
||||||
|
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="ml-4 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||||
|
{shouldShowDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
colorSchema="danger"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
className="ml-4"
|
||||||
|
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title="Do you want to delete the selected secrets and folders across envs?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||||
|
onDeleteApproved={handleBulkDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user