Compare commits

..

7 Commits

Author SHA1 Message Date
Sheen
f5238598aa misc: updated admin integration picture 2025-06-23 14:12:54 +00:00
Sheen Capadngan
982aa80092 misc: added tabs for admin integrations 2025-06-23 22:05:08 +08:00
Sheen Capadngan
f85efdc6f8 misc: add auto-sync after config update 2025-06-21 02:57:34 +08:00
Sheen Capadngan
8680c52412 Merge branch 'misc/add-self-serve-for-github-app-connection-setup' of https://github.com/Infisical/infisical into misc/add-self-serve-for-github-app-connection-setup 2025-06-21 02:41:39 +08:00
Sheen Capadngan
0ad3c67f82 misc: minor renames 2025-06-21 02:41:15 +08:00
Sheen
f75fff0565 doc: add image 2025-06-20 18:31:36 +00:00
Sheen Capadngan
1fa1d0a15a misc: add self-serve for github connection setup 2025-06-21 02:23:20 +08:00
45 changed files with 3543 additions and 3636 deletions

View File

@@ -0,0 +1,91 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasEncryptedGithubAppConnectionClientIdColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionClientId"
);
const hasEncryptedGithubAppConnectionClientSecretColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionClientSecret"
);
const hasEncryptedGithubAppConnectionSlugColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionSlug"
);
const hasEncryptedGithubAppConnectionAppIdColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionId"
);
const hasEncryptedGithubAppConnectionAppPrivateKeyColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionPrivateKey"
);
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
if (!hasEncryptedGithubAppConnectionClientIdColumn) {
t.binary("encryptedGitHubAppConnectionClientId").nullable();
}
if (!hasEncryptedGithubAppConnectionClientSecretColumn) {
t.binary("encryptedGitHubAppConnectionClientSecret").nullable();
}
if (!hasEncryptedGithubAppConnectionSlugColumn) {
t.binary("encryptedGitHubAppConnectionSlug").nullable();
}
if (!hasEncryptedGithubAppConnectionAppIdColumn) {
t.binary("encryptedGitHubAppConnectionId").nullable();
}
if (!hasEncryptedGithubAppConnectionAppPrivateKeyColumn) {
t.binary("encryptedGitHubAppConnectionPrivateKey").nullable();
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasEncryptedGithubAppConnectionClientIdColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionClientId"
);
const hasEncryptedGithubAppConnectionClientSecretColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionClientSecret"
);
const hasEncryptedGithubAppConnectionSlugColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionSlug"
);
const hasEncryptedGithubAppConnectionAppIdColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionId"
);
const hasEncryptedGithubAppConnectionAppPrivateKeyColumn = await knex.schema.hasColumn(
TableName.SuperAdmin,
"encryptedGitHubAppConnectionPrivateKey"
);
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
if (hasEncryptedGithubAppConnectionClientIdColumn) {
t.dropColumn("encryptedGitHubAppConnectionClientId");
}
if (hasEncryptedGithubAppConnectionClientSecretColumn) {
t.dropColumn("encryptedGitHubAppConnectionClientSecret");
}
if (hasEncryptedGithubAppConnectionSlugColumn) {
t.dropColumn("encryptedGitHubAppConnectionSlug");
}
if (hasEncryptedGithubAppConnectionAppIdColumn) {
t.dropColumn("encryptedGitHubAppConnectionId");
}
if (hasEncryptedGithubAppConnectionAppPrivateKeyColumn) {
t.dropColumn("encryptedGitHubAppConnectionPrivateKey");
}
});
}

View File

@@ -29,7 +29,12 @@ export const SuperAdminSchema = z.object({
adminIdentityIds: z.string().array().nullable().optional(),
encryptedMicrosoftTeamsAppId: zodBuffer.nullable().optional(),
encryptedMicrosoftTeamsClientSecret: zodBuffer.nullable().optional(),
encryptedMicrosoftTeamsBotId: zodBuffer.nullable().optional()
encryptedMicrosoftTeamsBotId: zodBuffer.nullable().optional(),
encryptedGitHubAppConnectionClientId: zodBuffer.nullable().optional(),
encryptedGitHubAppConnectionClientSecret: zodBuffer.nullable().optional(),
encryptedGitHubAppConnectionSlug: zodBuffer.nullable().optional(),
encryptedGitHubAppConnectionId: zodBuffer.nullable().optional(),
encryptedGitHubAppConnectionPrivateKey: zodBuffer.nullable().optional()
});
export type TSuperAdmin = z.infer<typeof SuperAdminSchema>;

View File

@@ -89,7 +89,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
schema: {
querystring: z.object({
projectSlug: z.string().trim(),
authorUserId: z.string().trim().optional(),
authorProjectMembershipId: z.string().trim().optional(),
envSlug: z.string().trim().optional()
}),
response: {
@@ -143,7 +143,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
handler: async (req) => {
const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({
projectSlug: req.query.projectSlug,
authorUserId: req.query.authorUserId,
authorProjectMembershipId: req.query.authorProjectMembershipId,
envSlug: req.query.envSlug,
actor: req.permission.type,
actorId: req.permission.id,

View File

@@ -30,7 +30,6 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
workspaceId: z.string().trim(),
environment: z.string().trim().optional(),
committer: z.string().trim().optional(),
search: z.string().trim().optional(),
status: z.nativeEnum(RequestState).optional(),
limit: z.coerce.number().default(20),
offset: z.coerce.number().default(0)
@@ -67,14 +66,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
userId: z.string().nullable().optional()
})
.array()
}).array(),
totalCount: z.number()
}).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { approvals, totalCount } = await server.services.secretApprovalRequest.getSecretApprovals({
const approvals = await server.services.secretApprovalRequest.getSecretApprovals({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
@@ -82,7 +80,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
...req.query,
projectId: req.query.workspaceId
});
return { approvals, totalCount };
return { approvals };
}
});

View File

@@ -725,17 +725,16 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
)
.where(`${TableName.Environment}.projectId`, projectId)
.where(`${TableName.AccessApprovalPolicy}.deletedAt`, null)
.select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"))
.select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId"))
.select(db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"));
.select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId"));
const formattedRequests = sqlNestRelationships({
data: accessRequests,
key: "id",
parentMapper: (doc) => ({
...AccessApprovalRequestsSchema.parse(doc),
isPolicyDeleted: Boolean(doc.policyDeletedAt)
...AccessApprovalRequestsSchema.parse(doc)
}),
childrenMapper: [
{
@@ -752,8 +751,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
(req) =>
!req.privilegeId &&
!req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) &&
req.status === ApprovalStatus.PENDING &&
!req.isPolicyDeleted
req.status === ApprovalStatus.PENDING
);
// an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required.
@@ -761,8 +759,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
(req) =>
req.privilegeId ||
req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) ||
req.status !== ApprovalStatus.PENDING ||
req.isPolicyDeleted
req.status !== ApprovalStatus.PENDING
);
return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length };

View File

