Merge remote-tracking branch 'origin/main' into feat/add-cloudflare-app-connection-and-sync

This commit is contained in:
Sheen Capadngan
2025-06-23 22:16:09 +08:00
27 changed files with 3465 additions and 2922 deletions
@@ -89,7 +89,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
schema: { schema: {
querystring: z.object({ querystring: z.object({
projectSlug: z.string().trim(), projectSlug: z.string().trim(),
authorProjectMembershipId: z.string().trim().optional(), authorUserId: z.string().trim().optional(),
envSlug: z.string().trim().optional() envSlug: z.string().trim().optional()
}), }),
response: { response: {
@@ -143,7 +143,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
handler: async (req) => { handler: async (req) => {
const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({ const { requests } = await server.services.accessApprovalRequest.listApprovalRequests({
projectSlug: req.query.projectSlug, projectSlug: req.query.projectSlug,
authorProjectMembershipId: req.query.authorProjectMembershipId, authorUserId: req.query.authorUserId,
envSlug: req.query.envSlug, envSlug: req.query.envSlug,
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
@@ -30,6 +30,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
workspaceId: z.string().trim(), workspaceId: z.string().trim(),
environment: z.string().trim().optional(), environment: z.string().trim().optional(),
committer: z.string().trim().optional(), committer: z.string().trim().optional(),
search: z.string().trim().optional(),
status: z.nativeEnum(RequestState).optional(), status: z.nativeEnum(RequestState).optional(),
limit: z.coerce.number().default(20), limit: z.coerce.number().default(20),
offset: z.coerce.number().default(0) offset: z.coerce.number().default(0)
@@ -66,13 +67,14 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
userId: z.string().nullable().optional() userId: z.string().nullable().optional()
}) })
.array() .array()
}).array() }).array(),
totalCount: z.number()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const approvals = await server.services.secretApprovalRequest.getSecretApprovals({ const { approvals, totalCount } = await server.services.secretApprovalRequest.getSecretApprovals({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
@@ -80,7 +82,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
...req.query, ...req.query,
projectId: req.query.workspaceId projectId: req.query.workspaceId
}); });
return { approvals }; return { approvals, totalCount };
} }
}); });
@@ -725,16 +725,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
) )
.where(`${TableName.Environment}.projectId`, projectId) .where(`${TableName.Environment}.projectId`, projectId)
.where(`${TableName.AccessApprovalPolicy}.deletedAt`, null)
.select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"))
.select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId"))
.select(db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"));
const formattedRequests = sqlNestRelationships({ const formattedRequests = sqlNestRelationships({
data: accessRequests, data: accessRequests,
key: "id", key: "id",
parentMapper: (doc) => ({ parentMapper: (doc) => ({
...AccessApprovalRequestsSchema.parse(doc) ...AccessApprovalRequestsSchema.parse(doc),
isPolicyDeleted: Boolean(doc.policyDeletedAt)
}), }),
childrenMapper: [ childrenMapper: [
{ {
@@ -751,7 +752,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
(req) => (req) =>
!req.privilegeId && !req.privilegeId &&
!req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) &&
req.status === ApprovalStatus.PENDING req.status === ApprovalStatus.PENDING &&
!req.isPolicyDeleted
); );
// 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. // 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.
@@ -759,7 +761,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
(req) => (req) =>
req.privilegeId || req.privilegeId ||
req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) ||
req.status !== ApprovalStatus.PENDING req.status !== ApprovalStatus.PENDING ||
req.isPolicyDeleted
); );
return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length };
@@ -275,7 +275,7 @@ export const accessApprovalRequestServiceFactory = ({
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
projectSlug, projectSlug,
authorProjectMembershipId, authorUserId,
envSlug, envSlug,
actor, actor,
actorOrgId, actorOrgId,
@@ -300,8 +300,8 @@ export const accessApprovalRequestServiceFactory = ({
const policies = await accessApprovalPolicyDAL.find({ projectId: project.id }); const policies = await accessApprovalPolicyDAL.find({ projectId: project.id });
let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id)); let requests = await accessApprovalRequestDAL.findRequestsWithPrivilegeByPolicyIds(policies.map((p) => p.id));
if (authorProjectMembershipId) { if (authorUserId) {
requests = requests.filter((request) => request.requestedByUserId === actorId); requests = requests.filter((request) => request.requestedByUserId === authorUserId);
} }
if (envSlug) { if (envSlug) {
@@ -31,7 +31,7 @@ export type TCreateAccessApprovalRequestDTO = {
export type TListApprovalRequestsDTO = { export type TListApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
authorProjectMembershipId?: string; authorUserId?: string;
envSlug?: string; envSlug?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@@ -24,6 +24,7 @@ type TFindQueryFilter = {
committer?: string; committer?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string;
}; };
export const secretApprovalRequestDALFactory = (db: TDbClient) => { export const secretApprovalRequestDALFactory = (db: TDbClient) => {
@@ -314,7 +315,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId)
.orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId)
) )
.andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null))
.select("status", `${TableName.SecretApprovalRequest}.id`) .select("status", `${TableName.SecretApprovalRequest}.id`)
.groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .groupBy(`${TableName.SecretApprovalRequest}.id`, "status")
.count("status") .count("status")
@@ -340,13 +340,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
}; };
const findByProjectId = async ( const findByProjectId = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter,
tx?: Knex tx?: Knex
) => { ) => {
try { try {
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination // akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
// this is the place u wanna look at. // this is the place u wanna look at.
const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest) const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join( .join(
@@ -435,7 +435,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName") db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
) )
.orderBy("createdAt", "desc"); .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}%`);
});
}
const docs = await (tx || db) const docs = await (tx || db)
.with("w", query) .with("w", query)
@@ -443,6 +466,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.from<Awaited<typeof query>[number]>("w") .from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", offset) .where("w.rank", ">=", offset)
.andWhere("w.rank", "<", offset + limit); .andWhere("w.rank", "<", offset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({ const formattedDoc = sqlNestRelationships({
data: docs, data: docs,
key: "id", key: "id",
@@ -504,23 +531,26 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
} }
] ]
}); });
return formattedDoc.map((el) => ({ return {
approvals: formattedDoc.map((el) => ({
...el, ...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
})); })),
totalCount
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindSAR" }); throw new DatabaseError({ error, name: "FindSAR" });
} }
}; };
const findByProjectIdBridgeSecretV2 = async ( const findByProjectIdBridgeSecretV2 = async (
{ status, limit = 20, offset = 0, projectId, committer, environment, userId }: TFindQueryFilter, { status, limit = 20, offset = 0, projectId, committer, environment, userId, search }: TFindQueryFilter,
tx?: Knex tx?: Knex
) => { ) => {
try { try {
// akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination // akhilmhdh: If ever u wanted a 1 to so many relationship connected with pagination
// this is the place u wanna look at. // this is the place u wanna look at.
const query = (tx || db.replicaNode())(TableName.SecretApprovalRequest) const innerQuery = (tx || db.replicaNode())(TableName.SecretApprovalRequest)
.join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.join( .join(
@@ -609,14 +639,42 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser").as("committerUserLastName") db.ref("lastName").withSchema("committerUser").as("committerUserLastName")
) )
.orderBy("createdAt", "desc"); .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}%`);
});
}
const rankOffset = offset + 1;
const docs = await (tx || db) const docs = await (tx || db)
.with("w", query) .with("w", query)
.select("*") .select("*")
.from<Awaited<typeof query>[number]>("w") .from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", offset) .where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", offset + limit); .andWhere("w.rank", "<", rankOffset + limit);
// @ts-expect-error knex does not infer
const totalCount = Number(docs[0]?.total_count || 0);
const formattedDoc = sqlNestRelationships({ const formattedDoc = sqlNestRelationships({
data: docs, data: docs,
key: "id", key: "id",
@@ -682,10 +740,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
} }
] ]
}); });
return formattedDoc.map((el) => ({ return {
approvals: formattedDoc.map((el) => ({
...el, ...el,
policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers }
})); })),
totalCount
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindSAR" }); throw new DatabaseError({ error, name: "FindSAR" });
} }
@@ -194,7 +194,8 @@ export const secretApprovalRequestServiceFactory = ({
environment, environment,
committer, committer,
limit, limit,
offset offset,
search
}: TListApprovalsDTO) => { }: TListApprovalsDTO) => {
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
@@ -208,6 +209,7 @@ export const secretApprovalRequestServiceFactory = ({
}); });
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({ return secretApprovalRequestDAL.findByProjectIdBridgeSecretV2({
projectId, projectId,
@@ -216,19 +218,21 @@ export const secretApprovalRequestServiceFactory = ({
status, status,
userId: actorId, userId: actorId,
limit, limit,
offset offset,
search
}); });
} }
const approvals = await secretApprovalRequestDAL.findByProjectId({
return secretApprovalRequestDAL.findByProjectId({
projectId, projectId,
committer, committer,
environment, environment,
status, status,
userId: actorId, userId: actorId,
limit, limit,
offset offset,
search
}); });
return approvals;
}; };
const getSecretApprovalDetails = async ({ const getSecretApprovalDetails = async ({
@@ -93,6 +93,7 @@ export type TListApprovalsDTO = {
committer?: string; committer?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string;
} & TProjectPermission; } & TProjectPermission;
export type TSecretApprovalDetailsDTO = { export type TSecretApprovalDetailsDTO = {
+2247
View File
File diff suppressed because it is too large Load Diff
-2242
View File
File diff suppressed because it is too large Load Diff
+7 -46
View File
@@ -1,3 +1,7 @@
* {
border-radius: 0 !important;
}
#navbar .max-w-8xl { #navbar .max-w-8xl {
max-width: 100%; max-width: 100%;
border-bottom: 1px solid #ebebeb; border-bottom: 1px solid #ebebeb;
@@ -26,24 +30,20 @@
} }
#sidebar li > div.mt-2 { #sidebar li > div.mt-2 {
border-radius: 0;
padding: 5px; padding: 5px;
} }
#sidebar li > a.text-primary { #sidebar li > a.text-primary {
border-radius: 0;
background-color: #FBFFCC; background-color: #FBFFCC;
border-left: 4px solid #EFFF33; border-left: 4px solid #EFFF33;
padding: 5px; padding: 5px;
} }
#sidebar li > a.mt-2 { #sidebar li > a.mt-2 {
border-radius: 0;
padding: 5px; padding: 5px;
} }
#sidebar li > a.leading-6 { #sidebar li > a.leading-6 {
border-radius: 0;
padding: 0px; padding: 0px;
} }
@@ -68,65 +68,26 @@
} }
#content-area .mt-8 .block{ #content-area .mt-8 .block{
border-radius: 0;
border-width: 1px; border-width: 1px;
background-color: #FCFBFA; background-color: #FCFBFA;
border-color: #ebebeb; border-color: #ebebeb;
} }
/* #content-area:hover .mt-8 .block:hover{ /* #content-area:hover .mt-8 .block:hover{
border-radius: 0;
border-width: 1px; border-width: 1px;
background-color: #FDFFE5; background-color: #FDFFE5;
border-color: #EFFF33; 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{ #content-area div.my-4{
border-radius: 0;
border-width: 1px; border-width: 1px;
} }
#content-area div.flex-1 { /* #content-area div.flex-1 {
/* text-transform: uppercase; */
opacity: 0.8; opacity: 0.8;
font-weight: 400; font-weight: 400;
} } */
#content-area button {
border-radius: 0;
}
#content-area a {
border-radius: 0;
}
#content-area .not-prose {
border-radius: 0;
}
/* .eyebrow { /* .eyebrow {
text-transform: uppercase; text-transform: uppercase;
@@ -94,7 +94,7 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
className={twMerge( 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", "block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
className, className,
isDisabled ? "pointer-events-none opacity-50" : "" isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""
)} )}
> >
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}> <Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
+13 -5
View File
@@ -1,12 +1,20 @@
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"; import { PolicyType } from "@app/hooks/api/policies/enums";
export const policyDetails: Record<PolicyType, { name: string; className: string }> = { export const policyDetails: Record<
PolicyType,
{ name: string; className: string; icon: IconDefinition }
> = {
[PolicyType.AccessPolicy]: { [PolicyType.AccessPolicy]: {
className: "bg-lime-900 text-lime-100", className: "bg-green/20 text-green",
name: "Access Policy" name: "Access Policy",
icon: faArrowRightToBracket
}, },
[PolicyType.ChangePolicy]: { [PolicyType.ChangePolicy]: {
className: "bg-indigo-900 text-indigo-100", className: "bg-yellow/20 text-yellow",
name: "Change Policy" name: "Change Policy",
icon: faEdit
} }
}; };
@@ -65,11 +65,11 @@ const fetchApprovalPolicies = async ({ projectSlug }: TGetAccessApprovalRequests
const fetchApprovalRequests = async ({ const fetchApprovalRequests = async ({
projectSlug, projectSlug,
envSlug, envSlug,
authorProjectMembershipId authorUserId
}: TGetAccessApprovalRequestsDTO) => { }: TGetAccessApprovalRequestsDTO) => {
const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>( const { data } = await apiRequest.get<{ requests: TAccessApprovalRequest[] }>(
"/api/v1/access-approvals/requests", "/api/v1/access-approvals/requests",
{ params: { projectSlug, envSlug, authorProjectMembershipId } } { params: { projectSlug, envSlug, authorUserId } }
); );
return data.requests.map((request) => ({ return data.requests.map((request) => ({
@@ -109,12 +109,12 @@ export const useGetAccessRequestsCount = ({
export const useGetAccessApprovalPolicies = ({ export const useGetAccessApprovalPolicies = ({
projectSlug, projectSlug,
envSlug, envSlug,
authorProjectMembershipId, authorUserId,
options = {} options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) => }: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({ useQuery({
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug), queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug),
queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorProjectMembershipId }), queryFn: () => fetchApprovalPolicies({ projectSlug, envSlug, authorUserId }),
...options, ...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true) enabled: Boolean(projectSlug) && (options?.enabled ?? true)
}); });
@@ -122,16 +122,13 @@ export const useGetAccessApprovalPolicies = ({
export const useGetAccessApprovalRequests = ({ export const useGetAccessApprovalRequests = ({
projectSlug, projectSlug,
envSlug, envSlug,
authorProjectMembershipId, authorUserId,
options = {} options = {}
}: TGetAccessApprovalRequestsDTO & TReactQueryOptions) => }: TGetAccessApprovalRequestsDTO & TReactQueryOptions) =>
useQuery({ useQuery({
queryKey: accessApprovalKeys.getAccessApprovalRequests( queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, authorUserId),
projectSlug, queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorUserId }),
envSlug,
authorProjectMembershipId
),
queryFn: () => fetchApprovalRequests({ projectSlug, envSlug, authorProjectMembershipId }),
...options, ...options,
enabled: Boolean(projectSlug) && (options?.enabled ?? true) enabled: Boolean(projectSlug) && (options?.enabled ?? true),
placeholderData: (previousData) => previousData
}); });
@@ -148,7 +148,7 @@ export type TCreateAccessRequestDTO = {
export type TGetAccessApprovalRequestsDTO = { export type TGetAccessApprovalRequestsDTO = {
projectSlug: string; projectSlug: string;
envSlug?: string; envSlug?: string;
authorProjectMembershipId?: string; authorUserId?: string;
}; };
export type TGetAccessPolicyApprovalCountDTO = { export type TGetAccessPolicyApprovalCountDTO = {
@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-query"; import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { import {
decryptAssymmetric, decryptAssymmetric,
@@ -25,10 +25,11 @@ export const secretApprovalRequestKeys = {
status, status,
committer, committer,
offset, offset,
limit limit,
search
}: TGetSecretApprovalRequestList) => }: TGetSecretApprovalRequestList) =>
[ [
{ workspaceId, environment, status, committer, offset, limit }, { workspaceId, environment, status, committer, offset, limit, search },
"secret-approval-requests" "secret-approval-requests"
] as const, ] as const,
detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) => detail: ({ id }: Omit<TGetSecretApprovalRequestDetails, "decryptKey">) =>
@@ -118,23 +119,25 @@ const fetchSecretApprovalRequestList = async ({
committer, committer,
status = "open", status = "open",
limit = 20, limit = 20,
offset offset = 0,
search = ""
}: TGetSecretApprovalRequestList) => { }: TGetSecretApprovalRequestList) => {
const { data } = await apiRequest.get<{ approvals: TSecretApprovalRequest[] }>( const { data } = await apiRequest.get<{
"/api/v1/secret-approval-requests", approvals: TSecretApprovalRequest[];
{ totalCount: number;
}>("/api/v1/secret-approval-requests", {
params: { params: {
workspaceId, workspaceId,
environment, environment,
committer, committer,
status, status,
limit, limit,
offset offset,
search
} }
} });
);
return data.approvals; return data;
}; };
export const useGetSecretApprovalRequests = ({ export const useGetSecretApprovalRequests = ({
@@ -143,31 +146,32 @@ export const useGetSecretApprovalRequests = ({
options = {}, options = {},
status, status,
limit = 20, limit = 20,
offset = 0,
search,
committer committer
}: TGetSecretApprovalRequestList & TReactQueryOptions) => }: TGetSecretApprovalRequestList & TReactQueryOptions) =>
useInfiniteQuery({ useQuery({
initialPageParam: 0,
queryKey: secretApprovalRequestKeys.list({ queryKey: secretApprovalRequestKeys.list({
workspaceId, workspaceId,
environment, environment,
committer, committer,
status status,
limit,
search,
offset
}), }),
queryFn: ({ pageParam }) => queryFn: () =>
fetchSecretApprovalRequestList({ fetchSecretApprovalRequestList({
workspaceId, workspaceId,
environment, environment,
status, status,
committer, committer,
limit, limit,
offset: pageParam offset,
search
}), }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true), enabled: Boolean(workspaceId) && (options?.enabled ?? true),
getNextPageParam: (lastPage, pages) => { placeholderData: (previousData) => previousData
if (lastPage.length && lastPage.length < limit) return undefined;
return lastPage?.length !== 0 ? pages.length * limit : undefined;
}
}); });
const fetchSecretApprovalRequestDetails = async ({ const fetchSecretApprovalRequestDetails = async ({
@@ -113,6 +113,7 @@ export type TGetSecretApprovalRequestList = {
committer?: string; committer?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string;
}; };
export type TGetSecretApprovalRequestCount = { export type TGetSecretApprovalRequestCount = {
@@ -352,9 +352,9 @@ export const ProjectLayout = () => {
secretApprovalReqCount?.open || secretApprovalReqCount?.open ||
accessApprovalRequestCount?.pendingCount accessApprovalRequestCount?.pendingCount
) && ( ) && (
<span className="ml-2 rounded border border-primary-400 bg-primary-600 px-1 py-0.5 text-xs font-semibold text-black"> <Badge variant="primary" className="ml-1.5">
{pendingRequestsCount} {pendingRequestsCount}
</span> </Badge>
)} )}
</MenuItem> </MenuItem>
)} )}
@@ -1,7 +1,5 @@
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; 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 { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -45,21 +43,7 @@ export const SecretApprovalsPage = () => {
<PageHeader <PageHeader
title="Approval Workflows" title="Approval Workflows"
description="Create approval policies for any modifications to secrets in sensitive environments and folders." 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}> <Tabs defaultValue={defaultTab}>
<TabList> <TabList>
<Tab value={TabSection.SecretApprovalRequests}> <Tab value={TabSection.SecretApprovalRequests}>
@@ -2,15 +2,25 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
faArrowUpRightFromSquare,
faBan,
faBookOpen,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
faChevronDown, faChevronDown,
faClipboardCheck,
faLock, faLock,
faPlus faMagnifyingGlass,
faPlus,
faSearch,
faStopwatch,
faUser,
IconDefinition
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { import {
@@ -21,6 +31,8 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Input,
Pagination,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
@@ -32,7 +44,12 @@ import {
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api"; import { useGetWorkspaceUsers } from "@app/hooks/api";
import { import {
accessApprovalKeys, accessApprovalKeys,
@@ -48,28 +65,21 @@ import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { RequestAccessModal } from "./components/RequestAccessModal"; import { RequestAccessModal } from "./components/RequestAccessModal";
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal"; import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => { const generateRequestText = (request: TAccessApprovalRequest) => {
const { isTemporary } = request; const { isTemporary } = request;
return ( return (
<div className="flex w-full items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div> <div>
Requested {isTemporary ? "temporary" : "permanent"} access to{" "} Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.policy.secretPath} {request.policy.secretPath}
</code> </code>{" "}
in in{" "}
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary"> <code className="mx-1 rounded bg-mineshaft-600 px-1.5 py-0.5 font-mono text-[13px] text-mineshaft-200">
{request.environmentName} {request.environmentName}
</code> </code>
</div> </div>
<div>
{request.requestedByUserId === userId && (
<span className="text-xs text-gray-500">
<Badge className="ml-1">Requested By You</Badge>
</span>
)}
</div>
</div> </div>
); );
}; };
@@ -120,30 +130,64 @@ export const AccessApprovalRequest = ({
projectSlug projectSlug
}); });
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ const {
data: requests,
refetch: refetchRequests,
isPending: areRequestsPending
} = useGetAccessApprovalRequests({
projectSlug, projectSlug,
authorProjectMembershipId: requestedByFilter, authorUserId: requestedByFilter,
envSlug: envFilter 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(() => { const filteredRequests = useMemo(() => {
let accessRequests: typeof requests;
if (statusFilter === "open") if (statusFilter === "open")
return requests?.filter( accessRequests = requests?.filter(
(request) => (request) =>
!request.policy.deletedAt && !request.policy.deletedAt &&
!request.isApproved && !request.isApproved &&
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
if (statusFilter === "close") if (statusFilter === "close")
return requests?.filter( accessRequests = requests?.filter(
(request) => (request) =>
request.policy.deletedAt || request.policy.deletedAt ||
request.isApproved || request.isApproved ||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
); );
return requests; return (
}, [requests, statusFilter, requestedByFilter, envFilter]); 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
});
const generateRequestDetails = useCallback( const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => { (request: TAccessApprovalRequest) => {
@@ -162,9 +206,15 @@ export const AccessApprovalRequest = ({
const canBypass = const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id); !request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
let displayData: { label: string; type: "primary" | "danger" | "success" } = { let displayData: {
label: string;
type: "primary" | "danger" | "success";
tooltipContent?: string;
icon: IconDefinition | null;
} = {
label: "", label: "",
type: "primary" type: "primary",
icon: null
}; };
const isExpired = const isExpired =
@@ -172,20 +222,42 @@ export const AccessApprovalRequest = ({
request.isApproved && request.isApproved &&
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
if (isExpired) displayData = { label: "Access Expired", type: "danger" }; if (isExpired)
else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; displayData = {
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; 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")}`
};
else if (userReviewStatus === ApprovalStatus.APPROVED) { else if (userReviewStatus === ApprovalStatus.APPROVED) {
displayData = { displayData = {
label: `Pending ${request.policy.approvals - request.reviewers.length} review${ label: `Pending ${request.policy.approvals - request.reviewers.length} review${
request.policy.approvals - request.reviewers.length > 1 ? "s" : "" request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
}`, }`,
type: "primary" type: "primary",
icon: faClipboardCheck
}; };
} else if (!isReviewedByUser) } else if (!isReviewedByUser)
displayData = { displayData = {
label: "Review Required", label: "Review Required",
type: "primary" type: "primary",
icon: faClipboardCheck
}; };
return { return {
@@ -225,16 +297,42 @@ export const AccessApprovalRequest = ({
[generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen] [generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen]
); );
const isFiltered = Boolean(search || envFilter || requestedByFilter);
return ( 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>
<div className="mb-6 flex items-end justify-between"> <div className="flex items-start gap-1">
<div className="flex flex-col"> <p className="text-xl font-semibold text-mineshaft-100">Access Requests</p>
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span> <a
<div className="mt-2 text-sm text-bunker-300"> href="https://infisical.com/docs/documentation/platform/access-controls/access-requests"
Request access to secrets in sensitive environments and folders. 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> </div>
</a>
</div>
<p className="text-sm text-bunker-300">
Request and review access to secrets in sensitive environments and folders
</p>
</div> </div>
<div>
<Tooltip <Tooltip
content="To submit Access Requests, your project needs to create Access Request policies first." content="To submit Access Requests, your project needs to create Access Request policies first."
isDisabled={policiesLoading || !!policies?.length} isDisabled={policiesLoading || !!policies?.length}
@@ -247,25 +345,23 @@ export const AccessApprovalRequest = ({
} }
handlePopUpOpen("requestAccess"); handlePopUpOpen("requestAccess");
}} }}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={policiesLoading || !policies?.length} isDisabled={policiesLoading || !policies?.length}
> >
Request access Request Access
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
</div> <Input
value={search}
<AnimatePresence> onChange={(e) => setSearch(e.target.value)}
<motion.div leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
key="approval-changes-list" placeholder="Search approval requests by requesting user or environment..."
transition={{ duration: 0.1 }} className="flex-1"
initial={{ opacity: 0, translateX: 30 }} containerClassName="mb-4"
animate={{ opacity: 1, translateX: 0 }} />
exit={{ opacity: 0, translateX: 30 }} <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">
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 <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -273,17 +369,19 @@ export const AccessApprovalRequest = ({
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open"); if (evt.key === "Enter") setStatusFilter("open");
}} }}
className={ className={twMerge(
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
> >
<FontAwesomeIcon icon={faLock} className="mr-2" /> <FontAwesomeIcon icon={faLock} className="mr-2" />
{!!requestCount && requestCount?.pendingCount} Pending {!!requestCount && requestCount?.pendingCount} Pending
</div> </div>
<div <div
className={ className={twMerge(
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setStatusFilter("close")} onClick={() => setStatusFilter("close")}
@@ -292,7 +390,7 @@ export const AccessApprovalRequest = ({
}} }}
> >
<FontAwesomeIcon icon={faCheck} className="mr-2" /> <FontAwesomeIcon icon={faCheck} className="mr-2" />
{!!requestCount && requestCount.finalizedCount} Completed {!!requestCount && requestCount.finalizedCount} Closed
</div> </div>
<div className="flex flex-grow justify-end space-x-8"> <div className="flex flex-grow justify-end space-x-8">
<DropdownMenu> <DropdownMenu>
@@ -300,14 +398,20 @@ export const AccessApprovalRequest = ({
<Button <Button
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
className="text-bunker-300" className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />} rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
> >
Environments Environments
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select an environment</DropdownMenuLabel> 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 }) => ( {currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -337,15 +441,27 @@ export const AccessApprovalRequest = ({
Requested By Requested By
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
<DropdownMenuLabel>Select an author</DropdownMenuLabel> 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>
{members?.map(({ user: membershipUser, id }) => ( {members?.map(({ user: membershipUser, id }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
setRequestedByFilter((state) => (state === id ? undefined : id)) setRequestedByFilter((state) =>
state === membershipUser.id ? undefined : membershipUser.id
)
} }
key={`request-filter-member-${id}`} key={`request-filter-member-${id}`}
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />} icon={
requestedByFilter === membershipUser.id && (
<FontAwesomeIcon icon={faCheckCircle} />
)
}
iconPos="right" iconPos="right"
> >
{membershipUser.username} {membershipUser.username}
@@ -357,19 +473,26 @@ export const AccessApprovalRequest = ({
</div> </div>
</div> </div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800"> <div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{filteredRequests?.length === 0 && ( {filteredRequests?.length === 0 && !isFiltered && (
<div className="py-12"> <div className="py-12">
<EmptyState title="No more access requests pending." /> <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} />
</div> </div>
)} )}
{!!filteredRequests?.length && {!!filteredRequests?.length &&
filteredRequests?.map((request) => { filteredRequests?.slice(offset, perPage * page).map((request) => {
const details = generateRequestDetails(request); const details = generateRequestDetails(request);
return ( return (
<div <div
key={request.id} key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80" 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"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => handleSelectRequest(request)} onClick={() => handleSelectRequest(request)}
@@ -379,14 +502,18 @@ export const AccessApprovalRequest = ({
} }
}} }}
> >
<div className="w-full"> <div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col justify-between"> <div className="flex w-full flex-col justify-between">
<div className="mb-1 flex w-full items-center"> <div className="mb-1 flex w-full items-center">
<FontAwesomeIcon icon={faLock} className="mr-2" /> <FontAwesomeIcon
{generateRequestText(request, user.id)} icon={faLock}
size="xs"
className="mr-1.5 text-mineshaft-300"
/>
{generateRequestText(request)}
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-xs text-gray-500"> <div className="text-xs leading-3 text-gray-500">
{membersGroupById?.[request.requestedByUserId]?.user && ( {membersGroupById?.[request.requestedByUserId]?.user && (
<> <>
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "} Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
@@ -397,21 +524,45 @@ export const AccessApprovalRequest = ({
</> </>
)} )}
</div> </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> <div>
<Badge variant={details.displayData.type}> <Badge
{details.displayData.label} 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> </Badge>
</div> </div>
</div> </Tooltip>
</div> </div>
</div> </div>
</div> </div>
); );
})} })}
{Boolean(filteredRequests.length) && (
<Pagination
className="border-none"
count={filteredRequests.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
</div>
</div> </div>
</motion.div>
</AnimatePresence>
{!!policies && ( {!!policies && (
<RequestAccessModal <RequestAccessModal
policies={policies} policies={policies}
@@ -452,6 +603,7 @@ export const AccessApprovalRequest = ({
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={() => handlePopUpClose("upgradePlan")} onOpenChange={() => handlePopUpClose("upgradePlan")}
/> />
</div> </motion.div>
</AnimatePresence>
); );
}; };
@@ -1,11 +1,19 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCheckCircle, faCheckCircle,
faChevronDown,
faFileShield, faFileShield,
faPlus faFilter,
faMagnifyingGlass,
faPlus,
faSearch
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -19,8 +27,9 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Modal, IconButton,
ModalContent, Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -38,7 +47,12 @@ import {
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { import {
useDeleteAccessApprovalPolicy, useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy, useDeleteSecretApprovalPolicy,
@@ -47,6 +61,7 @@ import {
useListWorkspaceGroups useListWorkspaceGroups
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; 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 { PolicyType } from "@app/hooks/api/policies/enums";
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types"; import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
@@ -57,6 +72,18 @@ interface IProps {
workspaceId: string; 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 useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies( const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
{ {
@@ -112,11 +139,79 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
currentWorkspace currentWorkspace
); );
const [filterType, setFilterType] = useState<string | null>(null); const [filters, setFilters] = useState<PolicyFilters>({
type: null,
environmentIds: []
});
const filteredPolicies = useMemo(() => { const {
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies; search,
}, [policies, filterType]); 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 { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy(); const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy(); const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
@@ -151,16 +246,57 @@ 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 ( 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>
<div className="mb-6 flex items-end justify-between"> <div className="flex items-start gap-1">
<div className="flex flex-col"> <p className="text-xl font-semibold text-mineshaft-100">Policies</p>
<span className="text-xl font-semibold text-mineshaft-100">Policies</span> <a
<div className="mt-2 text-sm text-bunker-300"> href="https://infisical.com/docs/documentation/platform/pr-workflows"
Implement granular policies for access requests and secrets management. 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> </div>
</a>
</div>
<p className="text-sm text-bunker-300">
Implement granular policies for access requests and secrets management
</p>
</div> </div>
<div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
@@ -174,6 +310,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
} }
handlePopUpOpen("policyForm"); handlePopUpOpen("policyForm");
}} }}
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
@@ -182,41 +319,54 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> <div className="mb-4 flex items-center gap-2">
<TableContainer> <Input
<Table> value={search}
<THead> onChange={(e) => setSearch(e.target.value)}
<Tr> leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
<Th>Name</Th> placeholder="Search policies by name, type, environment or secret path..."
<Th>Environment</Th> className="flex-1"
<Th>Secret Path</Th> />
<Th>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger asChild>
<Button <IconButton
ariaLabel="Filter findings"
variant="plain" variant="plain"
colorSchema="secondary" size="sm"
className="text-xs font-semibold uppercase text-bunker-300" className={twMerge(
rightIcon={ "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",
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" /> isTableFiltered && "border-primary/50 text-primary"
} )}
> >
Type <FontAwesomeIcon icon={faFilter} />
</Button> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select a type</DropdownMenuLabel> className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Policy Type</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setFilterType(null)} onClick={() =>
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />} setFilters((prev) => ({
...prev,
type: null
}))
}
icon={!filters && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right" iconPos="right"
> >
All All
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setFilterType(PolicyType.AccessPolicy)} onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.AccessPolicy
}))
}
icon={ icon={
filterType === PolicyType.AccessPolicy && ( filters.type === PolicyType.AccessPolicy && (
<FontAwesomeIcon icon={faCheckCircle} /> <FontAwesomeIcon icon={faCheckCircle} />
) )
} }
@@ -225,9 +375,14 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
Access Policy Access Policy
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setFilterType(PolicyType.ChangePolicy)} onClick={() =>
setFilters((prev) => ({
...prev,
type: PolicyType.ChangePolicy
}))
}
icon={ icon={
filterType === PolicyType.ChangePolicy && ( filters.type === PolicyType.ChangePolicy && (
<FontAwesomeIcon icon={faCheckCircle} /> <FontAwesomeIcon icon={faCheckCircle} />
) )
} }
@@ -235,25 +390,110 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
> >
Change Policy Change Policy
</DropdownMenuItem> </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> </DropdownMenuContent>
</DropdownMenu> </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>
<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> </Tr>
</THead> </THead>
<TBody> <TBody>
{isPoliciesLoading && ( {isPoliciesLoading && (
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" /> <TableSkeleton
columns={5}
innerKey="secret-policies"
className="bg-mineshaft-700"
/>
)} )}
{!isPoliciesLoading && !filteredPolicies?.length && ( {!isPoliciesLoading && !policies?.length && (
<Tr> <Tr>
<Td colSpan={6}> <Td colSpan={5}>
<EmptyState title="No policies found" icon={faFileShield} /> <EmptyState title="No Policies Found" icon={faFileShield} />
</Td> </Td>
</Tr> </Tr>
)} )}
{!!currentWorkspace && {!!currentWorkspace &&
filteredPolicies?.map((policy) => ( filteredPolicies
?.slice(offset, perPage * page)
.map((policy) => (
<ApprovalPolicyRow <ApprovalPolicyRow
policy={policy} policy={policy}
key={policy.id} key={policy.id}
@@ -265,20 +505,21 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
))} ))}
</TBody> </TBody>
</Table> </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> </TableContainer>
<Modal </div>
isOpen={popUp.policyForm.isOpen} </motion.div>
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 <AccessPolicyForm
projectId={currentWorkspace.id} projectId={currentWorkspace.id}
projectSlug={currentWorkspace.slug} projectSlug={currentWorkspace.slug}
@@ -287,8 +528,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
members={members} members={members}
editValues={popUp.policyForm.data as TAccessApprovalPolicy} editValues={popUp.policyForm.data as TAccessApprovalPolicy}
/> />
</ModalContent>
</Modal>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deletePolicy.isOpen} isOpen={popUp.deletePolicy.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -301,6 +540,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add secret approval policy if you switch to Infisical's Enterprise plan." text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
/> />
</div> </AnimatePresence>
); );
}; };
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { RefObject, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -13,6 +13,8 @@ import {
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
Modal,
ModalContent,
Select, Select,
SelectItem, SelectItem,
Switch, Switch,
@@ -110,20 +112,20 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
export const AccessPolicyForm = ({ const Form = ({
isOpen,
onToggle, onToggle,
members = [], members = [],
projectId, projectId,
projectSlug, projectSlug,
editValues editValues,
}: Props) => { modalContainer,
isEditMode
}: Props & { modalContainer: RefObject<HTMLDivElement>; isEditMode: boolean }) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null); const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null); const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const { const {
control, control,
handleSubmit, handleSubmit,
reset,
watch, watch,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
@@ -188,13 +190,8 @@ export const AccessPolicyForm = ({
const { data: groups } = useListWorkspaceGroups(projectId); const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || []; const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
}, [isOpen, isEditMode]);
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy(); const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
@@ -387,6 +384,7 @@ export const AccessPolicyForm = ({
setDraggedItem(null); setDraggedItem(null);
setDragOverItem(null); setDragOverItem(null);
}; };
return ( return (
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
@@ -572,7 +570,7 @@ export const AccessPolicyForm = ({
className="flex-grow" className="flex-grow"
> >
<FilterableSelect <FilterableSelect
menuPortalTarget={document.getElementById("policy-form")} menuPortalTarget={modalContainer.current}
menuPlacement="top" menuPlacement="top"
isMulti isMulti
placeholder="Select members..." placeholder="Select members..."
@@ -602,7 +600,7 @@ export const AccessPolicyForm = ({
className="flex-grow" className="flex-grow"
> >
<FilterableSelect <FilterableSelect
menuPortalTarget={document.getElementById("policy-form")} menuPortalTarget={modalContainer.current}
menuPlacement="top" menuPlacement="top"
isMulti isMulti
placeholder="Select groups..." placeholder="Select groups..."
@@ -813,3 +811,27 @@ export const AccessPolicyForm = ({
</div> </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>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -9,6 +9,8 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
GenericFieldLabel,
IconButton,
Td, Td,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
@@ -80,11 +82,11 @@ export const ApprovalPolicyRow = ({
userLabels: members userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id)) ?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member)) .map((member) => getMemberLabel(member))
.join(","), .join(", "),
groupLabels: groups groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id)) ?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name) .map(({ group }) => group.name)
.join(","), .join(", "),
approvals: el.approvals approvals: el.approvals
}; };
}); });
@@ -102,36 +104,47 @@ export const ApprovalPolicyRow = ({
}} }}
onClick={() => setIsExpanded.toggle()} onClick={() => setIsExpanded.toggle()}
> >
<Td>{policy.name}</Td> <Td>{policy.name || <span className="text-mineshaft-400">Unnamed Policy</span>}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.name}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td> <Td>
<Badge className={policyDetails[policy.policyType].className}> <Badge
{policyDetails[policy.policyType].name} 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> </Badge>
</Td> </Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg"> <DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<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"> <DropdownMenuTrigger asChild>
<FontAwesomeIcon size="sm" icon={faEllipsis} /> <IconButton
</div> ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1"> </DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end" className="min-w-[12rem] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(); onEdit();
}} }}
disabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
> >
Edit Policy Edit Policy
</DropdownMenuItem> </DropdownMenuItem>
@@ -143,16 +156,12 @@ export const ApprovalPolicyRow = ({
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuItem <DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
disabled={!isAllowed} isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
> >
Delete Policy Delete Policy
</DropdownMenuItem> </DropdownMenuItem>
@@ -162,45 +171,41 @@ export const ApprovalPolicyRow = ({
</DropdownMenu> </DropdownMenu>
</Td> </Td>
</Tr> </Tr>
{isExpanded && (
<Tr> <Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900"> <Td colSpan={6} className="!border-none p-0">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div> <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) => ( {labels?.map((el, index) => (
<div <div
key={`approval-list-${index + 1}`} key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4" className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-800 p-4"
> >
<div> <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 className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white"> <div>{index + 1}</div>
<div className="text-lg">{index + 1}</div>
</div> </div>
{index !== labels.length - 1 && ( {index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" /> <div className="absolute bottom-0 left-8 h-[1.25rem] border-r border-mineshaft-400" />
)} )}
{index !== 0 && ( {index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" /> <div className="absolute left-8 top-0 h-[1.25rem] border-r border-mineshaft-400" />
)} )}
</div>
<div className="grid flex-grow grid-cols-3"> <div className="grid flex-grow grid-cols-3">
<div> <GenericFieldLabel label="Users">{el.userLabels}</GenericFieldLabel>
<div className="mb-1 text-xs font-semibold uppercase">Users</div> <GenericFieldLabel label="Groups">{el.groupLabels}</GenericFieldLabel>
<div>{el.userLabels || "-"}</div> <GenericFieldLabel label="Approvals Required">{el.approvals}</GenericFieldLabel>
</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> </div>
))} ))}
</div>
</div>
</Td> </Td>
</Tr> </Tr>
)}
</> </>
); );
}; };
@@ -1,14 +1,19 @@
import { Fragment, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
faArrowUpRightFromSquare,
faBookOpen,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
faChevronDown, faChevronDown,
faCodeBranch faCodeBranch,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { formatDistance } from "date-fns"; import { formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { import {
Button, Button,
@@ -18,6 +23,8 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
Input,
Pagination,
Skeleton Skeleton
} from "@app/components/v2"; } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
@@ -28,6 +35,12 @@ import {
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination } from "@app/hooks";
import { import {
useGetSecretApprovalRequestCount, useGetSecretApprovalRequestCount,
useGetSecretApprovalRequests, useGetSecretApprovalRequests,
@@ -52,18 +65,41 @@ export const SecretApprovalRequest = () => {
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false); const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
const { const {
data: secretApprovalRequests, debouncedSearch: debouncedSearchFilter,
isFetchingNextPage: isFetchingNextApprovalRequest, search: searchFilter,
fetchNextPage: fetchNextApprovalRequest, setSearch: setSearchFilter,
hasNextPage: hasNextApprovalPage, 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,
isPending: isApprovalRequestLoading, isPending: isApprovalRequestLoading,
refetch refetch
} = useGetSecretApprovalRequests({ } = useGetSecretApprovalRequests({
workspaceId, workspaceId,
status: statusFilter, status: statusFilter,
environment: envFilter, environment: envFilter,
committer: committerFilter committer: committerFilter,
search: debouncedSearchFilter,
limit,
offset
}); });
const totalApprovalCount = data?.totalCount ?? 0;
const secretApprovalRequests = data?.approvals ?? [];
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } = const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
useGetSecretApprovalRequestCount({ workspaceId }); useGetSecretApprovalRequestCount({ workspaceId });
const { user: userSession } = useUser(); const { user: userSession } = useUser();
@@ -88,8 +124,9 @@ export const SecretApprovalRequest = () => {
refetch(); refetch();
}; };
const isRequestListEmpty = const isRequestListEmpty = !isApprovalRequestLoading && secretApprovalRequests?.length === 0;
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
const isFiltered = Boolean(searchFilter || envFilter || committerFilter);
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -116,7 +153,38 @@ export const SecretApprovalRequest = () => {
exit={{ opacity: 0, translateX: 30 }} exit={{ opacity: 0, translateX: 30 }}
className="rounded-md text-gray-300" 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 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>
<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 <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -124,17 +192,19 @@ export const SecretApprovalRequest = () => {
onKeyDown={(evt) => { onKeyDown={(evt) => {
if (evt.key === "Enter") setStatusFilter("open"); if (evt.key === "Enter") setStatusFilter("open");
}} }}
className={ className={twMerge(
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "close" && "text-gray-500 duration-100 hover:text-gray-400"
)}
> >
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" /> <FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
</div> </div>
<div <div
className={ className={twMerge(
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : "" "font-medium",
} statusFilter === "open" && "text-gray-500 duration-100 hover:text-gray-400"
)}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setStatusFilter("close")} onClick={() => setStatusFilter("close")}
@@ -152,13 +222,21 @@ export const SecretApprovalRequest = () => {
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
className={envFilter ? "text-white" : "text-bunker-300"} className={envFilter ? "text-white" : "text-bunker-300"}
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />} rightIcon={
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
}
> >
Environments Environments
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
<DropdownMenuLabel>Select an environment</DropdownMenuLabel> 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 }) => ( {currentWorkspace?.environments.map(({ slug, name }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))} onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
@@ -188,8 +266,14 @@ export const SecretApprovalRequest = () => {
Author Author
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent
<DropdownMenuLabel>Select an author</DropdownMenuLabel> 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 }) => ( {members?.map(({ user, id }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -210,14 +294,14 @@ export const SecretApprovalRequest = () => {
</div> </div>
</div> </div>
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800"> <div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
{isRequestListEmpty && ( {isRequestListEmpty && !isFiltered && (
<div className="py-12"> <div className="py-12">
<EmptyState title="No more requests pending." /> <EmptyState
title={`No ${statusFilter === "open" ? "Open" : "Closed"} Change Requests`}
/>
</div> </div>
)} )}
{secretApprovalRequests?.pages?.map((group, i) => ( {secretApprovalRequests.map((secretApproval) => {
<Fragment key={`secret-approval-request-${i + 1}`}>
{group?.map((secretApproval) => {
const { const {
id: reqId, id: reqId,
commits, commits,
@@ -233,7 +317,7 @@ export const SecretApprovalRequest = () => {
return ( return (
<div <div
key={reqId} key={reqId}
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700" className="flex flex-col border-b border-mineshaft-600 px-8 py-3 last:border-b-0 hover:bg-mineshaft-700"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setSelectedApprovalId(secretApproval.id)} onClick={() => setSelectedApprovalId(secretApproval.id)}
@@ -241,14 +325,18 @@ export const SecretApprovalRequest = () => {
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
}} }}
> >
<div className="mb-1"> <div className="mb-1 text-sm">
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" /> <FontAwesomeIcon
icon={faCodeBranch}
size="sm"
className="mr-1.5 text-mineshaft-300"
/>
{secretApproval.isReplicated {secretApproval.isReplicated
? `${commits.length} secret pending import` ? `${commits.length} secret pending import`
: generateCommitText(commits)} : generateCommitText(commits)}
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span> <span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
</div> </div>
<span className="text-xs text-gray-500"> <span className="text-xs leading-3 text-gray-500">
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
{committerUser?.firstName || ""} {committerUser?.lastName || ""} ( {committerUser?.firstName || ""} {committerUser?.lastName || ""} (
{committerUser?.email}) {committerUser?.email})
@@ -257,9 +345,24 @@ export const SecretApprovalRequest = () => {
</div> </div>
); );
})} })}
</Fragment> {Boolean(
))} !secretApprovalRequests.length && isFiltered && !isApprovalRequestLoading
{(isFetchingNextApprovalRequest || 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> <div>
{Array.apply(0, Array(3)).map((_x, index) => ( {Array.apply(0, Array(3)).map((_x, index) => (
<div <div
@@ -276,18 +379,7 @@ export const SecretApprovalRequest = () => {
</div> </div>
)} )}
</div> </div>
{hasNextApprovalPage && ( </div>
<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> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -56,27 +56,24 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
if (score[CommitType.CREATE]) if (score[CommitType.CREATE])
text.push( text.push(
<span key="created-commit"> <span key="created-commit">
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} {score[CommitType.CREATE]} Secret{score[CommitType.CREATE] !== 1 && "s"}
<span style={{ color: "#60DD00" }}> created</span> <span className="text-green-600"> Created</span>
</span> </span>
); );
if (score[CommitType.UPDATE]) if (score[CommitType.UPDATE])
text.push( text.push(
<span key="updated-commit"> <span key="updated-commit">
{Boolean(text.length) && ","} {Boolean(text.length) && ", "}
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} {score[CommitType.UPDATE]} Secret{score[CommitType.UPDATE] !== 1 && "s"}
<span style={{ color: "#F8EB30" }} className="text-orange-600"> <span className="text-yellow-600"> Updated</span>
{" "}
updated
</span>
</span> </span>
); );
if (score[CommitType.DELETE]) if (score[CommitType.DELETE])
text.push( text.push(
<span className="deleted-commit"> <span className="deleted-commit">
{Boolean(text.length) && "and"} {Boolean(text.length) && "and"}
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} {score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
<span style={{ color: "#F83030" }}> deleted</span> <span className="text-red-600"> Deleted</span>
</span> </span>
); );
return text; return text;
@@ -37,7 +37,10 @@ const formSchema = z.object({
policyArns: z.string().trim().optional(), policyArns: z.string().trim().optional(),
tags: z tags: z
.array( .array(
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
) )
.optional() .optional()
}), }),
@@ -52,7 +55,10 @@ const formSchema = z.object({
policyArns: z.string().trim().optional(), policyArns: z.string().trim().optional(),
tags: z tags: z
.array( .array(
z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) z.object({
key: z.string().trim().min(1).max(128),
value: z.string().trim().min(1).max(256)
})
) )
.optional() .optional()
}) })
@@ -26,7 +26,7 @@ const formSchema = z.object({
policyArns: z.string().trim().optional(), policyArns: z.string().trim().optional(),
tags: z tags: z
.array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })) .array(z.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) }))
.optional(), .optional()
}), }),
z.object({ z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole), method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
@@ -97,7 +97,7 @@ export const EditDynamicSecretAwsIamForm = ({
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}", usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
inputs: { inputs: {
...(dynamicSecret.inputs as TForm["inputs"]) ...(dynamicSecret.inputs as TForm["inputs"])
}, }
} }
}); });
const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey; const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey;
@@ -125,8 +125,7 @@ export const EditDynamicSecretAwsIamForm = ({
defaultTTL, defaultTTL,
inputs, inputs,
newName: newName === dynamicSecret.name ? undefined : newName, newName: newName === dynamicSecret.name ? undefined : newName,
usernameTemplate: usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
!usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
} }
}); });
onClose(); onClose();