@@ -275,7 +275,7 @@ export const accessApprovalRequestServiceFactory = ({
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug,
authorUserId,
authorProjectMembershipId,
envSlug,
actor,
actorOrgId,
@@ -300,8 +300,8 @@ export const accessApprovalRequestServiceFactory = ({
const policies = await accessApprovalPolicyDAL.find({ projectId: project.id });
let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id));
if (authorUserId) {
requests = requests.filter((request) => request.requestedByUserId === authorUserId);
if (authorProjectMembershipId) {
requests = requests.filter((request) => request.requestedByUserId === actorId);
}
if (envSlug) {

View File

@@ -31,7 +31,7 @@ export type TCreateAccessApprovalRequestDTO = {
export type TListApprovalRequestsDTO = {
projectSlug: string;
authorUserId?: string;
authorProjectMembershipId?: string;
envSlug?: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -24,7 +24,6 @@ type TFindQueryFilter = {
committer?: string;
limit?: number;
offset?: number;
search?: string;
};
export const secretApprovalRequestDALFactory = (db: TDbClient) => {
@@ -315,6 +314,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
)
.andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null))
.select("status", `${TableName.SecretApprovalRequest}.id`)
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
.count("status")
@@ -340,13 +340,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
};
const findByProjectId = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter,
{ status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter,
tx?: Knex
) => {
try {
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
// this is the place u wanna look at.
const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(
@@ -435,30 +435,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
)
.distinctOn(`${TableName.SecretApprovalRequest}.id`)
.as("inner");
const query = (tx || db)
.select("*")
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery)
.orderBy("createdAt", "desc") as typeof innerQuery;
if (search) {
void query.where((qb) => {
void qb
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
db.ref("firstName").withSchema("committerUser"),
db.ref("lastName").withSchema("committerUser"),
`%${search}%`
])
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`])
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`)
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`)
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`);
});
}
.orderBy("createdAt", "desc");
const docs = await (tx || db)
.with("w", query)
@@ -466,10 +443,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", offset)
.andWhere("w.rank", "<", offset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({
data: docs,
key: "id",
@@ -531,26 +504,23 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
}
]
});
return {
approvals: formattedDoc.map((el) => ({
...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
})),
totalCount
};
return formattedDoc.map((el) => ({
...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindSAR" });
}
};
const findByProjectIdBridgeSecretV2 = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter,
{ status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter,
tx?: Knex
) => {
try {
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
// this is the place u wanna look at.
const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join(
@@ -639,42 +609,14 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
)
.distinctOn(`${TableName.SecretApprovalRequest}.id`)
.as("inner");
.orderBy("createdAt", "desc");
const query = (tx || db)
.select("*")
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery)
.orderBy("createdAt", "desc") as typeof innerQuery;
if (search) {
void query.where((qb) => {
void qb
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
db.ref("firstName").withSchema("committerUser"),
db.ref("lastName").withSchema("committerUser"),
`%${search}%`
])
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`])
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`)
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`)
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`);
});
}
const rankOffset = offset + 1;
const docs = await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
.where("w.rank", ">=", offset)
.andWhere("w.rank", "<", offset + limit);
const formattedDoc = sqlNestRelationships({
data: docs,
key: "id",
@@ -740,13 +682,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
}
]
});
return {
approvals: formattedDoc.map((el) => ({
...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
})),
totalCount
};
return formattedDoc.map((el) => ({
...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindSAR" });
}

View File

@@ -194,8 +194,7 @@ export const secretApprovalRequestServiceFactory = ({
environment,
committer,
limit,
offset,
search
offset
}: TListApprovalsDTO) => {
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
@@ -209,7 +208,6 @@ export const secretApprovalRequestServiceFactory = ({
});
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) {
return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({
projectId,
@@ -218,21 +216,19 @@ export const secretApprovalRequestServiceFactory = ({
status,
userId: actorId,
limit,
offset,
search
offset
});
}
return secretApprovalRequestDAL.findByProjectId({
const approvals = await secretApprovalRequestDAL.findByProjectId({
projectId,
committer,
environment,
status,
userId: actorId,
limit,
offset,
search
offset
});
return approvals;
};
const getSecretApprovalDetails = async ({

View File

@@ -93,7 +93,6 @@ export type TListApprovalsDTO = {
committer?: string;
limit?: number;
offset?: number;
search?: string;
} & TProjectPermission;
export type TSecretApprovalDetailsDTO = {

View File

@@ -2020,10 +2020,16 @@ export const registerRoutes = async (
if (licenseSyncJob) {
cronJobs.push(licenseSyncJob);
}
const microsoftTeamsSyncJob = await microsoftTeamsService.initializeBackgroundSync();
if (microsoftTeamsSyncJob) {
cronJobs.push(microsoftTeamsSyncJob);
}
const adminIntegrationsSyncJob = await superAdminService.initializeAdminIntegrationConfigSync();
if (adminIntegrationsSyncJob) {
cronJobs.push(adminIntegrationsSyncJob);
}
}
server.decorate<FastifyZodProvider["store"]>("store", {

View File

@@ -37,7 +37,12 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
encryptedSlackClientSecret: true,
encryptedMicrosoftTeamsAppId: true,
encryptedMicrosoftTeamsClientSecret: true,
encryptedMicrosoftTeamsBotId: true
encryptedMicrosoftTeamsBotId: true,
encryptedGitHubAppConnectionClientId: true,
encryptedGitHubAppConnectionClientSecret: true,
encryptedGitHubAppConnectionSlug: true,
encryptedGitHubAppConnectionId: true,
encryptedGitHubAppConnectionPrivateKey: true
}).extend({
isMigrationModeOn: z.boolean(),
defaultAuthOrgSlug: z.string().nullable(),
@@ -87,6 +92,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
microsoftTeamsAppId: z.string().optional(),
microsoftTeamsClientSecret: z.string().optional(),
microsoftTeamsBotId: z.string().optional(),
gitHubAppConnectionClientId: z.string().optional(),
gitHubAppConnectionClientSecret: z.string().optional(),
gitHubAppConnectionSlug: z.string().optional(),
gitHubAppConnectionId: z.string().optional(),
gitHubAppConnectionPrivateKey: z.string().optional(),
authConsentContent: z
.string()
.trim()
@@ -348,6 +358,13 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
appId: z.string(),
clientSecret: z.string(),
botId: z.string()
}),
gitHubAppConnection: z.object({
clientId: z.string(),
clientSecret: z.string(),
appSlug: z.string(),
appId: z.string(),
privateKey: z.string()
})
})
}

View File

@@ -7,6 +7,7 @@ import { request } from "@app/lib/config/request";
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
import { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { getInstanceIntegrationsConfig } from "@app/services/super-admin/super-admin-service";
import { AppConnection } from "../app-connection-enums";
import { GitHubConnectionMethod } from "./github-connection-enums";
@@ -14,13 +15,14 @@ import { TGitHubConnection, TGitHubConnectionConfig } from "./github-connection-
export const getGitHubConnectionListItem = () => {
const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig();
const { gitHubAppConnection } = getInstanceIntegrationsConfig();
return {
name: "GitHub" as const,
app: AppConnection.GitHub as const,
methods: Object.values(GitHubConnectionMethod) as [GitHubConnectionMethod.App, GitHubConnectionMethod.OAuth],
oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG
appClientSlug: gitHubAppConnection.appSlug || INF_APP_CONNECTION_GITHUB_APP_SLUG
};
};
@@ -30,23 +32,24 @@ export const getGitHubClient = (appConnection: TGitHubConnection) => {
const { method, credentials } = appConnection;
let client: Octokit;
const { gitHubAppConnection } = getInstanceIntegrationsConfig();
const appId = gitHubAppConnection.appId || appCfg.INF_APP_CONNECTION_GITHUB_APP_ID;
const appPrivateKey = gitHubAppConnection.privateKey || appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
switch (method) {
case GitHubConnectionMethod.App:
if (!appCfg.INF_APP_CONNECTION_GITHUB_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY) {
if (!appId || !appPrivateKey) {
throw new InternalServerError({
message: `GitHub ${getAppConnectionMethodName(method).replace(
"GitHub",
""
)} environment variables have not been configured`
message: `GitHub ${getAppConnectionMethodName(method).replace("GitHub", "")} has not been configured`
});
}
client = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appCfg.INF_APP_CONNECTION_GITHUB_APP_ID,
privateKey: appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY,
appId,
privateKey: appPrivateKey,
installationId: credentials.installationId
}
});
@@ -154,6 +157,8 @@ type TokenRespData = {
export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => {
const { credentials, method } = config;
const { gitHubAppConnection } = getInstanceIntegrationsConfig();
const {
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET,
@@ -165,8 +170,8 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
const { clientId, clientSecret } =
method === GitHubConnectionMethod.App
? {
clientId: INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID,
clientSecret: INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET
clientId: gitHubAppConnection.clientId || INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID,
clientSecret: gitHubAppConnection.clientSecret || INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET
}
: // oauth
{

View File

@@ -1,4 +1,5 @@
import bcrypt from "bcrypt";
import { CronJob } from "cron";
import jwt from "jsonwebtoken";
import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas";
@@ -8,6 +9,7 @@ import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { TAuthLoginFactory } from "../auth/auth-login-service";
@@ -35,6 +37,7 @@ import {
TAdminBootstrapInstanceDTO,
TAdminGetIdentitiesDTO,
TAdminGetUsersDTO,
TAdminIntegrationConfig,
TAdminSignUpDTO,
TGetOrganizationsDTO
} from "./super-admin-types";
@@ -70,6 +73,31 @@ export let getServerCfg: () => Promise<
}
>;
let adminIntegrationsConfig: TAdminIntegrationConfig = {
slack: {
clientSecret: "",
clientId: ""
},
microsoftTeams: {
appId: "",
clientSecret: "",
botId: ""
},
gitHubAppConnection: {
clientId: "",
clientSecret: "",
appSlug: "",
appId: "",
privateKey: ""
}
};
Object.freeze(adminIntegrationsConfig);
export const getInstanceIntegrationsConfig = () => {
return adminIntegrationsConfig;
};
const ADMIN_CONFIG_KEY = "infisical-admin-cfg";
const ADMIN_CONFIG_KEY_EXP = 60; // 60s
export const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000";
@@ -138,6 +166,74 @@ export const superAdminServiceFactory = ({
return serverCfg;
};
const getAdminIntegrationsConfig = async () => {
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
if (!serverCfg) {
throw new NotFoundError({ name: "AdminConfig", message: "Admin config not found" });
}
const decrypt = kmsService.decryptWithRootKey();
const slackClientId = serverCfg.encryptedSlackClientId ? decrypt(serverCfg.encryptedSlackClientId).toString() : "";
const slackClientSecret = serverCfg.encryptedSlackClientSecret
? decrypt(serverCfg.encryptedSlackClientSecret).toString()
: "";
const microsoftAppId = serverCfg.encryptedMicrosoftTeamsAppId
? decrypt(serverCfg.encryptedMicrosoftTeamsAppId).toString()
: "";
const microsoftClientSecret = serverCfg.encryptedMicrosoftTeamsClientSecret
? decrypt(serverCfg.encryptedMicrosoftTeamsClientSecret).toString()
: "";
const microsoftBotId = serverCfg.encryptedMicrosoftTeamsBotId
? decrypt(serverCfg.encryptedMicrosoftTeamsBotId).toString()
: "";
const gitHubAppConnectionClientId = serverCfg.encryptedGitHubAppConnectionClientId
? decrypt(serverCfg.encryptedGitHubAppConnectionClientId).toString()
: "";
const gitHubAppConnectionClientSecret = serverCfg.encryptedGitHubAppConnectionClientSecret
? decrypt(serverCfg.encryptedGitHubAppConnectionClientSecret).toString()
: "";
const gitHubAppConnectionAppSlug = serverCfg.encryptedGitHubAppConnectionSlug
? decrypt(serverCfg.encryptedGitHubAppConnectionSlug).toString()
: "";
const gitHubAppConnectionAppId = serverCfg.encryptedGitHubAppConnectionId
? decrypt(serverCfg.encryptedGitHubAppConnectionId).toString()
: "";
const gitHubAppConnectionAppPrivateKey = serverCfg.encryptedGitHubAppConnectionPrivateKey
? decrypt(serverCfg.encryptedGitHubAppConnectionPrivateKey).toString()
: "";
return {
slack: {
clientSecret: slackClientSecret,
clientId: slackClientId
},
microsoftTeams: {
appId: microsoftAppId,
clientSecret: microsoftClientSecret,
botId: microsoftBotId
},
gitHubAppConnection: {
clientId: gitHubAppConnectionClientId,
clientSecret: gitHubAppConnectionClientSecret,
appSlug: gitHubAppConnectionAppSlug,
appId: gitHubAppConnectionAppId,
privateKey: gitHubAppConnectionAppPrivateKey
}
};
};
const $syncAdminIntegrationConfig = async () => {
const config = await getAdminIntegrationsConfig();
Object.freeze(config);
adminIntegrationsConfig = config;
};
const updateServerCfg = async (
data: TSuperAdminUpdate & {
slackClientId?: string;
@@ -145,6 +241,11 @@ export const superAdminServiceFactory = ({
microsoftTeamsAppId?: string;
microsoftTeamsClientSecret?: string;
microsoftTeamsBotId?: string;
gitHubAppConnectionClientId?: string;
gitHubAppConnectionClientSecret?: string;
gitHubAppConnectionSlug?: string;
gitHubAppConnectionId?: string;
gitHubAppConnectionPrivateKey?: string;
},
userId: string
) => {
@@ -236,10 +337,51 @@ export const superAdminServiceFactory = ({
updatedData.microsoftTeamsBotId = undefined;
microsoftTeamsSettingsUpdated = true;
}
let gitHubAppConnectionSettingsUpdated = false;
if (data.gitHubAppConnectionClientId !== undefined) {
const encryptedClientId = encryptWithRoot(Buffer.from(data.gitHubAppConnectionClientId));
updatedData.encryptedGitHubAppConnectionClientId = encryptedClientId;
updatedData.gitHubAppConnectionClientId = undefined;
gitHubAppConnectionSettingsUpdated = true;
}
if (data.gitHubAppConnectionClientSecret !== undefined) {
const encryptedClientSecret = encryptWithRoot(Buffer.from(data.gitHubAppConnectionClientSecret));
updatedData.encryptedGitHubAppConnectionClientSecret = encryptedClientSecret;
updatedData.gitHubAppConnectionClientSecret = undefined;
gitHubAppConnectionSettingsUpdated = true;
}
if (data.gitHubAppConnectionSlug !== undefined) {
const encryptedAppSlug = encryptWithRoot(Buffer.from(data.gitHubAppConnectionSlug));
updatedData.encryptedGitHubAppConnectionSlug = encryptedAppSlug;
updatedData.gitHubAppConnectionSlug = undefined;
gitHubAppConnectionSettingsUpdated = true;
}
if (data.gitHubAppConnectionId !== undefined) {
const encryptedAppId = encryptWithRoot(Buffer.from(data.gitHubAppConnectionId));
updatedData.encryptedGitHubAppConnectionId = encryptedAppId;
updatedData.gitHubAppConnectionId = undefined;
gitHubAppConnectionSettingsUpdated = true;
}
if (data.gitHubAppConnectionPrivateKey !== undefined) {
const encryptedAppPrivateKey = encryptWithRoot(Buffer.from(data.gitHubAppConnectionPrivateKey));
updatedData.encryptedGitHubAppConnectionPrivateKey = encryptedAppPrivateKey;
updatedData.gitHubAppConnectionPrivateKey = undefined;
gitHubAppConnectionSettingsUpdated = true;
}
const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData);
await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg));
if (gitHubAppConnectionSettingsUpdated) {
await $syncAdminIntegrationConfig();
}
if (
updatedServerCfg.encryptedMicrosoftTeamsAppId &&
updatedServerCfg.encryptedMicrosoftTeamsClientSecret &&
@@ -593,43 +735,6 @@ export const superAdminServiceFactory = ({
await userDAL.updateById(userId, { superAdmin: true });
};
const getAdminIntegrationsConfig = async () => {
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
if (!serverCfg) {
throw new NotFoundError({ name: "AdminConfig", message: "Admin config not found" });
}
const decrypt = kmsService.decryptWithRootKey();
const slackClientId = serverCfg.encryptedSlackClientId ? decrypt(serverCfg.encryptedSlackClientId).toString() : "";
const slackClientSecret = serverCfg.encryptedSlackClientSecret
? decrypt(serverCfg.encryptedSlackClientSecret).toString()
: "";
const microsoftAppId = serverCfg.encryptedMicrosoftTeamsAppId
? decrypt(serverCfg.encryptedMicrosoftTeamsAppId).toString()
: "";
const microsoftClientSecret = serverCfg.encryptedMicrosoftTeamsClientSecret
? decrypt(serverCfg.encryptedMicrosoftTeamsClientSecret).toString()
: "";
const microsoftBotId = serverCfg.encryptedMicrosoftTeamsBotId
? decrypt(serverCfg.encryptedMicrosoftTeamsBotId).toString()
: "";
return {
slack: {
clientSecret: slackClientSecret,
clientId: slackClientId
},
microsoftTeams: {
appId: microsoftAppId,
clientSecret: microsoftClientSecret,
botId: microsoftBotId
}
};
};
const getConfiguredEncryptionStrategies = async () => {
const appCfg = getConfig();
@@ -696,6 +801,19 @@ export const superAdminServiceFactory = ({
return (await keyStore.getItem("invalidating-cache")) !== null;
};
const initializeAdminIntegrationConfigSync = async () => {
logger.info("Setting up background sync process for admin integrations config");
// initial sync upon startup
await $syncAdminIntegrationConfig();
// sync admin integrations config every 5 minutes
const job = new CronJob("*/5 * * * *", $syncAdminIntegrationConfig);
job.start();
return job;
};
return {
initServerCfg,
updateServerCfg,
@@ -714,6 +832,7 @@ export const superAdminServiceFactory = ({
checkIfInvalidatingCache,
getOrganizations,
deleteOrganization,
deleteOrganizationMembership
deleteOrganizationMembership,
initializeAdminIntegrationConfigSync
};
};

View File

@@ -55,3 +55,22 @@ export enum CacheType {
ALL = "all",
SECRETS = "secrets"
}
export type TAdminIntegrationConfig = {
slack: {
clientSecret: string;
clientId: string;
};
microsoftTeams: {
appId: string;
clientSecret: string;
botId: string;
};
gitHubAppConnection: {
clientId: string;
clientSecret: string;
appSlug: string;
appId: string;
privateKey: string;
};
};

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

View File

@@ -53,7 +53,22 @@ Infisical supports two methods for connecting to GitHub.
Obtain the necessary Github application credentials. This would be the application slug, client ID, app ID, client secret, and private key.
![integrations github app credentials](/images/integrations/github/app/self-hosted-github-app-credentials.png)
Back in your Infisical instance, add the five new environment variables for the credentials of your GitHub application:
Back in your Infisical instance, you can configure the GitHub App credentials in one of two ways:
**Option 1: Server Admin Panel (Recommended)**
Navigate to the server admin panel > **Integrations** > **GitHub App** and enter the GitHub application credentials:
![integrations github app admin panel](/images/integrations/github/app/self-hosted-github-app-admin-panel.png)
- **Client ID**: The Client ID of your GitHub application
- **Client Secret**: The Client Secret of your GitHub application
- **App Slug**: The Slug of your GitHub application (found in the URL)
- **App ID**: The App ID of your GitHub application
- **Private Key**: The Private Key of your GitHub application
**Option 2: Environment Variables**
Alternatively, you can add the new environment variables for the credentials of your GitHub application:
- `INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID`: The **Client ID** of your GitHub application.
- `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application.
@@ -61,7 +76,7 @@ Infisical supports two methods for connecting to GitHub.
- `INF_APP_CONNECTION_GITHUB_APP_ID`: The **App ID** of your GitHub application.
- `INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY`: The **Private Key** of your GitHub application.
Once added, restart your Infisical instance and use the GitHub integration via app authentication.
Once configured, you can use the GitHub integration via app authentication. If you configured the credentials using environment variables, restart your Infisical instance for the changes to take effect. If you configured them through the server admin panel, allow approximately 5 minutes for the changes to propagate.
</Step>
</Steps>
</Accordion>
@@ -158,4 +173,5 @@ Infisical supports two methods for connecting to GitHub.
</Step>
</Steps>
</Tab>
</Tabs>

View File

@@ -1,128 +0,0 @@
---
title: "Infisical Cloud"
description: "Architecture overview of Infisical's US and EU cloud deployments"
---
This document provides an overview of Infisical's cloud architecture for our US and EU deployments, detailing the core components and how they interact to provide security and infrastructure services.
## Overview
Infisical Cloud operates on AWS infrastructure using containerized services deployed via Amazon ECS (Elastic Container Service). Our US and EU deployments use identical architectural patterns to ensure consistency and reliability across regions.
![Infisical Cloud Architecture](/images/self-hosting/reference-architectures/Infisical-AWS-ECS-architecture.jpeg)
## Components
A typical Infisical Cloud deployment consists of the following components:
### Application Services
- **Infisical Core**: Main application server running the Infisical backend API
- **License API**: Dedicated API service for license management with separate RDS instance (shared between US/EU)
- **Application Load Balancer**: Routes incoming traffic to application containers with SSL termination and host-based routing
### Data Layer
- **Amazon RDS (PostgreSQL)**:
- **Main RDS Instance**: Primary database for secrets, users, and metadata (Multi-AZ, encryption enabled)
- **License API RDS Instance**: Dedicated database for license management services
- **Amazon ElastiCache (Redis)**:
- **Main Redis Cluster**: Multi-AZ replication group for core application caching and queuing
- **License API Redis**: Dedicated cache for license services
- Redis 7 engine with CloudWatch logging and snapshot backups
### Infrastructure
- **ECS Fargate**: Serverless container platform running application services
- **AWS Global Accelerator**: Global traffic routing and performance optimization
- **Cloudflare**: DNS management and routing
- **AWS SSM Parameter Store**: Stores application configuration and secrets
- **CloudWatch**: Centralized logging and monitoring
## System Layout
### Service Architecture
The Infisical application runs as multiple containerized services on ECS:
- **Main Server**: Auto-scaling containerized application services
- **License API**: Dedicated service with separate infrastructure (shared globally)
- **Monitoring**: AWS OTel Collector and Datadog Agent sidecars
Container images are pulled from Docker Hub and managed via GitHub Actions for deployments.
### Network Configuration
Services are deployed in private subnets with the following connectivity:
- External traffic → Application Load Balancer → ECS Services
- Main server exposes port 8080
- License API exposes port 4000 (portal.infisical.com, license.infisical.com)
- Service-to-service communication via AWS Service Connect
### Data Flow
1. **DNS resolution** via Cloudflare routes traffic to AWS Global Accelerator
2. **Global Accelerator** optimizes routing to the nearest AWS region
3. **Client requests** are routed through the Application Load Balancer to ECS containers
4. **Application logic** processes requests in the Infisical Core service
5. **Data persistence** occurs via encrypted connections to RDS
6. **Caching** utilizes ElastiCache for performance optimization
7. **Configuration** is retrieved from AWS SSM Parameter Store
## Regional Deployments
Each region operates in a separate AWS account, providing strong isolation boundaries for security, compliance, and operational independence.
### US Cloud (us.infisical.com or app.infisical.com)
- **AWS Account**: Dedicated US AWS account
- **Infrastructure**: ECS-based containerized deployment
- **Monitoring**: Integrated with Datadog for observability and security monitoring
### EU Cloud (eu.infisical.com)
- **AWS Account**: Dedicated EU AWS account
- **Infrastructure**: ECS-based containerized deployment
- **Monitoring**: Integrated with Datadog for observability and security monitoring
## Configuration Management
Application configuration and secrets are managed through AWS SSM Parameter Store, with deployment automation handled via GitHub Actions.
## Monitoring and Observability
### Logging
- **CloudWatch**: 365-day retention for application logs
- **Health Checks**: HTTP endpoint monitoring for service health
### Metrics
- **AWS OTel Collector**: Prometheus metrics collection
- **Datadog Agent**: Application performance monitoring and infrastructure metrics
## Container Management
- **Images**: `infisical/staging_infisical` and `infisical/license-api` from Docker Hub
- **Deployment**: Automated via GitHub Actions updating SSM parameter for image tags
- **Registry Access**: Docker Hub credentials stored in AWS Secrets Manager
- **Platform**: ECS Fargate serverless container platform
## Security Overview
### Data Protection
- **Encryption**: All secrets encrypted at rest and in transit
- **Network Isolation**: Services deployed in private subnets with controlled access
- **Authentication**: API tokens and service accounts for secure access
- **Audit Logging**: Comprehensive audit trails for all secret operations
### Network Architecture
- **VPC Design**: Dedicated VPC with public and private subnets across multiple Availability Zones
- **NAT Gateway**: Controlled outbound connectivity from private subnets
- **Load Balancing**: Application Load Balancer with SSL termination and health checks
- **Security Groups**: Restrictive firewall rules and controlled network access
- **High Availability**: Multi-AZ deployment with automatic failover
- **Network Monitoring**: VPC Flow Logs with 365-day retention for traffic analysis

2215
docs/mint.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,3 @@
* {
border-radius: 0 !important;
}
#navbar .max-w-8xl {
max-width: 100%;
border-bottom: 1px solid #ebebeb;
@@ -30,20 +26,24 @@
}
#sidebar li > div.mt-2 {
border-radius: 0;
padding: 5px;
}
#sidebar li > a.text-primary {
border-radius: 0;
background-color: #FBFFCC;
border-left: 4px solid #EFFF33;
padding: 5px;
}
#sidebar li > a.mt-2 {
border-radius: 0;
padding: 5px;
}
#sidebar li > a.leading-6 {
border-radius: 0;
padding: 0px;
}
@@ -68,26 +68,65 @@
}
#content-area .mt-8 .block{
border-radius: 0;
border-width: 1px;
background-color: #FCFBFA;
border-color: #ebebeb;
}
/* #content-area:hover .mt-8 .block:hover{
border-radius: 0;
border-width: 1px;
background-color: #FDFFE5;
border-color: #EFFF33;
} */
#content-area .mt-8 .rounded-xl{
border-radius: 0;
}
#content-area .mt-8 .rounded-lg{
border-radius: 0;
}
#content-area .mt-6 .rounded-xl{
border-radius: 0;
}
#content-area .mt-6 .rounded-lg{
border-radius: 0;
}
#content-area .mt-6 .rounded-md{
border-radius: 0;
}
#content-area .mt-8 .rounded-md{
border-radius: 0;
}
#content-area div.my-4{
border-radius: 0;
border-width: 1px;
}
/* #content-area div.flex-1 {
#content-area div.flex-1 {
/* text-transform: uppercase; */
opacity: 0.8;
font-weight: 400;
} */
}
#content-area button {
border-radius: 0;
}
#content-area a {
border-radius: 0;
}
#content-area .not-prose {
border-radius: 0;
}
/* .eyebrow {
text-transform: uppercase;

View File

@@ -94,7 +94,7 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
className={twMerge(
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className,
isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""
isDisabled ? "pointer-events-none opacity-50" : ""
)}
>
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>

View File

@@ -16,6 +16,12 @@ export const ROUTE_PATHS = Object.freeze({
PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset"),
PasswordSetupPage: setRoute("/password-setup", "/_authenticate/password-setup")
},
Admin: {
IntegrationsPage: setRoute(
"/admin/integrations",
"/_authenticate/_inject-org-details/admin/_admin-layout/integrations"
)
},
Organization: {
Settings: {
OauthCallbackPage: setRoute(

View File

@@ -1,20 +1,12 @@
import { IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faArrowRightToBracket, faEdit } from "@fortawesome/free-solid-svg-icons";
import { PolicyType } from "@app/hooks/api/policies/enums";
export const policyDetails: Record<
PolicyType,
{ name: string; className: string; icon: IconDefinition }
> = {
export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
[PolicyType.AccessPolicy]: {
className: "bg-green/20 text-green",
name: "Access Policy",
icon: faArrowRightToBracket
className: "bg-lime-900 text-lime-100",
name: "Access Policy"
},
[PolicyType.ChangePolicy]: {
className: "bg-yellow/20 text-yellow",
name: "Change Policy",
icon: faEdit
className: "bg-indigo-900 text-indigo-100",
name: "Change Policy"
}
};

View File

@@ -65,11 +65,11 @@ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequests
const fetchApprovalRequests = async ({
projectSlug,
envSlug,
authorUserId
authorProjectMembershipId
}: TGetAccessApprovalRequestsDTO) => {
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
"/api/v1/access-approvals/requests",
{ params: { projectSlug, envSlug, authorUserId } }
{ params: { projectSlug, envSlug, authorProjectMembershipId } }
);
return data.requests.map((request) => ({
@@ -109,12 +109,12 @@ export const useGetAccessRequestsCount = ({
export const useGetAccessApprovalPolicies = ({
projectSlug,
envSlug,
authorUserId,
authorProjectMembershipId,
options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorUserId }),
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }),
...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
});
@@ -122,13 +122,16 @@ export const useGetAccessApprovalPolicies = ({
export const useGetAccessApprovalRequests = ({
projectSlug,
envSlug,
authorUserId,
authorProjectMembershipId,
options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, authorUserId),
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorUserId }),
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envSlug,
authorProjectMembershipId
),
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true),
placeholderData: (previousData) => previousData
enabled: Boolean(projectSlug) && (options?.enabled ?? true)
});

View File

@@ -148,7 +148,7 @@ export type TCreateAccessRequestDTO = {
export type TGetAccessApprovalRequestsDTO = {
projectSlug: string;
envSlug?: string;
authorUserId?: string;
authorProjectMembershipId?: string;
};
export type TGetAccessPolicyApprovalCountDTO = {

View File

@@ -56,6 +56,11 @@ export type TUpdateServerConfigDTO = {
microsoftTeamsAppId?: string;
microsoftTeamsClientSecret?: string;
microsoftTeamsBotId?: string;
gitHubAppConnectionClientId?: string;
gitHubAppConnectionClientSecret?: string;
gitHubAppConnectionSlug?: string;
gitHubAppConnectionId?: string;
gitHubAppConnectionPrivateKey?: string;
} & Partial<TServerConfig>;
export type TCreateAdminUserDTO = {
@@ -100,6 +105,13 @@ export type AdminIntegrationsConfig = {
clientSecret: string;
botId: string;
};
gitHubAppConnection: {
clientId: string;
clientSecret: string;
appSlug: string;
appId: string;
privateKey: string;
};
};
export type TGetServerRootKmsEncryptionDetails = {

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query";
import {
decryptAssymmetric,
@@ -25,11 +25,10 @@ export const secretApprovalRequestKeys = {
status,
committer,
offset,
limit,
search
limit
}: TGetSecretApprovalRequestList) =>
[
{ workspaceId, environment, status, committer, offset, limit, search },
{ workspaceId, environment, status, committer, offset, limit },
"secret-approval-requests"
] as const,
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
@@ -119,25 +118,23 @@ const fetchSecretApprovalRequestList = async ({
committer,
status = "open",
limit = 20,
offset = 0,
search = ""
offset
}: TGetSecretApprovalRequestList) => {
const { data } = await apiRequest.get<{
approvals: TSecretApprovalRequest[];
totalCount: number;
}>("/api/v1/secret-approval-requests", {
params: {
workspaceId,
environment,
committer,
status,
limit,
offset,
search
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>(
"/api/v1/secret-approval-requests",
{
params: {
workspaceId,
environment,
committer,
status,
limit,
offset
}
}
});
);
return data;
return data.approvals;
};
export const useGetSecretApprovalRequests = ({
@@ -146,32 +143,31 @@ export const useGetSecretApprovalRequests = ({
options = {},
status,
limit = 20,
offset = 0,
search,
committer
}: TGetSecretApprovalRequestList & TReactQueryOptions) =>
useQuery({
useInfiniteQuery({
initialPageParam: 0,
queryKey: secretApprovalRequestKeys.list({
workspaceId,
environment,
committer,
status,
limit,
search,
offset
status
}),
queryFn: () =>
queryFn: ({ pageParam }) =>
fetchSecretApprovalRequestList({
workspaceId,
environment,
status,
committer,
limit,
offset,
search
offset: pageParam
}),
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
placeholderData: (previousData) => previousData
getNextPageParam: (lastPage, pages) => {
if (lastPage.length && lastPage.length < limit) return undefined;
return lastPage?.length !== 0 ? pages.length * limit : undefined;
}
});
const fetchSecretApprovalRequestDetails = async ({

View File

@@ -113,7 +113,6 @@ export type TGetSecretApprovalRequestList = {
committer?: string;
limit?: number;
offset?: number;
search?: string;
};
export type TGetSecretApprovalRequestCount = {

View File

@@ -352,9 +352,9 @@ export const ProjectLayout = () => {
secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount
) && (
<Badge variant="primary" className="ml-1.5">
<span className="ml-2 rounded border border-primary-400 bg-primary-600 px-1 py-0.5 text-xs font-semibold text-black">
{pendingRequestsCount}
</Badge>
</span>
)}
</MenuItem>
)}

View File

@@ -0,0 +1,222 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaGithub } from "react-icons/fa";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input,
TextArea
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useUpdateServerConfig } from "@app/hooks/api";
import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types";
const gitHubAppFormSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
appSlug: z.string(),
appId: z.string(),
privateKey: z.string()
});
type TGitHubAppConnectionForm = z.infer<typeof gitHubAppFormSchema>;
type Props = {
adminIntegrationsConfig?: AdminIntegrationsConfig;
};
export const GitHubAppConnectionForm = ({ adminIntegrationsConfig }: Props) => {
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isGitHubAppClientSecretFocused, setIsGitHubAppClientSecretFocused] = useToggle();
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TGitHubAppConnectionForm>({
resolver: zodResolver(gitHubAppFormSchema)
});
const onSubmit = async (data: TGitHubAppConnectionForm) => {
await updateAdminServerConfig({
gitHubAppConnectionClientId: data.clientId,
gitHubAppConnectionClientSecret: data.clientSecret,
gitHubAppConnectionSlug: data.appSlug,
gitHubAppConnectionId: data.appId,
gitHubAppConnectionPrivateKey: data.privateKey
});
createNotification({
text: "Updated GitHub app connection configuration. It can take up to 5 minutes to take effect.",
type: "success"
});
};
useEffect(() => {
if (adminIntegrationsConfig) {
setValue("clientId", adminIntegrationsConfig.gitHubAppConnection.clientId);
setValue("clientSecret", adminIntegrationsConfig.gitHubAppConnection.clientSecret);
setValue("appSlug", adminIntegrationsConfig.gitHubAppConnection.appSlug);
setValue("appId", adminIntegrationsConfig.gitHubAppConnection.appId);
setValue("privateKey", adminIntegrationsConfig.gitHubAppConnection.privateKey);
}
}, [adminIntegrationsConfig]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="github-app-integration" className="data-[state=open]:border-none">
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<FaGithub className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">GitHub App</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
<div className="flex w-full flex-col justify-start rounded-md rounded-t-none border border-t-0 border-mineshaft-500 bg-mineshaft-700 px-4 py-4">
<div className="mb-2 max-w-lg text-sm text-mineshaft-300">
Step 1: Create and configure GitHub App. Please refer to the documentation below for
more information.
</div>
<div className="mb-6">
<a
href="https://infisical.com/docs/integrations/app-connections/github#self-hosted-instance"
target="_blank"
rel="noopener noreferrer"
>
<Button colorSchema="secondary">Documentation</Button>
</a>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable GitHub App connections. Copy
the credentials from your GitHub App&apos;s settings page.
</div>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type="text"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
tooltipText="You can find your Client Secret in the GitHub App's settings under 'Client secrets'."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isGitHubAppClientSecretFocused ? "text" : "password"}
onFocus={() => setIsGitHubAppClientSecretFocused.on()}
onBlur={() => setIsGitHubAppClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="appSlug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App Slug"
tooltipText="The GitHub App slug from the app's URL (e.g., 'my-app' from github.com/apps/my-app)."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type="text"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="App ID"
tooltipText="The numeric App ID found in your GitHub App's settings."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type="text"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Private Key"
tooltipText="The private key generated for your GitHub App (PEM format)."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea
{...field}
value={field.value || ""}
className="min-h-32"
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</form>
);
};

View File

@@ -1,24 +1,94 @@
import { useGetAdminIntegrationsConfig } from "@app/hooks/api";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useGetAdminIntegrationsConfig } from "@app/hooks/api";
import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types";
import { GitHubAppConnectionForm } from "./GitHubAppConnectionForm";
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
enum IntegrationTabSections {
Workflow = "workflow",
AppConnections = "app-connections"
}
interface WorkflowTabProps {
adminIntegrationsConfig: AdminIntegrationsConfig;
}
interface AppConnectionsTabProps {
adminIntegrationsConfig: AdminIntegrationsConfig;
}
const WorkflowTab = ({ adminIntegrationsConfig }: WorkflowTabProps) => (
<div className="flex flex-col gap-2">
<SlackIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
<MicrosoftTeamsIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
);
const AppConnectionsTab = ({ adminIntegrationsConfig }: AppConnectionsTabProps) => (
<div className="flex flex-col gap-2">
<GitHubAppConnectionForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
);
export const IntegrationsPageForm = () => {
const { data: adminIntegrationsConfig } = useGetAdminIntegrationsConfig();
const navigate = useNavigate({
from: ROUTE_PATHS.Admin.IntegrationsPage.path
});
const selectedTab = useSearch({
from: ROUTE_PATHS.Admin.IntegrationsPage.id,
select: (el: { selectedTab?: string }) => el.selectedTab || IntegrationTabSections.Workflow,
structuralSharing: true
});
const updateSelectedTab = (tab: string) => {
navigate({
search: { selectedTab: tab }
});
};
const tabSections = [
{
key: IntegrationTabSections.Workflow,
label: "Workflows",
component: WorkflowTab
},
{
key: IntegrationTabSections.AppConnections,
label: "App Connections",
component: AppConnectionsTab
}
];
return (
<div className="mb-6 min-h-64 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<div className="text-xl font-semibold text-mineshaft-100">Integrations</div>
<div className="text-sm text-mineshaft-300">
Configure your instance-wide settings to enable integration with Slack and Microsoft
Teams.
Configure your instance-wide settings to enable integration with third-party services.
</div>
</div>
<div className="flex flex-col gap-2">
<SlackIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
<MicrosoftTeamsIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
{tabSections.map((section) => (
<Tab value={section.key} key={`integration-tab-${section.key}`}>
{section.label}
</Tab>
))}
</TabList>
{tabSections.map(({ key, component: Component }) => (
<TabPanel value={key} key={`integration-tab-panel-${key}`}>
<Component adminIntegrationsConfig={adminIntegrationsConfig!} />
</TabPanel>
))}
</Tabs>
</div>
);
};

View File

@@ -75,7 +75,7 @@ export const MicrosoftTeamsIntegrationForm = ({ adminIntegrationsConfig }: Props
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<BsMicrosoftTeams className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">Microsoft Teams Integration</div>
<div className="text-[15px] font-semibold">Microsoft Teams</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">

View File

@@ -108,7 +108,7 @@ export const SlackIntegrationForm = ({ adminIntegrationsConfig }: Props) => {
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<BsSlack className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">Slack Integration</div>
<div className="text-[15px] font-semibold">Slack</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">

View File

@@ -111,10 +111,10 @@ export const GitHubConnectionForm = ({ appConnection }: Props) => {
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
? `Credentials have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See Docs to configure GitHub ${methodDetails.name} Connections.`
: `See Docs to configure Github ${methodDetails.name} Connections.`
}`
: error?.message
}

View File

@@ -1,5 +1,7 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -43,7 +45,21 @@ export const SecretApprovalsPage = () => {
<PageHeader
title="Approval Workflows"
description="Create approval policies for any modifications to secrets in sensitive environments and folders."
/>
>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</PageHeader>
<Tabs defaultValue={defaultTab}>
<TabList>
<Tab value={TabSection.SecretApprovalRequests}>

View File

@@ -2,25 +2,15 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useMemo, useState } from "react";
import {
faArrowUpRightFromSquare,
faBan,
faBookOpen,
faCheck,
faCheckCircle,
faChevronDown,
faClipboardCheck,
faLock,
faMagnifyingGlass,
faPlus,
faSearch,
faStopwatch,
faUser,
IconDefinition
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import {
@@ -31,8 +21,6 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Input,
Pagination,
Tooltip
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -44,12 +32,7 @@ import {
useUser,
useWorkspace
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { usePopUp } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import {
accessApprovalKeys,
@@ -65,21 +48,28 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { RequestAccessModal } from "./components/RequestAccessModal";
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
const generateRequestText = (request: TAccessApprovalRequest) => {
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => {
const { isTemporary } = request;
return (
<div className="flex items-center justify-between text-sm">
<div className="flex w-full items-center justify-between text-sm">
<div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
<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 bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
</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.requestedByUserId === userId && (
<span className="text-xs text-gray-500">
<Badge className="ml-1">Requested By You</Badge>
</span>
)}
</div>
</div>
);
};
@@ -130,64 +120,30 @@ export const AccessApprovalRequest = ({
projectSlug
});
const {
data: requests,
refetch: refetchRequests,
isPending: areRequestsPending
} = useGetAccessApprovalRequests({
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({
projectSlug,
authorUserId: requestedByFilter,
authorProjectMembershipId: requestedByFilter,
envSlug: envFilter
});
const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", {
initPerPage: getUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("accessRequestsTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRequests = useMemo(() => {
let accessRequests: typeof requests;
if (statusFilter === "open")
accessRequests = requests?.filter(
return requests?.filter(
(request) =>
!request.policy.deletedAt &&
!request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
if (statusFilter === "close")
accessRequests = requests?.filter(
return requests?.filter(
(request) =>
request.policy.deletedAt ||
request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
);
return (
accessRequests?.filter((request) => {
const { environmentName, requestedByUser } = request;
const searchValue = search.trim().toLowerCase();
return (
environmentName?.toLowerCase().includes(searchValue) ||
`${requestedByUser?.email ?? ""} ${requestedByUser?.firstName ?? ""} ${requestedByUser?.lastName ?? ""}`
.toLowerCase()
.includes(searchValue)
);
}) ?? []
);
}, [requests, statusFilter, requestedByFilter, envFilter, search]);
useResetPageHelper({
totalCount: filteredRequests.length,
offset,
setPage
});
return requests;
}, [requests, statusFilter, requestedByFilter, envFilter]);
const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => {
@@ -206,15 +162,9 @@ export const AccessApprovalRequest = ({
const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
let displayData: {
label: string;
type: "primary" | "danger" | "success";
tooltipContent?: string;
icon: IconDefinition | null;
} = {
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
label: "",
type: "primary",
icon: null
type: "primary"
};
const isExpired =
@@ -222,42 +172,20 @@ export const AccessApprovalRequest = ({
request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired)
displayData = {
label: "Access Expired",
type: "danger",
icon: faStopwatch,
tooltipContent: request.privilege?.temporaryAccessEndTime
? `Expired ${format(request.privilege.temporaryAccessEndTime, "M/d/yyyy h:mm aa")}`
: undefined
};
else if (isAccepted)
displayData = {
label: "Access Granted",
type: "success",
icon: faCheck,
tooltipContent: `Granted ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
};
else if (isRejectedByAnyone)
displayData = {
label: "Rejected",
type: "danger",
icon: faBan,
tooltipContent: `Rejected ${format(request.updatedAt, "M/d/yyyy h:mm aa")}`
};
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",
icon: faClipboardCheck
type: "primary"
};
} else if (!isReviewedByUser)
displayData = {
label: "Review Required",
type: "primary",
icon: faClipboardCheck
type: "primary"
};
return {
@@ -297,71 +225,47 @@ export const AccessApprovalRequest = ({
[generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen]
);
const isFiltered = Boolean(search || envFilter || requestedByFilter);
return (
<AnimatePresence mode="wait">
<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="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
<a
href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Request and review access to secrets in sensitive environments and folders
</p>
</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");
}}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={policiesLoading || !policies?.length}
>
Request Access
</Button>
</Tooltip>
<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>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search approval requests by requesting user or environment..."
className="flex-1"
containerClassName="mb-4"
/>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
</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}
@@ -369,19 +273,17 @@ export const AccessApprovalRequest = ({
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={twMerge(
"font-medium",
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
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={twMerge(
"font-medium",
statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
className={
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
}
role="button"
tabIndex={0}
onClick={() => setStatusFilter("close")}
@@ -390,7 +292,7 @@ export const AccessApprovalRequest = ({
}}
>
<FontAwesomeIcon icon={faCheck} className="mr-2" />
{!!requestCount && requestCount.finalizedCount} Closed
{!!requestCount && requestCount.finalizedCount} Completed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
@@ -398,20 +300,14 @@ export const AccessApprovalRequest = ({
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"}
className="text-bunker-300"
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
>
Environments
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
<DropdownMenuContent>
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -441,27 +337,15 @@ export const AccessApprovalRequest = ({
Requested By
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select Requesting User
</DropdownMenuLabel>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
{members?.map(({ user: membershipUser, id }) => (
<DropdownMenuItem
onClick={() =>
setRequestedByFilter((state) =>
state === membershipUser.id ? undefined : membershipUser.id
)
setRequestedByFilter((state) => (state === id ? undefined : id))
}
key={`request-filter-member-${id}`}
icon={
requestedByFilter === membershipUser.id && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
{membershipUser.username}
@@ -473,26 +357,19 @@ export const AccessApprovalRequest = ({
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{filteredRequests?.length === 0 && !isFiltered && (
{filteredRequests?.length === 0 && (
<div className="py-12">
<EmptyState
title={`No ${statusFilter === "open" ? "Pending" : "Closed"} Access Requests`}
/>
</div>
)}
{Boolean(!filteredRequests?.length && isFiltered && !areRequestsPending) && (
<div className="py-12">
<EmptyState title="No Requests Match Filters" icon={faSearch} />
<EmptyState title="No more access requests pending." />
</div>
)}
{!!filteredRequests?.length &&
filteredRequests?.slice(offset, perPage * page).map((request) => {
filteredRequests?.map((request) => {
const details = generateRequestDetails(request);
return (
<div
key={request.id}
className="flex w-full cursor-pointer border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700 aria-disabled:opacity-80"
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button"
tabIndex={0}
onClick={() => handleSelectRequest(request)}
@@ -502,18 +379,14 @@ export const AccessApprovalRequest = ({
}
}}
>
<div className="flex w-full items-center justify-between">
<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}
size="xs"
className="mr-1.5 text-mineshaft-300"
/>
{generateRequestText(request)}
<FontAwesomeIcon icon={faLock} className="mr-2" />
{generateRequestText(request, user.id)}
</div>
<div className="flex items-center justify-between">
<div className="text-xs leading-3 text-gray-500">
<div className="text-xs text-gray-500">
{membersGroupById?.[request.requestedByUserId]?.user && (
<>
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
@@ -524,86 +397,61 @@ export const AccessApprovalRequest = ({
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{request.requestedByUserId === user.id && (
<div className="flex items-center gap-1.5 whitespace-nowrap text-xs text-bunker-300">
<FontAwesomeIcon icon={faUser} size="sm" />
<span>Requested By You</span>
</div>
)}
<Tooltip content={details.displayData.tooltipContent}>
<div>
<Badge
className="flex items-center gap-1.5 whitespace-nowrap"
variant={details.displayData.type}
>
{details.displayData.icon && (
<FontAwesomeIcon icon={details.displayData.icon} />
)}
<span>{details.displayData.label}</span>
<Badge variant={details.displayData.type}>
{details.displayData.label}
</Badge>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
);
})}
{Boolean(filteredRequests.length) && (
<Pagination
className="border-none"
count={filteredRequests.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</div>
{!!policies && (
<RequestAccessModal
policies={policies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envFilter,
requestedByFilter
)
});
handlePopUpClose("requestAccess");
}}
/>
)}
</motion.div>
</AnimatePresence>
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
refetchRequests();
}}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
{!!policies && (
<RequestAccessModal
policies={policies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
queryClient.invalidateQueries({
queryKey: accessApprovalKeys.getAccessApprovalRequests(
projectSlug,
envFilter,
requestedByFilter
)
});
handlePopUpClose("requestAccess");
}}
/>
</motion.div>
</AnimatePresence>
)}
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
setSelectedRequest(null);
refetchRequests();
}}
canBypass={generateRequestDetails(selectedRequest).canBypass}
/>
)}
<UpgradePlanModal
text="You need to upgrade your plan to access this feature"
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")}
/>
</div>
);
};

View File

@@ -1,19 +1,11 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCheckCircle,
faChevronDown,
faFileShield,
faFilter,
faMagnifyingGlass,
faPlus,
faSearch
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
@@ -27,9 +19,8 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
@@ -47,12 +38,7 @@ import {
useWorkspace
} from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { usePopUp } from "@app/hooks";
import {
useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy,
@@ -61,7 +47,6 @@ import {
useListWorkspaceGroups
} from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
@@ -72,18 +57,6 @@ interface IProps {
workspaceId: string;
}
enum PolicyOrderBy {
Name = "name",
Environment = "environment",
SecretPath = "secret-path",
Type = "type"
}
type PolicyFilters = {
type: null | PolicyType;
environmentIds: string[];
};
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
{
@@ -139,79 +112,11 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
currentWorkspace
);
const [filters, setFilters] = useState<PolicyFilters>({
type: null,
environmentIds: []
});
const [filterType, setFilterType] = useState<string | null>(null);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<PolicyOrderBy>(PolicyOrderBy.Name, {
initPerPage: getUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("approvalPoliciesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredPolicies = useMemo(
() =>
policies
.filter(({ policyType, environment, name, secretPath }) => {
if (filters.type && policyType !== filters.type) return false;
if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id))
return false;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) ||
environment.name.toLowerCase().includes(searchValue) ||
(secretPath ?? "*").toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [policyOne, policyTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case PolicyOrderBy.Type:
return policyOne.policyType
.toLowerCase()
.localeCompare(policyTwo.policyType.toLowerCase());
case PolicyOrderBy.Environment:
return policyOne.environment.name
.toLowerCase()
.localeCompare(policyTwo.environment.name.toLowerCase());
case PolicyOrderBy.SecretPath:
return (policyOne.secretPath ?? "*")
.toLowerCase()
.localeCompare((policyTwo.secretPath ?? "*").toLowerCase());
case PolicyOrderBy.Name:
default:
return policyOne.name.toLowerCase().localeCompare(policyTwo.name.toLowerCase());
}
}),
[policies, filters, search, orderBy, orderDirection]
);
useResetPageHelper({
totalCount: filteredPolicies.length,
offset,
setPage
});
const filteredPolicies = useMemo(() => {
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies;
}, [policies, filterType]);
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
@@ -246,288 +151,144 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
}
};
const isTableFiltered = filters.type !== null || Boolean(filters.environmentIds.length);
const handleSort = (column: PolicyOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: PolicyOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: PolicyOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<AnimatePresence mode="wait">
<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="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Policies</p>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Implement granular policies for access requests and secrets management
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create Policy
</Button>
)}
</ProjectPermissionCan>
<div>
<div className="mb-6 flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
<div className="mt-2 text-sm text-bunker-300">
Implement granular policies for access requests and secrets management.
</div>
<div className="mb-4 flex items-center gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search policies by name, type, environment or secret path..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter findings"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Policy Type</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: null
}))
}
icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
All
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.AccessPolicy
}))
}
icon={
filters.type === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.ChangePolicy
}))
}
icon={
filters.type === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{currentWorkspace.environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Environment
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Secret Path
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Type
<IconButton
variant="plain"
className={getClassName(PolicyOrderBy.Type)}
ariaLabel="sort"
onClick={() => handleSort(PolicyOrderBy.Type)}
>
<FontAwesomeIcon icon={getColSortIcon(PolicyOrderBy.Type)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton
columns={5}
innerKey="secret-policies"
className="bg-mineshaft-700"
/>
)}
{!isPoliciesLoading && !policies?.length && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No Policies Found" icon={faFileShield} />
</Td>
</Tr>
)}
{!!currentWorkspace &&
filteredPolicies
?.slice(offset, perPage * page)
.map((policy) => (
<ApprovalPolicyRow
policy={policy}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
{Boolean(!filteredPolicies.length && policies.length && !isPoliciesLoading) && (
<EmptyState title="No Policies Match Search" icon={faSearch} />
)}
{Boolean(filteredPolicies.length) && (
<Pagination
count={filteredPolicies.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</TableContainer>
</div>
</motion.div>
<AccessPolicyForm
projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug}
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.secretApproval) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("policyForm");
}}
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>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className="text-xs font-semibold uppercase text-bunker-300"
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Type
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setFilterType(null)}
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
All
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.AccessPolicy)}
icon={
filterType === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Access Policy
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFilterType(PolicyType.ChangePolicy)}
icon={
filterType === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right"
>
Change Policy
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
)}
{!isPoliciesLoading && !filteredPolicies?.length && (
<Tr>
<Td colSpan={6}>
<EmptyState title="No policies found" icon={faFileShield} />
</Td>
</Tr>
)}
{!!currentWorkspace &&
filteredPolicies?.map((policy) => (
<ApprovalPolicyRow
policy={policy}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>
))}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp.policyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
members={members}
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
/>
onOpenChange={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
>
<ModalContent
className="max-w-3xl"
title={
popUp.policyForm.data
? `Edit ${popUp?.policyForm?.data?.name || "Policy"}`
: "Create Policy"
}
id="policy-form"
>
<AccessPolicyForm
projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug}
isOpen={popUp.policyForm.isOpen}
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
members={members}
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove"
@@ -540,6 +301,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/>
</AnimatePresence>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { RefObject, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -13,8 +13,6 @@ import {
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch,
@@ -112,20 +110,20 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>;
const Form = ({
export const AccessPolicyForm = ({
isOpen,
onToggle,
members = [],
projectId,
projectSlug,
editValues,
modalContainer,
isEditMode
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
editValues
}: Props) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const {
control,
handleSubmit,
reset,
watch,
formState: { isSubmitting }
} = useForm<TFormSchema>({
@@ -190,8 +188,13 @@ const Form = ({
const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
@@ -384,7 +387,6 @@ const Form = ({
setDraggedItem(null);
setDragOverItem(null);
};
return (
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
@@ -570,7 +572,7 @@ const Form = ({
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPortalTarget={document.getElementById("policy-form")}
menuPlacement="top"
isMulti
placeholder="Select members..."
@@ -600,7 +602,7 @@ const Form = ({
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPortalTarget={document.getElementById("policy-form")}
menuPlacement="top"
isMulti
placeholder="Select groups..."
@@ -811,27 +813,3 @@ const Form = ({
</div>
);
};
export const AccessPolicyForm = ({ isOpen, onToggle, editValues, ...props }: Props) => {
const modalContainer = useRef<HTMLDivElement>(null);
const isEditMode = Boolean(editValues);
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-3xl"
ref={modalContainer}
title={isEditMode ? "Edit Policy" : "Create Policy"}
>
<Form
{...props}
isOpen={isOpen}
onToggle={onToggle}
editValues={editValues}
modalContainer={modalContainer}
isEditMode={isEditMode}
/>
</ModalContent>
</Modal>
);
};

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -9,8 +9,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
GenericFieldLabel,
IconButton,
Td,
Tr
} from "@app/components/v2";
@@ -82,11 +80,11 @@ export const ApprovalPolicyRow = ({
userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member))
.join(", "),
.join(","),
groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name)
.join(", "),
.join(","),
approvals: el.approvals
};
});
@@ -104,47 +102,36 @@ export const ApprovalPolicyRow = ({
}}
onClick={() => setIsExpanded.toggle()}
>
<Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
<Td>{policy.environment.name}</Td>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<Badge
className={twMerge(
policyDetails[policy.policyType].className,
"flex w-min items-center gap-1.5 whitespace-nowrap"
)}
>
<FontAwesomeIcon icon={policyDetails[policy.policyType].icon} />
<span>{policyDetails[policy.policyType].name}</span>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
@@ -156,12 +143,16 @@ export const ApprovalPolicyRow = ({
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
@@ -171,41 +162,45 @@ export const ApprovalPolicyRow = ({
</DropdownMenu>
</Td>
</Tr>
<Tr>
<Td colSpan={6} className="!border-none p-0">
<div
className={`w-full overflow-hidden bg-mineshaft-900/75 transition-all duration-500 ease-in-out ${
isExpanded ? "thin-scrollbar max-h-[26rem] !overflow-y-auto opacity-100" : "max-h-0"
}`}
>
<div className="p-4">
<div className="mb-4 border-b-2 border-mineshaft-500 pb-2">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
>
<div className="my-auto mr-8 flex h-8 w-8 items-center justify-center rounded border border-mineshaft-400 bg-bunker-500/50 text-white">
<div>{index + 1}</div>
{isExpanded && (
<Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
<div className="text-lg">{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
)}
{index !== 0 && (
<div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" />
)}
<div className="grid flex-grow grid-cols-3">
<GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>{el.userLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
</div>
</div>
))}
</div>
</div>
</Td>
</Tr>
</div>
))}
</Td>
</Tr>
)}
</>
);
};

View File

@@ -1,19 +1,14 @@
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import {
faArrowUpRightFromSquare,
faBookOpen,
faCheck,
faCheckCircle,
faChevronDown,
faCodeBranch,
faMagnifyingGlass,
faSearch
faCodeBranch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSearch } from "@tanstack/react-router";
import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import {
Button,
@@ -23,8 +18,6 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
Input,
Pagination,
Skeleton
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
@@ -35,12 +28,6 @@ import {
useUser,
useWorkspace
} from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination } from "@app/hooks";
import {
useGetSecretApprovalRequestCount,
useGetSecretApprovalRequests,
@@ -65,41 +52,18 @@ export const SecretApprovalRequest = () => {
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
const {
debouncedSearch: debouncedSearchFilter,
search: searchFilter,
setSearch: setSearchFilter,
setPage,
page,
perPage,
setPerPage,
offset,
limit
} = usePagination("", {
initPerPage: getUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("changeRequestsTable", PreferenceKey.PerPage, newPerPage);
};
const {
data,
data: secretApprovalRequests,
isFetchingNextPage: isFetchingNextApprovalRequest,
fetchNextPage: fetchNextApprovalRequest,
hasNextPage: hasNextApprovalPage,
isPending: isApprovalRequestLoading,
refetch
} = useGetSecretApprovalRequests({
workspaceId,
status: statusFilter,
environment: envFilter,
committer: committerFilter,
search: debouncedSearchFilter,
limit,
offset
committer: committerFilter
});
const totalApprovalCount = data?.totalCount ?? 0;
const secretApprovalRequests = data?.approvals ?? [];
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId });
const { user: userSession } = useUser();
@@ -124,9 +88,8 @@ export const SecretApprovalRequest = () => {
refetch();
};
const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0;
const isFiltered = Boolean(searchFilter || envFilter || committerFilter);
const isRequestListEmpty =
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
return (
<AnimatePresence mode="wait">
@@ -153,233 +116,178 @@ export const SecretApprovalRequest = () => {
exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Change Requests</p>
<a
href="https://infisical.com/docs/documentation/platform/pr-workflows"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">Review pending and closed change requests</p>
</div>
<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={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<Input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search change requests by author, environment or policy path..."
className="flex-1"
containerClassName="mb-4"
/>
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 px-8 py-3 text-sm">
<div
role="button"
tabIndex={0}
onClick={() => setStatusFilter("open")}
onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open");
}}
className={twMerge(
"font-medium",
statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
>
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div>
<div
className={twMerge(
"font-medium",
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" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<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" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
</div>
<div className="flex flex-grow justify-end space-x-8">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "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(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"}
className={committerFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Environments
Author
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Environment
</DropdownMenuLabel>
{currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
{members?.map(({ user, id }) => (
<DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
key={`request-filter-${slug}`}
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
onClick={() =>
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
{name}
{user.username}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
variant="plain"
colorSchema="secondary"
className={committerFilter ? "text-white" : "text-bunker-300"}
rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
>
Author
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="thin-scrollbar max-h-[20rem] overflow-y-auto"
>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Select an Author
</DropdownMenuLabel>
{members?.map(({ user, id }) => (
<DropdownMenuItem
onClick={() =>
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
}
key={`request-filter-member-${id}`}
icon={
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
}
iconPos="right"
>
{user.username}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && !isFiltered && (
<div className="py-12">
<EmptyState
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
/>
</div>
)}
{secretApprovalRequests.map((secretApproval) => {
const {
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1 text-sm">
<FontAwesomeIcon
icon={faCodeBranch}
size="sm"
className="mr-1.5 text-mineshaft-300"
/>
{secretApproval.isReplicated
? `${commits.length} secret pending import`
: generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs leading-3 text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email})
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
{Boolean(
!secretApprovalRequests.length && isFiltered && !isApprovalRequestLoading
) && (
<div className="py-12">
<EmptyState title="No Requests Match Filters" icon={faSearch} />
</div>
)}
{Boolean(totalApprovalCount) && (
<Pagination
className="border-none"
count={totalApprovalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{isApprovalRequestLoading && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && (
<div className="py-12">
<EmptyState title="No more requests pending." />
</div>
)}
{secretApprovalRequests?.pages?.map((group, i) => (
<Fragment key={`secret-approval-request-${i + 1}`}>
{group?.map((secretApproval) => {
const {
id: reqId,
commits,
createdAt,
reviewers,
status,
committerUser
} = secretApproval;
const isReviewed = reviewers.some(
({ status: reviewStatus, userId }) =>
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
);
return (
<div
key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)}
onKeyDown={(evt) => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}}
>
<div className="mb-1">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{secretApproval.isReplicated
? `${commits.length} secret pending import`
: generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div>
<span className="text-xs text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email})
{!isReviewed && status === "open" && " - Review required"}
</span>
</div>
);
})}
</Fragment>
))}
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
<div>
{Array.apply(0, Array(3)).map((_x, index) => (
<div
key={`approval-request-loading-${index + 1}`}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
>
<div className="mb-2 flex items-center">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
<Skeleton className="w-1/4 bg-mineshaft-600" />
</div>
<Skeleton className="w-1/2 bg-mineshaft-600" />
</div>
))}
</div>
)}
</div>
{hasNextApprovalPage && (
<Button
className="mt-4 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextApprovalRequest}
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
onClick={() => fetchNextApprovalRequest()}
>
{hasNextApprovalPage ? "Load More" : "End of history"}
</Button>
)}
</motion.div>
)}
</AnimatePresence>

View File

@@ -56,24 +56,27 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
if (score[CommitType.CREATE])
text.push(
<span key="created-commit">
{score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
<span className="text-green-600"> Created</span>
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#60DD00" }}> created</span>
</span>
);
if (score[CommitType.UPDATE])
text.push(
<span key="updated-commit">
{Boolean(text.length) && ", "}
{score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
<span className="text-yellow-600"> Updated</span>
{Boolean(text.length) && ","}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F8EB30" }} className="text-orange-600">
{" "}
updated
</span>
</span>
);
if (score[CommitType.DELETE])
text.push(
<span className="deleted-commit">
{Boolean(text.length) && "and"}
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
<span className="text-red-600"> Deleted</span>
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F83030" }}> deleted</span>
</span>
);
return text;

View File

@@ -36,13 +36,10 @@ const formSchema = z.object({
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional(),
tags: z
.array(
z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
)
.optional()
.array(
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) })
)
.optional()
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
@@ -54,13 +51,10 @@ const formSchema = z.object({
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional(),
tags: z
.array(
z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
)
.optional()
.array(
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) })
)
.optional()
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {

View File

@@ -25,8 +25,8 @@ const formSchema = z.object({
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional(),
tags: z
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional()
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional(),
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
@@ -38,8 +38,8 @@ const formSchema = z.object({
userGroups: z.string().trim().optional(),
policyArns: z.string().trim().optional(),
tags: z
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional()
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional()
})
]),
defaultTTL: z.string().superRefine((val, ctx) => {
@@ -97,7 +97,7 @@ export const EditDynamicSecretAwsIamForm = ({
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
},
}
});
const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey;
@@ -125,7 +125,8 @@ export const EditDynamicSecretAwsIamForm = ({
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName,
usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
usernameTemplate:
!usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
}
});
onClose();