mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-31 15:32:32 +00:00
Compare commits
48 Commits
helm-updat
...
daniel/go-
Author | SHA1 | Date | |
---|---|---|---|
|
24bf9f7a2a | ||
|
8d4fa0bdb9 | ||
|
041d585f19 | ||
|
e1a11c37e3 | ||
|
e6349474aa | ||
|
d6da108e32 | ||
|
93baf9728b | ||
|
ecd39abdc1 | ||
|
d8313a161e | ||
|
b8e79f20dc | ||
|
0088217fa9 | ||
|
13485cecbb | ||
|
85e9952a4c | ||
|
ebcf4761b6 | ||
|
bf20556b17 | ||
|
dcde10a401 | ||
|
e0373cf416 | ||
|
ea038f26df | ||
|
f95c446651 | ||
|
59ab4de24a | ||
|
d2295c47f5 | ||
|
47dc4f0c47 | ||
|
4b0e0d4de5 | ||
|
6128301622 | ||
|
8c318f51e4 | ||
|
be51e358fc | ||
|
e8dd8a908d | ||
|
fd20cb1e38 | ||
|
a07f168c36 | ||
|
530045aaf2 | ||
|
cd4f2cccf8 | ||
|
74200bf860 | ||
|
c59cecdb45 | ||
|
b0cacc5a4a | ||
|
2f4c42482d | ||
|
042a472f59 | ||
|
53c015988d | ||
|
fb0b6b00dd | ||
|
a5f198a3d5 | ||
|
2f060407ab | ||
|
c516ce8196 | ||
|
95ccd35f61 | ||
|
d5741b4a72 | ||
|
4654a17e5f | ||
|
dd2fee3eca | ||
|
802cf79af5 | ||
|
cefcd872ee | ||
|
4955e2064d |
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasCol = await knex.schema.hasColumn(TableName.SecretFolder, "lastSecretModified");
|
||||
if (!hasCol) {
|
||||
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
|
||||
t.datetime("lastSecretModified");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasCol = await knex.schema.hasColumn(TableName.SecretFolder, "lastSecretModified");
|
||||
if (hasCol) {
|
||||
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
|
||||
t.dropColumn("lastSecretModified");
|
||||
});
|
||||
}
|
||||
}
|
@@ -16,7 +16,8 @@ export const SecretFoldersSchema = z.object({
|
||||
envId: z.string().uuid(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
isReserved: z.boolean().default(false).nullable().optional(),
|
||||
description: z.string().nullable().optional()
|
||||
description: z.string().nullable().optional(),
|
||||
lastSecretModified: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;
|
||||
|
@@ -9,13 +9,14 @@ import { logger } from "@app/lib/logger";
|
||||
import { QueueName } from "@app/queue";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
|
||||
import { EventType } from "./audit-log-types";
|
||||
import { EventType, filterableSecretEvents } from "./audit-log-types";
|
||||
|
||||
export type TAuditLogDALFactory = ReturnType<typeof auditLogDALFactory>;
|
||||
|
||||
type TFindQuery = {
|
||||
actor?: string;
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
orgId?: string;
|
||||
eventType?: string;
|
||||
startDate?: string;
|
||||
@@ -32,6 +33,7 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
{
|
||||
orgId,
|
||||
projectId,
|
||||
environment,
|
||||
userAgentType,
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -40,12 +42,14 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
actorId,
|
||||
actorType,
|
||||
secretPath,
|
||||
secretKey,
|
||||
eventType,
|
||||
eventMetadata
|
||||
}: Omit<TFindQuery, "actor" | "eventType"> & {
|
||||
actorId?: string;
|
||||
actorType?: ActorType;
|
||||
secretPath?: string;
|
||||
secretKey?: string;
|
||||
eventType?: EventType[];
|
||||
eventMetadata?: Record<string, string>;
|
||||
},
|
||||
@@ -90,8 +94,29 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (projectId && secretPath) {
|
||||
void sqlQuery.whereRaw(`"eventMetadata" @> jsonb_build_object('secretPath', ?::text)`, [secretPath]);
|
||||
const eventIsSecretType = !eventType?.length || eventType.some((event) => filterableSecretEvents.includes(event));
|
||||
// We only want to filter for environment/secretPath/secretKey if the user is either checking for all event types
|
||||
|
||||
// ? Note(daniel): use the `eventMetadata" @> ?::jsonb` approach to properly use our GIN index
|
||||
if (projectId && eventIsSecretType) {
|
||||
if (environment || secretPath) {
|
||||
// Handle both environment and secret path together to only use the GIN index once
|
||||
void sqlQuery.whereRaw(`"eventMetadata" @> ?::jsonb`, [
|
||||
JSON.stringify({
|
||||
...(environment && { environment }),
|
||||
...(secretPath && { secretPath })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
// Handle secret key separately to include the OR condition
|
||||
if (secretKey) {
|
||||
void sqlQuery.whereRaw(
|
||||
`("eventMetadata" @> ?::jsonb
|
||||
OR "eventMetadata"->'secrets' @> ?::jsonb)`,
|
||||
[JSON.stringify({ secretKey }), JSON.stringify([{ secretKey }])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by actor type
|
||||
|
@@ -63,6 +63,8 @@ export const auditLogServiceFactory = ({
|
||||
actorType: filter.actorType,
|
||||
eventMetadata: filter.eventMetadata,
|
||||
secretPath: filter.secretPath,
|
||||
secretKey: filter.secretKey,
|
||||
environment: filter.environment,
|
||||
...(filter.projectId ? { projectId: filter.projectId } : { orgId: actorOrgId })
|
||||
});
|
||||
|
||||
|
@@ -33,9 +33,11 @@ export type TListProjectAuditLogDTO = {
|
||||
endDate?: string;
|
||||
startDate?: string;
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
auditLogActorId?: string;
|
||||
actorType?: ActorType;
|
||||
secretPath?: string;
|
||||
secretKey?: string;
|
||||
eventMetadata?: Record<string, string>;
|
||||
};
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
@@ -286,6 +288,16 @@ export enum EventType {
|
||||
KMIP_OPERATION_REGISTER = "kmip-operation-register"
|
||||
}
|
||||
|
||||
export const filterableSecretEvents: EventType[] = [
|
||||
EventType.GET_SECRET,
|
||||
EventType.DELETE_SECRETS,
|
||||
EventType.CREATE_SECRETS,
|
||||
EventType.UPDATE_SECRETS,
|
||||
EventType.CREATE_SECRET,
|
||||
EventType.UPDATE_SECRET,
|
||||
EventType.DELETE_SECRET
|
||||
];
|
||||
|
||||
interface UserActorMetadata {
|
||||
userId: string;
|
||||
email?: string | null;
|
||||
|
@@ -632,7 +632,8 @@ export const FOLDERS = {
|
||||
environment: "The slug of the environment to list folders from.",
|
||||
path: "The path to list folders from.",
|
||||
directory: "The directory to list folders from. (Deprecated in favor of path)",
|
||||
recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories."
|
||||
recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories.",
|
||||
lastSecretModified: "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)"
|
||||
},
|
||||
GET_BY_ID: {
|
||||
folderId: "The ID of the folder to get details."
|
||||
@@ -840,9 +841,13 @@ export const AUDIT_LOGS = {
|
||||
EXPORT: {
|
||||
projectId:
|
||||
"Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.",
|
||||
environment:
|
||||
"The environment to filter logs by. If not provided, logs from all environments will be returned. Note that the projectId parameter must also be provided.",
|
||||
eventType: "The type of the event to export.",
|
||||
secretPath:
|
||||
"The path of the secret to query audit logs for. Note that the projectId parameter must also be provided.",
|
||||
secretKey:
|
||||
"The key of the secret to query audit logs for. Note that the projectId parameter must also be provided.",
|
||||
userAgentType: "Choose which consuming application to export audit logs for.",
|
||||
eventMetadata:
|
||||
"Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.",
|
||||
|
@@ -6,6 +6,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
validateAltNamesField,
|
||||
validateCaDateField
|
||||
} from "@app/services/certificate-authority/certificate-authority-validators";
|
||||
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
|
||||
export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@@ -649,6 +651,16 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
caId: ca.id,
|
||||
commonName: req.body.commonName,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
@@ -707,7 +719,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } =
|
||||
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } =
|
||||
await server.services.certificateAuthority.signCertFromCa({
|
||||
isInternal: false,
|
||||
caId: req.params.caId,
|
||||
@@ -731,6 +743,16 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SignCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
caId: ca.id,
|
||||
commonName,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: certificate.toString("pem"),
|
||||
certificateChain,
|
||||
|
@@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { CertExtendedKeyUsage, CertKeyUsage, CrlReason } from "@app/services/certificate/certificate-types";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
validateAltNamesField,
|
||||
validateCaDateField
|
||||
} from "@app/services/certificate-authority/certificate-authority-validators";
|
||||
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
|
||||
export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@@ -150,6 +152,17 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
caId: req.body.caId,
|
||||
certificateTemplateId: req.body.certificateTemplateId,
|
||||
commonName: req.body.commonName,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
@@ -228,7 +241,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } =
|
||||
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } =
|
||||
await server.services.certificateAuthority.signCertFromCa({
|
||||
isInternal: false,
|
||||
actor: req.permission.type,
|
||||
@@ -251,6 +264,17 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SignCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
caId: req.body.caId,
|
||||
certificateTemplateId: req.body.certificateTemplateId,
|
||||
commonName,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: certificate.toString("pem"),
|
||||
certificateChain,
|
||||
|
@@ -897,6 +897,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
projectId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
recursive: booleanSchema.default(false),
|
||||
filterByAction: z
|
||||
.enum([ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.ReadValue])
|
||||
.default(ProjectPermissionSecretActions.ReadValue)
|
||||
@@ -915,7 +916,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { projectId, environment, secretPath, filterByAction } = req.query;
|
||||
const { projectId, environment, secretPath, filterByAction, recursive } = req.query;
|
||||
|
||||
const { secrets } = await server.services.secret.getAccessibleSecrets({
|
||||
actorId: req.permission.id,
|
||||
@@ -925,7 +926,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
environment,
|
||||
secretPath,
|
||||
projectId,
|
||||
filterByAction
|
||||
filterByAction,
|
||||
recursive
|
||||
});
|
||||
|
||||
return { secrets };
|
||||
|
@@ -111,12 +111,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
description: "Get all audit logs for an organization",
|
||||
querystring: z.object({
|
||||
projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId),
|
||||
environment: z.string().optional().describe(AUDIT_LOGS.EXPORT.environment),
|
||||
actorType: z.nativeEnum(ActorType).optional(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (!val ? val : removeTrailingSlash(val)))
|
||||
.describe(AUDIT_LOGS.EXPORT.secretPath),
|
||||
secretKey: z.string().optional().describe(AUDIT_LOGS.EXPORT.secretKey),
|
||||
|
||||
// eventType is split with , for multiple values, we need to transform it to array
|
||||
eventType: z
|
||||
|
@@ -335,6 +335,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
|
||||
querystring: z.object({
|
||||
workspaceId: z.string().trim().describe(FOLDERS.LIST.workspaceId),
|
||||
environment: z.string().trim().describe(FOLDERS.LIST.environment),
|
||||
lastSecretModified: z.string().datetime().trim().optional().describe(FOLDERS.LIST.lastSecretModified),
|
||||
path: z
|
||||
.string()
|
||||
.trim()
|
||||
|
@@ -1818,7 +1818,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
|
||||
issuingCaCertificate,
|
||||
serialNumber,
|
||||
ca
|
||||
ca,
|
||||
commonName: cn
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -423,7 +423,7 @@ export const projectMembershipServiceFactory = ({
|
||||
const usernamesAndEmails = [...emails, ...usernames];
|
||||
|
||||
const projectMembers = await projectMembershipDAL.findMembershipsByUsername(projectId, [
|
||||
...new Set(usernamesAndEmails.map((element) => element.toLowerCase()))
|
||||
...new Set(usernamesAndEmails.map((element) => element))
|
||||
]);
|
||||
|
||||
if (projectMembers.length !== usernamesAndEmails.length) {
|
||||
|
@@ -402,7 +402,8 @@ export const secretFolderServiceFactory = ({
|
||||
orderDirection,
|
||||
limit,
|
||||
offset,
|
||||
recursive
|
||||
recursive,
|
||||
lastSecretModified
|
||||
}: TGetFolderDTO) => {
|
||||
// folder list is allowed to be read by anyone
|
||||
// permission to check does user has access
|
||||
@@ -425,7 +426,16 @@ export const secretFolderServiceFactory = ({
|
||||
const recursiveFolders = await folderDAL.findByEnvsDeep({ parentIds: [parentFolder.id] });
|
||||
// remove the parent folder
|
||||
return recursiveFolders
|
||||
.filter((folder) => folder.id !== parentFolder.id)
|
||||
.filter((folder) => {
|
||||
if (lastSecretModified) {
|
||||
if (!folder.lastSecretModified) return false;
|
||||
|
||||
if (folder.lastSecretModified < new Date(lastSecretModified)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return folder.id !== parentFolder.id;
|
||||
})
|
||||
.map((folder) => ({
|
||||
...folder,
|
||||
relativePath: folder.path
|
||||
@@ -445,6 +455,11 @@ export const secretFolderServiceFactory = ({
|
||||
offset
|
||||
}
|
||||
);
|
||||
if (lastSecretModified) {
|
||||
return folders.filter((el) =>
|
||||
el.lastSecretModified ? el.lastSecretModified >= new Date(lastSecretModified) : false
|
||||
);
|
||||
}
|
||||
return folders;
|
||||
};
|
||||
|
||||
@@ -619,10 +634,29 @@ export const secretFolderServiceFactory = ({
|
||||
const relevantFolders = folders.filter((folder) => folder.envId === env.id);
|
||||
const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder]));
|
||||
|
||||
const foldersWithPath = relevantFolders.map((folder) => ({
|
||||
...folder,
|
||||
path: buildFolderPath(folder, foldersMap)
|
||||
}));
|
||||
const foldersWithPath = relevantFolders
|
||||
.map((folder) => {
|
||||
try {
|
||||
return {
|
||||
...folder,
|
||||
path: buildFolderPath(folder, foldersMap)
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
path: string;
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
name: string;
|
||||
envId: string;
|
||||
version?: number | null | undefined;
|
||||
parentId?: string | null | undefined;
|
||||
isReserved?: boolean | undefined;
|
||||
description?: string | undefined;
|
||||
}[];
|
||||
|
||||
return [env.slug, { ...env, folders: foldersWithPath }];
|
||||
})
|
||||
|
@@ -46,6 +46,7 @@ export type TGetFolderDTO = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
recursive?: boolean;
|
||||
lastSecretModified?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetFolderByIdDTO = {
|
||||
|
@@ -356,7 +356,7 @@ export const fnSecretBulkDelete = async ({
|
||||
interface FolderMap {
|
||||
[parentId: string]: TSecretFolders[];
|
||||
}
|
||||
const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
|
||||
export const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
|
||||
const map: FolderMap = {};
|
||||
map.null = []; // Initialize mapping for root directory
|
||||
|
||||
@@ -371,7 +371,7 @@ const buildHierarchy = (folders: TSecretFolders[]): FolderMap => {
|
||||
return map;
|
||||
};
|
||||
|
||||
const generatePaths = (
|
||||
export const generatePaths = (
|
||||
map: FolderMap,
|
||||
parentId: string = "null",
|
||||
basePath: string = "",
|
||||
|
@@ -44,10 +44,12 @@ import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
|
||||
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
|
||||
import {
|
||||
buildHierarchy,
|
||||
expandSecretReferencesFactory,
|
||||
fnSecretBulkDelete,
|
||||
fnSecretBulkInsert,
|
||||
fnSecretBulkUpdate,
|
||||
generatePaths,
|
||||
getAllSecretReferences,
|
||||
recursivelyGetSecretPaths,
|
||||
reshapeBridgeSecret
|
||||
@@ -2620,7 +2622,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
actorOrgId,
|
||||
recursive
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
@@ -2635,10 +2638,38 @@ export const secretV2BridgeServiceFactory = ({
|
||||
secretPath
|
||||
});
|
||||
|
||||
const folders = [];
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) return { secrets: [] };
|
||||
folders.push({ ...folder, parentId: null });
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds([folder.id]);
|
||||
const env = await projectEnvDAL.findOne({
|
||||
projectId,
|
||||
slug: environment
|
||||
});
|
||||
|
||||
if (!env) {
|
||||
throw new NotFoundError({
|
||||
message: `Environment with slug '${environment}' in project with ID ${projectId} not found`
|
||||
});
|
||||
}
|
||||
|
||||
if (recursive) {
|
||||
const subFolders = await folderDAL.find({
|
||||
envId: env.id,
|
||||
isReserved: false
|
||||
});
|
||||
folders.push(...subFolders);
|
||||
}
|
||||
|
||||
if (folders.length === 0) return { secrets: [] };
|
||||
|
||||
const folderMap = buildHierarchy(folders);
|
||||
const paths = Object.fromEntries(
|
||||
generatePaths(folderMap).map(({ folderId, path }) => [folderId, path === "/" ? path : path.substring(1)])
|
||||
);
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds(folders.map((f) => f.id));
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
@@ -2650,7 +2681,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
if (
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretPath: paths[el.folderId],
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
})
|
||||
@@ -2661,7 +2692,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
if (filterByAction === ProjectPermissionSecretActions.ReadValue) {
|
||||
return hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretPath: paths[el.folderId],
|
||||
secretName: el.key,
|
||||
secretTags: el.tags.map((i) => i.slug)
|
||||
});
|
||||
@@ -2674,7 +2705,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
filterByAction === ProjectPermissionSecretActions.DescribeSecret &&
|
||||
!hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretPath: paths[secret.folderId],
|
||||
secretName: secret.key,
|
||||
secretTags: secret.tags.map((i) => i.slug)
|
||||
});
|
||||
@@ -2682,7 +2713,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
return reshapeBridgeSecret(
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
paths[secret.folderId],
|
||||
{
|
||||
...secret,
|
||||
value: secret.encryptedValue
|
||||
|
@@ -356,5 +356,6 @@ export type TGetAccessibleSecretsDTO = {
|
||||
environment: string;
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
recursive?: boolean;
|
||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
} & TProjectPermission;
|
||||
|
@@ -646,6 +646,10 @@ export const secretQueueFactory = ({
|
||||
}
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) return;
|
||||
await folderDAL.updateById(folder.id, { lastSecretModified: new Date() });
|
||||
|
||||
await secretSyncQueue.queueSecretSyncsSyncSecretsByPath({ projectId, environmentSlug: environment, secretPath });
|
||||
|
||||
await syncIntegrations({ secretPath, projectId, environment, deDupeQueue, isManual: false });
|
||||
|
@@ -1321,7 +1321,8 @@ export const secretServiceFactory = ({
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environment,
|
||||
filterByAction
|
||||
filterByAction,
|
||||
recursive
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
@@ -1340,7 +1341,8 @@ export const secretServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
actorAuthMethod,
|
||||
recursive
|
||||
});
|
||||
|
||||
return secrets;
|
||||
|
@@ -184,6 +184,7 @@ export enum SecretsOrderBy {
|
||||
export type TGetAccessibleSecretsDTO = {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
recursive?: boolean;
|
||||
filterByAction: ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue;
|
||||
} & TProjectPermission;
|
||||
|
||||
|
@@ -17,7 +17,9 @@ export enum PostHogEventTypes {
|
||||
SecretRequestCreated = "Secret Request Created",
|
||||
SecretRequestDeleted = "Secret Request Deleted",
|
||||
SignSshKey = "Sign SSH Key",
|
||||
IssueSshCreds = "Issue SSH Credentials"
|
||||
IssueSshCreds = "Issue SSH Credentials",
|
||||
SignCert = "Sign PKI Certificate",
|
||||
IssueCert = "Issue PKI Certificate"
|
||||
}
|
||||
|
||||
export type TSecretModifiedEvent = {
|
||||
@@ -159,6 +161,26 @@ export type TIssueSshCredsEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TSignCertificateEvent = {
|
||||
event: PostHogEventTypes.SignCert;
|
||||
properties: {
|
||||
caId?: string;
|
||||
certificateTemplateId?: string;
|
||||
commonName: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TIssueCertificateEvent = {
|
||||
event: PostHogEventTypes.IssueCert;
|
||||
properties: {
|
||||
caId?: string;
|
||||
certificateTemplateId?: string;
|
||||
commonName: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TPostHogEvent = { distinctId: string } & (
|
||||
| TSecretModifiedEvent
|
||||
| TAdminInitEvent
|
||||
@@ -173,4 +195,6 @@ export type TPostHogEvent = { distinctId: string } & (
|
||||
| TSecretRequestDeletedEvent
|
||||
| TSignSshKeyEvent
|
||||
| TIssueSshCredsEvent
|
||||
| TSignCertificateEvent
|
||||
| TIssueCertificateEvent
|
||||
);
|
||||
|
@@ -694,7 +694,7 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err)
|
||||
}
|
||||
|
||||
httpClient.SetAuthToken(tokenDetails.Token)
|
||||
httpClient.SetHeader("Accept", "application/json")
|
||||
|
||||
// pull current secrets
|
||||
|
@@ -40,7 +40,7 @@ If you're using SAML/LDAP/OIDC for only one organization on your instance, you c
|
||||
|
||||
By default, users signing up through SAML/LDAP/OIDC will still need to verify their email address to prevent email spoofing. This requirement can be skipped by enabling the switch to trust logins through the respective method.
|
||||
|
||||
### Notices
|
||||
### Broadcast Messages
|
||||
|
||||
Auth consent content is displayed to users on the login page. They can be used to display important information to users, such as a maintenance message or a new feature announcement. Both HTML and Markdown formatting are supported, allowing for customized styling like below:
|
||||
|
||||
|
@@ -11,10 +11,11 @@ This means that updating the value of a base secret propagates directly to other
|
||||
|
||||

|
||||
|
||||
Since secret referencing works by reconstructing values back on the client side, the client, be it a user, service token, or a machine identity, fetching back secrets
|
||||
must be permissioned access to all base and dependent secrets.
|
||||
Since secret referencing reconstructs values on the client side, any client (user, service token, or machine identity) fetching secrets must have proper permissions to access all base and dependent secrets. Without sufficient permissions, secret references will not resolve to their appropriate values.
|
||||
|
||||
For example, to access some secret `A` whose values depend on secrets `B` and `C` from different scopes, a client must have `read` access to the scopes of secrets `A`, `B`, and `C`.
|
||||
For example, if secret A references values from secrets B and C located in different scopes, the client must have read access to all three scopes containing secrets A, B, and C. If permission to any referenced secret is missing, the reference will remain unresolved, potentially causing application errors or unexpected behavior.
|
||||
|
||||
This is an important security consideration when planning your secret access strategy, especially when working with cross-environment or cross-folder references.
|
||||
|
||||
### Syntax
|
||||
|
||||
@@ -28,11 +29,11 @@ Then consider the following scenarios:
|
||||
|
||||
Here are a few more helpful examples for how to reference secrets in different contexts:
|
||||
|
||||
| Reference syntax | Environment | Folder | Secret Key |
|
||||
| --------------------- | ----------- | ------------ | ---------- |
|
||||
| `${KEY1}` | same env | same folder | KEY1 |
|
||||
| `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 |
|
||||
| `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 |
|
||||
| Reference syntax | Environment | Folder | Secret Key |
|
||||
| ----------------------- | ----------- | ----------------------------- | ---------- |
|
||||
| `${KEY1}` | same env | same folder | KEY1 |
|
||||
| `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 |
|
||||
| `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 |
|
||||
|
||||
## Secret Imports
|
||||
|
||||
@@ -59,4 +60,12 @@ To reorder a secret import, hover over it and drag the arrows handle to the posi
|
||||
|
||||

|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/o11bMU0pXRs?si=dCprt3xLWPrSOJxy" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/o11bMU0pXRs?si=dCprt3xLWPrSOJxy"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
|
@@ -284,7 +284,7 @@ if err != nil {
|
||||
}
|
||||
```
|
||||
|
||||
## Working With Secrets
|
||||
## Secrets
|
||||
|
||||
### List Secrets
|
||||
|
||||
@@ -588,7 +588,7 @@ Create multiple secrets in Infisical.
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
## Working With Folders
|
||||
## Folders
|
||||
|
||||
###
|
||||
|
||||
@@ -745,3 +745,353 @@ deletedFolder, err := client.Folders().Delete(infisical.DeleteFolderOptions{
|
||||
</Expandable>
|
||||
|
||||
</ParamField>
|
||||
|
||||
## KMS
|
||||
|
||||
### Create Key
|
||||
|
||||
`client.Kms().Keys().Create(options)`
|
||||
|
||||
Create a new key in Infisical.
|
||||
|
||||
```go
|
||||
newKey, err := client.Kms().Keys().Create(infisical.KmsCreateKeyOptions{
|
||||
KeyUsage: "<sign-verify>|<encrypt-decrypt>",
|
||||
Description: "<key-description>",
|
||||
Name: "<key-name>",
|
||||
EncryptionAlgorithm: "<rsa-4096>|<ecc-nist-p256>|<aes-256-gcm>|<aes-128-gcm>",
|
||||
ProjectId: "<project-id>",
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyUsage" type="string" required>
|
||||
The usage of the key. Valid options are `sign-verify` or `encrypt-decrypt`.
|
||||
The usage dictates what the key can be used for.
|
||||
</ParamField>
|
||||
<ParamField query="Description" type="string" optional>
|
||||
The description of the key.
|
||||
</ParamField>
|
||||
<ParamField query="Name" type="string" required>
|
||||
The name of the key.
|
||||
</ParamField>
|
||||
<ParamField query="EncryptionAlgorithm" type="string" required>
|
||||
The encryption algorithm of the key.
|
||||
|
||||
Valid options for Signing/Verifying keys are:
|
||||
- `rsa-4096`
|
||||
- `ecc-nist-p256`
|
||||
|
||||
Valid options for Encryption/Decryption keys are:
|
||||
- `aes-256-gcm`
|
||||
- `aes-128-gcm`
|
||||
</ParamField>
|
||||
<ParamField query="ProjectId" type="string" required>
|
||||
The ID of the project where the key will be created.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return (object)
|
||||
<ParamField query="Return" type="object">
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key that was created.
|
||||
</ParamField>
|
||||
<ParamField query="Name" type="string" required>
|
||||
The name of the key that was created.
|
||||
</ParamField>
|
||||
<ParamField query="Description" type="string" required>
|
||||
The description of the key that was created.
|
||||
</ParamField>
|
||||
<ParamField query="IsDisabled" type="boolean" required>
|
||||
Whether or not the key is disabled.
|
||||
</ParamField>
|
||||
<ParamField query="OrgId" type="string" required>
|
||||
The ID of the organization that the key belongs to.
|
||||
</ParamField>
|
||||
<ParamField query="ProjectId" type="string" required>
|
||||
The ID of the project that the key belongs to.
|
||||
</ParamField>
|
||||
<ParamField query="KeyUsage" type="string" required>
|
||||
The intended usage of the key that was created.
|
||||
</ParamField>
|
||||
<ParamField query="EncryptionAlgorithm" type="string" required>
|
||||
The encryption algorithm of the key that was created.
|
||||
</ParamField>
|
||||
<ParamField query="Version" type="string" required>
|
||||
The version of the key that was created.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
### Delete Key
|
||||
|
||||
`client.Kms().Keys().Delete(options)`
|
||||
|
||||
Delete a key in Infisical.
|
||||
|
||||
```go
|
||||
deletedKey, err = client.Kms().Keys().Delete(infisical.KmsDeleteKeyOptions{
|
||||
KeyId: "<key-id>",
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to delete.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return (object)
|
||||
<ParamField query="Return" type="object">
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key that was deleted
|
||||
</ParamField>
|
||||
<ParamField query="Name" type="string" required>
|
||||
The name of the key that was deleted.
|
||||
</ParamField>
|
||||
<ParamField query="Description" type="string" required>
|
||||
The description of the key that was deleted.
|
||||
</ParamField>
|
||||
<ParamField query="IsDisabled" type="boolean" required>
|
||||
Whether or not the key is disabled.
|
||||
</ParamField>
|
||||
<ParamField query="OrgId" type="string" required>
|
||||
The ID of the organization that the key belonged to.
|
||||
</ParamField>
|
||||
<ParamField query="ProjectId" type="string" required>
|
||||
The ID of the project that the key belonged to.
|
||||
</ParamField>
|
||||
<ParamField query="KeyUsage" type="string" required>
|
||||
The intended usage of the key that was deleted.
|
||||
</ParamField>
|
||||
<ParamField query="EncryptionAlgorithm" type="string" required>
|
||||
The encryption algorithm of the key that was deleted.
|
||||
</ParamField>
|
||||
<ParamField query="Version" type="string" required>
|
||||
The version of the key that was deleted.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
### Signing Data
|
||||
|
||||
`client.Kms().Signing().Sign(options)`
|
||||
Sign data in Infisical.
|
||||
|
||||
```go
|
||||
res, err := client.Kms().Signing().SignData(infisical.KmsSignDataOptions{
|
||||
KeyId: "<key-id>",
|
||||
Data: "<data-to-sign>", // Must be a base64 encoded string.
|
||||
SigningAlgorithm: "<signing-algorithm>", // The signing algorithm that will be used to sign the data.
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to sign the data with.
|
||||
</ParamField>
|
||||
<ParamField query="Data" type="string" required>
|
||||
The data to sign. Must be a base64 encoded string.
|
||||
</ParamField>
|
||||
<ParamField query="IsDigest" type="boolean" optional>
|
||||
Whether the data is already digested or not.
|
||||
</ParamField>
|
||||
<ParamField query="SigningAlgorithm" type="string" required>
|
||||
The signing algorithm to use. You must use a signing algorithm that matches the key usage.
|
||||
|
||||
<Note>
|
||||
If you are unsure about which signing algorithms are available for your key, you can use the `client.Kms().Signing().ListSigningAlgorithms()` method. It will return an array of signing algorithms that are available for your key.
|
||||
</Note>
|
||||
|
||||
Valid options for `RSA 4096` keys are:
|
||||
- `RSASSA_PSS_SHA_512`
|
||||
- `RSASSA_PSS_SHA_384`
|
||||
- `RSASSA_PSS_SHA_256`
|
||||
- `RSASSA_PKCS1_V1_5_SHA_512`
|
||||
- `RSASSA_PKCS1_V1_5_SHA_384`
|
||||
- `RSASSA_PKCS1_V1_5_SHA_256`
|
||||
|
||||
Valid options for `ECC NIST P256` keys are:
|
||||
- `ECDSA_SHA_512`
|
||||
- `ECDSA_SHA_384`
|
||||
- `ECDSA_SHA_256`
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return ([]byte)
|
||||
<ParamField query="Return" type="[]byte">
|
||||
The signature of the data that was signed.
|
||||
</ParamField>
|
||||
|
||||
### Verifying Data
|
||||
|
||||
`client.Kms().Signing().Verify(options)`
|
||||
Verify data in Infisical.
|
||||
|
||||
```go
|
||||
res, err := client.Kms().Signing().Verify(infisical.KmsVerifyDataOptions{
|
||||
KeyId: "<key-id>",
|
||||
Data: "<data-to-verify>", // Must be a base64 encoded string.
|
||||
SigningAlgorithm: "<signing-algorithm>", // The signing algorithm that was used to sign the data.
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to verify the data with.
|
||||
</ParamField>
|
||||
<ParamField query="Data" type="string" required>
|
||||
The data to verify. Must be a base64 encoded string.
|
||||
</ParamField>
|
||||
<ParamField query="IsDigest" type="boolean" optional>
|
||||
Whether the data is already digested or not.
|
||||
</ParamField>
|
||||
<ParamField query="SigningAlgorithm" type="string" required>
|
||||
The signing algorithm that was used to sign the data.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return (object)
|
||||
<ParamField query="Return" type="object">
|
||||
<Expandable title="properties">
|
||||
<ParamField query="SignatureValid" type="boolean" required>
|
||||
Whether or not the data is valid.
|
||||
</ParamField>
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key that was used to verify the data.
|
||||
</ParamField>
|
||||
<ParamField query="SigningAlgorithm" type="string" required>
|
||||
The signing algorithm that was used to verify the data.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
### List Signing Algorithms
|
||||
|
||||
`client.Kms().Signing().ListSigningAlgorithms(options)`
|
||||
List signing algorithms in Infisical.
|
||||
|
||||
```go
|
||||
res, err := client.Kms().Signing().ListSigningAlgorithms(infisical.KmsListSigningAlgorithmsOptions{
|
||||
KeyId: "<key-id>",
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to list signing algorithms for.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return ([]string)
|
||||
<ParamField query="Return" type="[]string">
|
||||
The signing algorithms that are available for the key.
|
||||
</ParamField>
|
||||
|
||||
### Get Public Key
|
||||
<Note>
|
||||
This method is only available for keys with key usage `sign-verify`. If you attempt to use this method on a key that is intended for encryption/decryption, it will return an error.
|
||||
</Note>
|
||||
|
||||
`client.Kms().Signing().GetPublicKey(options)`
|
||||
Get the public key in Infisical.
|
||||
|
||||
```go
|
||||
publicKey, err := client.Kms().Signing().GetPublicKey(infisical.KmsGetPublicKeyOptions{
|
||||
KeyId: "<key-id>",
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to get the public key for.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return (string)
|
||||
<ParamField query="Return" type="string">
|
||||
The public key for the key.
|
||||
</ParamField>
|
||||
|
||||
### Encrypt Data
|
||||
|
||||
`client.Kms().Encryption().Encrypt(options)`
|
||||
Encrypt data with a key in Infisical KMS.
|
||||
|
||||
```go
|
||||
res, err := client.Kms().EncryptData(infisical.KmsEncryptDataOptions{
|
||||
KeyId: "<key-id>",
|
||||
Plaintext: "<data-to-encrypt>",
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to encrypt the data with.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return (string)
|
||||
<ParamField query="Return" type="string">
|
||||
The encrypted data.
|
||||
</ParamField>
|
||||
|
||||
### Decrypt Data
|
||||
|
||||
`client.Kms().DecryptData(options)`
|
||||
Decrypt data with a key in Infisical KMS.
|
||||
|
||||
```go
|
||||
res, err := client.Kms().DecryptData(infisical.KmsDecryptDataOptions{
|
||||
KeyId: "<key-id>",
|
||||
Ciphertext: "<encrypted-data>",
|
||||
})
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
<ParamField query="Parameters" type="object" optional>
|
||||
<Expandable title="properties">
|
||||
<ParamField query="KeyId" type="string" required>
|
||||
The ID of the key to decrypt the data with.
|
||||
</ParamField>
|
||||
<ParamField query="Ciphertext" type="string" required>
|
||||
The encrypted data to decrypt.
|
||||
</ParamField>
|
||||
</Expandable>
|
||||
</ParamField>
|
||||
|
||||
#### Return (string)
|
||||
<ParamField query="Return" type="string">
|
||||
The decrypted data.
|
||||
</ParamField>
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MongoAbility, MongoQuery } from "@casl/ability";
|
||||
import {
|
||||
faAnglesUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faDownLeftAndUpRightToCenter,
|
||||
faUpRightAndDownLeftFromCenter,
|
||||
faWindowRestore
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
ConnectionLineType,
|
||||
ControlButton,
|
||||
Controls,
|
||||
Node,
|
||||
NodeMouseHandler,
|
||||
@@ -23,7 +26,9 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
|
||||
import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components";
|
||||
import { AccessTreeSecretPathInput } from "./nodes/FolderNode/components/AccessTreeSecretPathInput";
|
||||
import { ShowMoreButtonNode } from "./nodes/ShowMoreButtonNode";
|
||||
import { AccessTreeErrorBoundary, AccessTreeProvider } from "./components";
|
||||
import { BasePermissionEdge } from "./edges";
|
||||
import { useAccessTree } from "./hooks";
|
||||
import { FolderNode, RoleNode } from "./nodes";
|
||||
@@ -35,13 +40,30 @@ export type AccessTreeProps = {
|
||||
|
||||
const EdgeTypes = { base: BasePermissionEdge };
|
||||
|
||||
const NodeTypes = { role: RoleNode, folder: FolderNode };
|
||||
const NodeTypes = { role: RoleNode, folder: FolderNode, showMoreButton: ShowMoreButtonNode };
|
||||
|
||||
const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
const accessTreeData = useAccessTree(permissions);
|
||||
const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData;
|
||||
const [selectedPath, setSelectedPath] = useState<string>("/");
|
||||
const accessTreeData = useAccessTree(permissions, selectedPath);
|
||||
const { edges, nodes, isLoading, viewMode, setViewMode, environment } = accessTreeData;
|
||||
const [initialRender, setInitialRender] = useState(true);
|
||||
|
||||
const { fitView, getViewport, setCenter } = useReactFlow();
|
||||
useEffect(() => {
|
||||
setSelectedPath("/");
|
||||
}, [environment]);
|
||||
|
||||
const { getViewport, setCenter, fitView } = useReactFlow();
|
||||
|
||||
const goToRootNode = useCallback(() => {
|
||||
const roleNode = nodes.find((node) => node.type === "role");
|
||||
if (roleNode) {
|
||||
setCenter(
|
||||
roleNode.position.x + (roleNode.width ? roleNode.width / 2 : 0),
|
||||
roleNode.position.y + (roleNode.height ? roleNode.height / 2 : 0),
|
||||
{ duration: 800, zoom: 1 }
|
||||
);
|
||||
}
|
||||
}, [nodes, setCenter]);
|
||||
|
||||
const onNodeClick: NodeMouseHandler<Node> = useCallback(
|
||||
(_, node) => {
|
||||
@@ -55,14 +77,19 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
padding: 0.2,
|
||||
duration: 1000,
|
||||
maxZoom: 1
|
||||
});
|
||||
}, 1);
|
||||
}, [fitView, nodes, edges, getViewport()]);
|
||||
setInitialRender(true);
|
||||
}, [selectedPath, environment]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (initialRender) {
|
||||
timer = setTimeout(() => {
|
||||
goToRootNode();
|
||||
setInitialRender(false);
|
||||
}, 500);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [nodes, edges, getViewport(), initialRender, goToRootNode]);
|
||||
|
||||
const handleToggleModalView = () =>
|
||||
setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal));
|
||||
@@ -133,13 +160,13 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
edges={edges}
|
||||
edgeTypes={EdgeTypes}
|
||||
nodeTypes={NodeTypes}
|
||||
fitView
|
||||
onNodeClick={onNodeClick}
|
||||
colorMode="dark"
|
||||
nodesDraggable={false}
|
||||
edgesReconnectable={false}
|
||||
nodesConnectable={false}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
minZoom={0.001}
|
||||
proOptions={{
|
||||
hideAttribution: false // we need pro license if we want to hide
|
||||
}}
|
||||
@@ -151,9 +178,17 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
)}
|
||||
{viewMode !== ViewMode.Docked && (
|
||||
<Panel position="top-right" className="flex gap-1.5">
|
||||
{viewMode !== ViewMode.Undocked && (
|
||||
<AccessTreeSecretPathInput
|
||||
placeholder="Provide a path, default is /"
|
||||
environment={environment}
|
||||
value={selectedPath}
|
||||
onChange={setSelectedPath}
|
||||
/>
|
||||
)}
|
||||
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
|
||||
<IconButton
|
||||
className="mr-1 rounded"
|
||||
className="ml-1 w-10 rounded"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={handleToggleUndockedView}
|
||||
@@ -170,7 +205,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
</Tooltip>
|
||||
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
|
||||
<IconButton
|
||||
className="rounded"
|
||||
className="w-10 rounded"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={handleToggleModalView}
|
||||
@@ -179,7 +214,7 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
viewMode === ViewMode.Modal
|
||||
? faArrowUpRightFromSquare
|
||||
? faDownLeftAndUpRightToCenter
|
||||
: faUpRightAndDownLeftFromCenter
|
||||
}
|
||||
/>
|
||||
@@ -187,9 +222,28 @@ const AccessTreeContent = ({ permissions }: AccessTreeProps) => {
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
)}
|
||||
<PermissionSimulation {...accessTreeData} />
|
||||
{viewMode === ViewMode.Docked && (
|
||||
<Panel position="top-right" className="flex gap-1.5">
|
||||
<AccessTreeSecretPathInput
|
||||
placeholder="Provide a path, default is /"
|
||||
environment={environment}
|
||||
value={selectedPath}
|
||||
onChange={setSelectedPath}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
|
||||
<Controls position="bottom-left" />
|
||||
<Controls
|
||||
position="bottom-left"
|
||||
showInteractive={false}
|
||||
onFitView={() => fitView({ duration: 800 })}
|
||||
>
|
||||
<ControlButton onClick={goToRootNode}>
|
||||
<Tooltip position="right" content="Go to root folder">
|
||||
<FontAwesomeIcon icon={faAnglesUp} />
|
||||
</Tooltip>
|
||||
</ControlButton>
|
||||
</Controls>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -46,6 +46,12 @@ export const PermissionSimulation = ({
|
||||
className="mr-1 rounded"
|
||||
colorSchema="secondary"
|
||||
onClick={handlePermissionSimulation}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon
|
||||
className="pl-1 text-sm text-bunker-300 hover:text-primary hover:opacity-80"
|
||||
icon={faChevronDown}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Permission Simulation
|
||||
</Button>
|
||||
|
@@ -5,6 +5,7 @@ import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
|
||||
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
import { useAccessTreeContext } from "../components";
|
||||
import { PermissionAccess } from "../types";
|
||||
@@ -15,8 +16,24 @@ import {
|
||||
getSubjectActionRuleMap,
|
||||
positionElements
|
||||
} from "../utils";
|
||||
import { createShowMoreNode } from "../utils/createShowMoreNode";
|
||||
|
||||
export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, MongoQuery>) => {
|
||||
const INITIAL_FOLDERS_PER_LEVEL = 10;
|
||||
const FOLDERS_INCREMENT = 10;
|
||||
|
||||
type LevelFolderMap = Record<
|
||||
string,
|
||||
{
|
||||
folders: TSecretFolderWithPath[];
|
||||
visibleCount: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export const useAccessTree = (
|
||||
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>,
|
||||
searchPath: string
|
||||
) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
|
||||
const [nodes, setNodes] = useNodesState<Node>([]);
|
||||
@@ -27,19 +44,124 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
||||
currentWorkspace.id
|
||||
);
|
||||
|
||||
const [levelFolderMap, setLevelFolderMap] = useState<LevelFolderMap>({});
|
||||
const [totalFolderCount, setTotalFolderCount] = useState(0);
|
||||
|
||||
const showMoreFolders = (parentId: string) => {
|
||||
setLevelFolderMap((prevMap) => {
|
||||
const level = prevMap[parentId];
|
||||
if (!level) return prevMap;
|
||||
|
||||
const newVisibleCount = Math.min(
|
||||
level.visibleCount + FOLDERS_INCREMENT,
|
||||
level.folders.length
|
||||
);
|
||||
|
||||
return {
|
||||
...prevMap,
|
||||
[parentId]: {
|
||||
...level,
|
||||
visibleCount: newVisibleCount,
|
||||
hasMore: newVisibleCount < level.folders.length
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const levelsWithMoreFolders = Object.entries(levelFolderMap)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, level]) => level.hasMore)
|
||||
.map(([parentId]) => parentId);
|
||||
|
||||
const getLevelCounts = (parentId: string) => {
|
||||
const level = levelFolderMap[parentId];
|
||||
if (!level) return { visibleCount: 0, totalCount: 0, hasMore: false };
|
||||
|
||||
return {
|
||||
visibleCount: level.visibleCount,
|
||||
totalCount: level.folders.length,
|
||||
hasMore: level.hasMore
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!environmentsFolders || !permissions || !environmentsFolders[environment]) return;
|
||||
|
||||
const { folders, name } = environmentsFolders[environment];
|
||||
const { folders } = environmentsFolders[environment];
|
||||
setTotalFolderCount(folders.length);
|
||||
const groupedFolders: Record<string, TSecretFolderWithPath[]> = {};
|
||||
|
||||
const filteredFolders = folders.filter((folder) => {
|
||||
if (folder.path.startsWith(searchPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
searchPath.startsWith(folder.path) &&
|
||||
(folder.path === "/" ||
|
||||
searchPath === folder.path ||
|
||||
searchPath.indexOf("/", folder.path.length) === folder.path.length)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
filteredFolders.forEach((folder) => {
|
||||
const parentId = folder.parentId || "";
|
||||
if (!groupedFolders[parentId]) {
|
||||
groupedFolders[parentId] = [];
|
||||
}
|
||||
groupedFolders[parentId].push(folder);
|
||||
});
|
||||
|
||||
const newLevelFolderMap: LevelFolderMap = {};
|
||||
|
||||
Object.entries(groupedFolders).forEach(([parentId, folderList]) => {
|
||||
const key = parentId;
|
||||
newLevelFolderMap[key] = {
|
||||
folders: folderList,
|
||||
visibleCount: Math.min(INITIAL_FOLDERS_PER_LEVEL, folderList.length),
|
||||
hasMore: folderList.length > INITIAL_FOLDERS_PER_LEVEL
|
||||
};
|
||||
});
|
||||
|
||||
setLevelFolderMap(newLevelFolderMap);
|
||||
}, [permissions, environmentsFolders, environment, subject, secretName, searchPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!environmentsFolders ||
|
||||
!permissions ||
|
||||
!environmentsFolders[environment] ||
|
||||
Object.keys(levelFolderMap).length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
const { slug } = environmentsFolders[environment];
|
||||
|
||||
const roleNode = createRoleNode({
|
||||
subject,
|
||||
environment: name
|
||||
environment: slug,
|
||||
environments: environmentsFolders,
|
||||
onSubjectChange: setSubject,
|
||||
onEnvironmentChange: setEnvironment
|
||||
});
|
||||
|
||||
const actionRuleMap = getSubjectActionRuleMap(subject, permissions);
|
||||
|
||||
const folderNodes = folders.map((folder) =>
|
||||
const visibleFolders: TSecretFolderWithPath[] = [];
|
||||
Object.entries(levelFolderMap).forEach(([key, levelData]) => {
|
||||
if (key !== "__rootFolderId") {
|
||||
visibleFolders.push(...levelData.folders.slice(0, levelData.visibleCount));
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const rootFolder = levelFolderMap.__rootFolderId?.folders[0];
|
||||
|
||||
const folderNodes = visibleFolders.map((folder) =>
|
||||
createFolderNode({
|
||||
folder,
|
||||
permissions,
|
||||
@@ -50,10 +172,45 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
||||
})
|
||||
);
|
||||
|
||||
const folderEdges = folderNodes.map(({ data: folder }) => {
|
||||
const actions = Object.values(folder.actions);
|
||||
const folderEdges: Edge[] = [];
|
||||
|
||||
if (rootFolder) {
|
||||
const rootFolderNode = folderNodes.find(
|
||||
(node) => node.data.id === rootFolder.id || node.data.path === rootFolder.path
|
||||
);
|
||||
|
||||
if (rootFolderNode) {
|
||||
const rootActions = Object.values(rootFolderNode.data.actions);
|
||||
let rootAccess: PermissionAccess;
|
||||
|
||||
if (Object.values(rootActions).some((action) => action === PermissionAccess.Full)) {
|
||||
rootAccess = PermissionAccess.Full;
|
||||
} else if (
|
||||
Object.values(rootActions).some((action) => action === PermissionAccess.Partial)
|
||||
) {
|
||||
rootAccess = PermissionAccess.Partial;
|
||||
} else {
|
||||
rootAccess = PermissionAccess.None;
|
||||
}
|
||||
|
||||
folderEdges.push(
|
||||
createBaseEdge({
|
||||
source: roleNode.id,
|
||||
target: rootFolderNode.id,
|
||||
access: rootAccess
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
folderNodes.forEach(({ data: folder }) => {
|
||||
if (rootFolder && (folder.id === rootFolder.id || folder.path === rootFolder.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = Object.values(folder.actions);
|
||||
let access: PermissionAccess;
|
||||
|
||||
if (Object.values(actions).some((action) => action === PermissionAccess.Full)) {
|
||||
access = PermissionAccess.Full;
|
||||
} else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) {
|
||||
@@ -62,17 +219,55 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
||||
access = PermissionAccess.None;
|
||||
}
|
||||
|
||||
return createBaseEdge({
|
||||
source: folder.parentId ?? roleNode.id,
|
||||
target: folder.id,
|
||||
access
|
||||
});
|
||||
folderEdges.push(
|
||||
createBaseEdge({
|
||||
source: folder.parentId ?? roleNode.id,
|
||||
target: folder.id,
|
||||
access
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const init = positionElements([roleNode, ...folderNodes], [...folderEdges]);
|
||||
const addMoreButtons: Node[] = [];
|
||||
|
||||
Object.entries(levelFolderMap).forEach(([parentId, levelData]) => {
|
||||
if (parentId === "__rootFolderId") return;
|
||||
|
||||
const key = parentId === "null" ? null : parentId;
|
||||
|
||||
if (key && levelData.hasMore) {
|
||||
const showMoreButtonNode = createShowMoreNode({
|
||||
parentId: key,
|
||||
onClick: () => showMoreFolders(key),
|
||||
remaining: levelData.folders.length - levelData.visibleCount,
|
||||
subject
|
||||
});
|
||||
|
||||
addMoreButtons.push(showMoreButtonNode);
|
||||
|
||||
folderEdges.push(
|
||||
createBaseEdge({
|
||||
source: key,
|
||||
target: showMoreButtonNode.id,
|
||||
access: PermissionAccess.Partial
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const init = positionElements([roleNode, ...folderNodes, ...addMoreButtons], [...folderEdges]);
|
||||
setNodes(init.nodes);
|
||||
setEdges(init.edges);
|
||||
}, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]);
|
||||
}, [
|
||||
levelFolderMap,
|
||||
permissions,
|
||||
environmentsFolders,
|
||||
environment,
|
||||
subject,
|
||||
secretName,
|
||||
setNodes,
|
||||
setEdges
|
||||
]);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
@@ -86,6 +281,11 @@ export const useAccessTree = (permissions: MongoAbility<ProjectPermissionSet, Mo
|
||||
secretName,
|
||||
setSecretName,
|
||||
viewMode,
|
||||
setViewMode
|
||||
setViewMode,
|
||||
levelFolderMap,
|
||||
showMoreFolders,
|
||||
levelsWithMoreFolders,
|
||||
getLevelCounts,
|
||||
totalFolderCount
|
||||
};
|
||||
};
|
||||
|
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
|
||||
type AccessTreeSecretPathInputProps = {
|
||||
placeholder: string;
|
||||
environment: string;
|
||||
value: string;
|
||||
onChange: (path: string) => void;
|
||||
};
|
||||
|
||||
export const AccessTreeSecretPathInput = ({
|
||||
placeholder,
|
||||
environment,
|
||||
value,
|
||||
onChange
|
||||
}: AccessTreeSecretPathInputProps) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const timeout: NodeJS.Timeout = setTimeout(() => {
|
||||
setIsFocused(false);
|
||||
}, 200);
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const focusInput = () => {
|
||||
const inputElement = inputRef.current?.querySelector("input");
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSearch = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
if (!isExpanded) {
|
||||
const timeout: NodeJS.Timeout = setTimeout(focusInput, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
return () => {};
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center overflow-hidden rounded transition-all duration-300 ease-in-out",
|
||||
isFocused ? "bg-mineshaft-800 shadow-md" : "bg-mineshaft-700",
|
||||
isExpanded ? "w-64" : "h-10 w-10"
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
|
||||
onClick={toggleSearch}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggleSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip position="bottom" content="Search paths">
|
||||
<div
|
||||
className="flex h-10 w-10 cursor-pointer items-center justify-center text-mineshaft-300 hover:text-white"
|
||||
onClick={toggleSearch}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggleSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={inputRef}
|
||||
className={twMerge(
|
||||
"flex-1 transition-opacity duration-300",
|
||||
isExpanded ? "opacity-100" : "hidden"
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
role="search"
|
||||
>
|
||||
<div className="custom-input-wrapper">
|
||||
<SecretPathInput
|
||||
placeholder={placeholder}
|
||||
environment={environment}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,10 +1,42 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
|
||||
import { Select, SelectItem } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
import { createRoleNode } from "../utils";
|
||||
|
||||
const getSubjectIcon = (subject: ProjectPermissionSub) => {
|
||||
switch (subject) {
|
||||
case ProjectPermissionSub.Secrets:
|
||||
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
|
||||
case ProjectPermissionSub.SecretFolders:
|
||||
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
|
||||
case ProjectPermissionSub.DynamicSecrets:
|
||||
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
|
||||
case ProjectPermissionSub.SecretImports:
|
||||
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
|
||||
default:
|
||||
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatLabel = (text: string) => {
|
||||
return text.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const RoleNode = ({
|
||||
data: { subject, environment }
|
||||
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
|
||||
data: { subject, environment, onSubjectChange, onEnvironmentChange, environments }
|
||||
}: NodeProps & {
|
||||
data: ReturnType<typeof createRoleNode>["data"] & {
|
||||
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||
onEnvironmentChange: (value: string) => void;
|
||||
environments: TProjectEnvironmentsFolders;
|
||||
};
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
@@ -12,11 +44,60 @@ export const RoleNode = ({
|
||||
className="pointer-events-none !cursor-pointer opacity-0"
|
||||
position={Position.Top}
|
||||
/>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
|
||||
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
|
||||
<span className="capitalize">{subject.replace("-", " ")} Access</span>
|
||||
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
|
||||
<p className="truncate capitalize">{environment}</p>
|
||||
<div className="flex w-full flex-col items-center justify-center rounded-md border-2 border-mineshaft-500 bg-gradient-to-b from-mineshaft-700 to-mineshaft-800 px-5 py-4 font-inter shadow-2xl">
|
||||
<div className="flex w-full min-w-[240px] flex-col gap-4">
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Subject</div>
|
||||
<Select
|
||||
value={subject}
|
||||
onValueChange={(value) => onSubjectChange(value as ProjectPermissionSub)}
|
||||
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
aria-label="Subject"
|
||||
>
|
||||
{[
|
||||
ProjectPermissionSub.Secrets,
|
||||
ProjectPermissionSub.SecretFolders,
|
||||
ProjectPermissionSub.DynamicSecrets,
|
||||
ProjectPermissionSub.SecretImports
|
||||
].map((sub) => {
|
||||
return (
|
||||
<SelectItem
|
||||
className="relative flex items-center gap-2 py-2 pl-8 pr-8 text-sm capitalize hover:bg-mineshaft-700"
|
||||
value={sub}
|
||||
key={sub}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{getSubjectIcon(sub)}
|
||||
<span className="font-medium">{formatLabel(sub)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="ml-1 text-xs font-semibold text-mineshaft-200">Environment</div>
|
||||
<Select
|
||||
value={environment}
|
||||
onValueChange={onEnvironmentChange}
|
||||
className="w-full rounded-md border border-mineshaft-600 bg-mineshaft-900/90 text-sm shadow-inner backdrop-blur-sm transition-all hover:border-amber-600/50 focus:border-amber-500"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
aria-label="Environment"
|
||||
>
|
||||
{Object.values(environments).map((env) => (
|
||||
<SelectItem
|
||||
key={env.slug}
|
||||
value={env.slug}
|
||||
className="relative py-2 pl-6 pr-8 text-sm hover:bg-mineshaft-700"
|
||||
>
|
||||
<div className="ml-3 font-medium">{env.name}</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,37 @@
|
||||
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
|
||||
import { Button, Tooltip } from "@app/components/v2";
|
||||
|
||||
import { createShowMoreNode } from "../utils/createShowMoreNode";
|
||||
|
||||
export const ShowMoreButtonNode = ({
|
||||
data: { onClick, remaining }
|
||||
}: NodeProps & { data: ReturnType<typeof createShowMoreNode>["data"] }) => {
|
||||
const tooltipText = `${remaining} ${remaining === 1 ? "folder is" : "folders are"} hidden. Click to show ${remaining > 10 ? "10 more" : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-2">
|
||||
<Handle
|
||||
type="target"
|
||||
className="pointer-events-none !cursor-pointer opacity-0"
|
||||
position={Position.Top}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Tooltip position="right" content={tooltipText}>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
size="xs"
|
||||
onClick={onClick}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronRight} className="ml-1" />}
|
||||
>
|
||||
Show More
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -7,7 +7,8 @@ export enum PermissionAccess {
|
||||
export enum PermissionNode {
|
||||
Role = "role",
|
||||
Folder = "folder",
|
||||
Environment = "environment"
|
||||
Environment = "environment",
|
||||
ShowMoreButton = "showMoreButton"
|
||||
}
|
||||
|
||||
export enum PermissionEdge {
|
||||
|
@@ -5,11 +5,13 @@ import { PermissionAccess, PermissionEdge } from "../types";
|
||||
export const createBaseEdge = ({
|
||||
source,
|
||||
target,
|
||||
access
|
||||
access,
|
||||
hideEdge = false
|
||||
}: {
|
||||
source: string;
|
||||
target: string;
|
||||
access: PermissionAccess;
|
||||
hideEdge?: boolean;
|
||||
}) => {
|
||||
const color = access === PermissionAccess.None ? "#707174" : "#ccccce";
|
||||
return {
|
||||
@@ -17,10 +19,12 @@ export const createBaseEdge = ({
|
||||
source,
|
||||
target,
|
||||
type: PermissionEdge.Base,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color
|
||||
},
|
||||
style: { stroke: color }
|
||||
markerEnd: hideEdge
|
||||
? undefined
|
||||
: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color
|
||||
},
|
||||
style: { stroke: hideEdge ? "transparent" : color }
|
||||
};
|
||||
};
|
||||
|
@@ -1,17 +1,31 @@
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { TProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
import { PermissionNode } from "../types";
|
||||
|
||||
export const createRoleNode = ({
|
||||
subject,
|
||||
environment
|
||||
environment,
|
||||
environments,
|
||||
onSubjectChange,
|
||||
onEnvironmentChange
|
||||
}: {
|
||||
subject: string;
|
||||
environment: string;
|
||||
environments: TProjectEnvironmentsFolders;
|
||||
onSubjectChange: Dispatch<SetStateAction<ProjectPermissionSub>>;
|
||||
onEnvironmentChange: (value: string) => void;
|
||||
}) => ({
|
||||
id: `role-${subject}-${environment}`,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
subject,
|
||||
environment
|
||||
environment,
|
||||
environments,
|
||||
onSubjectChange,
|
||||
onEnvironmentChange
|
||||
},
|
||||
type: PermissionNode.Role,
|
||||
height: 48,
|
||||
|
@@ -0,0 +1,45 @@
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
|
||||
import { PermissionNode } from "../types";
|
||||
|
||||
export const createShowMoreNode = ({
|
||||
parentId,
|
||||
onClick,
|
||||
remaining,
|
||||
subject
|
||||
}: {
|
||||
parentId: string | null;
|
||||
onClick: () => void;
|
||||
remaining: number;
|
||||
subject: ProjectPermissionSub;
|
||||
}) => {
|
||||
let height: number;
|
||||
|
||||
switch (subject) {
|
||||
case ProjectPermissionSub.DynamicSecrets:
|
||||
height = 130;
|
||||
break;
|
||||
case ProjectPermissionSub.Secrets:
|
||||
height = 85;
|
||||
break;
|
||||
default:
|
||||
height = 64;
|
||||
}
|
||||
const id = `show-more-${parentId || "root"}`;
|
||||
return {
|
||||
id,
|
||||
type: PermissionNode.ShowMoreButton,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
parentId,
|
||||
onClick,
|
||||
remaining
|
||||
},
|
||||
width: 150,
|
||||
height,
|
||||
style: {
|
||||
background: "transparent",
|
||||
border: "none"
|
||||
}
|
||||
};
|
||||
};
|
@@ -2,27 +2,96 @@ import Dagre from "@dagrejs/dagre";
|
||||
import { Edge, Node } from "@xyflow/react";
|
||||
|
||||
export const positionElements = (nodes: Node[], edges: Edge[]) => {
|
||||
const showMoreNodes = nodes.filter((node) => node.type === "showMoreButton");
|
||||
const showMoreParentIds = new Set(
|
||||
showMoreNodes.map((node) => node.data.parentId).filter(Boolean)
|
||||
);
|
||||
|
||||
const nodeMap: Record<string, Node> = {};
|
||||
const childrenMap: Record<string, string[]> = {};
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (!childrenMap[edge.source]) {
|
||||
childrenMap[edge.source] = [];
|
||||
}
|
||||
childrenMap[edge.source].push(edge.target);
|
||||
});
|
||||
|
||||
const dagre = new Dagre.graphlib.Graph({ directed: true })
|
||||
.setDefaultEdgeLabel(() => ({}))
|
||||
.setGraph({ rankdir: "TB" });
|
||||
.setGraph({
|
||||
rankdir: "TB",
|
||||
nodesep: 50,
|
||||
ranksep: 70
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagre.setNode(node.id, {
|
||||
width: node.width || 150,
|
||||
height: node.height || 40
|
||||
});
|
||||
});
|
||||
|
||||
edges.forEach((edge) => dagre.setEdge(edge.source, edge.target));
|
||||
nodes.forEach((node) => dagre.setNode(node.id, node));
|
||||
|
||||
Dagre.layout(dagre, {});
|
||||
|
||||
return {
|
||||
nodes: nodes.map((node) => {
|
||||
const { x, y } = dagre.node(node.id);
|
||||
const positionedNodes = nodes.map((node) => {
|
||||
const { x, y } = dagre.node(node.id);
|
||||
|
||||
if (node.type === "role") {
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: x - (node.width ? node.width / 2 : 0),
|
||||
y: y - (node.height ? node.height / 2 : 0)
|
||||
y: y - 150
|
||||
}
|
||||
};
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: x - (node.width ? node.width / 2 : 0),
|
||||
y: y - (node.height ? node.height / 2 : 0)
|
||||
},
|
||||
style: node.type === "showMoreButton" ? { ...node.style, zIndex: 10 } : node.style
|
||||
};
|
||||
});
|
||||
|
||||
positionedNodes.forEach((node) => {
|
||||
nodeMap[node.id] = node;
|
||||
});
|
||||
|
||||
Array.from(showMoreParentIds).forEach((parentId) => {
|
||||
const showMoreNodeIndex = positionedNodes.findIndex(
|
||||
(node) => node.type === "showMoreButton" && node.data.parentId === parentId
|
||||
);
|
||||
|
||||
if (showMoreNodeIndex !== -1) {
|
||||
const siblings = positionedNodes.filter(
|
||||
(node) => node.data?.parentId === parentId && node.type !== "showMoreButton"
|
||||
);
|
||||
|
||||
if (siblings.length > 0) {
|
||||
const rightmostSibling = siblings.reduce(
|
||||
(rightmost, current) => (current.position.x > rightmost.position.x ? current : rightmost),
|
||||
siblings[0]
|
||||
);
|
||||
|
||||
positionedNodes[showMoreNodeIndex] = {
|
||||
...positionedNodes[showMoreNodeIndex],
|
||||
position: {
|
||||
x: rightmostSibling.position.x + (rightmostSibling.width || 150) + 30,
|
||||
y: rightmostSibling.position.y
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: positionedNodes,
|
||||
edges
|
||||
};
|
||||
};
|
||||
|
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { faCheck, faCopy, faKey, faRefresh } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, Checkbox, IconButton, Slider } from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
|
||||
type PasswordOptionsType = {
|
||||
length: number;
|
||||
useUppercase: boolean;
|
||||
useLowercase: boolean;
|
||||
useNumbers: boolean;
|
||||
useSpecialChars: boolean;
|
||||
};
|
||||
|
||||
type PasswordGeneratorModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUsePassword?: (password: string) => void;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
const PasswordGeneratorModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUsePassword,
|
||||
minLength = 12,
|
||||
maxLength = 64
|
||||
}: PasswordGeneratorModalProps) => {
|
||||
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
|
||||
initialState: "Copy"
|
||||
});
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptionsType>({
|
||||
length: minLength,
|
||||
useUppercase: true,
|
||||
useLowercase: true,
|
||||
useNumbers: true,
|
||||
useSpecialChars: true
|
||||
});
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = {
|
||||
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
lowercase: "abcdefghijklmnopqrstuvwxyz",
|
||||
numbers: "0123456789",
|
||||
specialChars: "-_.~!*"
|
||||
};
|
||||
|
||||
let availableChars = "";
|
||||
if (passwordOptions.useUppercase) availableChars += charset.uppercase;
|
||||
if (passwordOptions.useLowercase) availableChars += charset.lowercase;
|
||||
if (passwordOptions.useNumbers) availableChars += charset.numbers;
|
||||
if (passwordOptions.useSpecialChars) availableChars += charset.specialChars;
|
||||
|
||||
if (availableChars === "") availableChars = charset.lowercase + charset.numbers;
|
||||
|
||||
let newPassword = "";
|
||||
for (let i = 0; i < passwordOptions.length; i += 1) {
|
||||
const randomIndex = Math.floor(Math.random() * availableChars.length);
|
||||
newPassword += availableChars[randomIndex];
|
||||
}
|
||||
|
||||
return newPassword;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const password = useMemo(() => {
|
||||
return generatePassword();
|
||||
}, [passwordOptions, refresh]);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(password)
|
||||
.then(() => {
|
||||
setCopyText("Copied");
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyText("Copy failed");
|
||||
});
|
||||
};
|
||||
|
||||
const usePassword = () => {
|
||||
if (onUsePassword) {
|
||||
onUsePassword(password);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="w-full max-w-lg rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-xl"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h2 className="mb-1 text-xl font-semibold text-bunker-200">Password Generator</h2>
|
||||
<p className="mb-6 text-sm text-bunker-400">Generate strong unique passwords</p>
|
||||
|
||||
<div className="relative mb-4 rounded-md bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-4/5 select-all break-all pr-2 font-mono text-lg">{password}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
variant="outline_bg"
|
||||
onClick={() => setRefresh((prev) => !prev)}
|
||||
className="w-full text-bunker-300 hover:text-bunker-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} className="mr-1 h-3 w-3" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
colorSchema="secondary"
|
||||
variant="outline_bg"
|
||||
onClick={copyToClipboard}
|
||||
className="w-full text-bunker-300 hover:text-bunker-100"
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopying ? faCheck : faCopy} className="mr-1 h-3 w-3" />
|
||||
{copyText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label htmlFor="password-length" className="text-sm text-bunker-300">
|
||||
Password length: {passwordOptions.length}
|
||||
</label>
|
||||
</div>
|
||||
<Slider
|
||||
id="password-length"
|
||||
min={minLength}
|
||||
max={maxLength}
|
||||
value={passwordOptions.length}
|
||||
onChange={(value) => setPasswordOptions({ ...passwordOptions, length: value })}
|
||||
className="mb-1"
|
||||
aria-labelledby="password-length-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-row justify-between gap-2">
|
||||
<Checkbox
|
||||
id="useUppercase"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useUppercase}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean })
|
||||
}
|
||||
>
|
||||
A-Z
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
id="useLowercase"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useLowercase}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean })
|
||||
}
|
||||
>
|
||||
a-z
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
id="useNumbers"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useNumbers}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean })
|
||||
}
|
||||
>
|
||||
0-9
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
id="useSpecialChars"
|
||||
className="mr-2 data-[state=checked]:bg-primary"
|
||||
isChecked={passwordOptions.useSpecialChars}
|
||||
onCheckedChange={(checked) =>
|
||||
setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean })
|
||||
}
|
||||
>
|
||||
-_.~!*
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" colorSchema="primary" variant="outline_bg" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{onUsePassword && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={usePassword}
|
||||
className="ml-2"
|
||||
>
|
||||
Use Password
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PasswordGeneratorProps = {
|
||||
onUsePassword?: (password: string) => void;
|
||||
isDisabled?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export const PasswordGenerator = ({
|
||||
onUsePassword,
|
||||
isDisabled = false,
|
||||
minLength = 12,
|
||||
maxLength = 64
|
||||
}: PasswordGeneratorProps) => {
|
||||
const [showGenerator, setShowGenerator] = useState(false);
|
||||
|
||||
const toggleGenerator = () => {
|
||||
setShowGenerator(!showGenerator);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ariaLabel="generate password"
|
||||
colorSchema="secondary"
|
||||
size="sm"
|
||||
onClick={toggleGenerator}
|
||||
isDisabled={isDisabled}
|
||||
className="rounded text-bunker-400 transition-colors duration-150 hover:bg-mineshaft-700 hover:text-bunker-200"
|
||||
>
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</IconButton>
|
||||
|
||||
<PasswordGeneratorModal
|
||||
isOpen={showGenerator}
|
||||
onClose={() => setShowGenerator(false)}
|
||||
onUsePassword={onUsePassword}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
2
frontend/src/components/v2/PasswordGenerator/index.tsx
Normal file
2
frontend/src/components/v2/PasswordGenerator/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { PasswordGeneratorProps } from "./PasswordGenerator";
|
||||
export { PasswordGenerator } from "./PasswordGenerator";
|
@@ -48,7 +48,6 @@ export const SecretPathInput = ({
|
||||
}, [propValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// update secret path if input is valid
|
||||
if (
|
||||
(debouncedInputValue.length > 0 &&
|
||||
debouncedInputValue[debouncedInputValue.length - 1] === "/") ||
|
||||
@@ -59,7 +58,6 @@ export const SecretPathInput = ({
|
||||
}, [debouncedInputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// filter suggestions based on matching
|
||||
const searchFragment = debouncedInputValue.split("/").pop() || "";
|
||||
const filteredSuggestions = folders
|
||||
.filter((suggestionEntry) =>
|
||||
@@ -78,7 +76,6 @@ export const SecretPathInput = ({
|
||||
const validPaths = inputValue.split("/");
|
||||
validPaths.pop();
|
||||
|
||||
// removed trailing slash
|
||||
const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`;
|
||||
onChange?.(newValue);
|
||||
setInputValue(newValue);
|
||||
@@ -102,7 +99,6 @@ export const SecretPathInput = ({
|
||||
};
|
||||
|
||||
const handleInputChange = (e: any) => {
|
||||
// propagate event to react-hook-form onChange
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
@@ -141,7 +137,7 @@ export const SecretPathInput = ({
|
||||
maxHeight: "var(--radix-select-content-available-height)"
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full flex-col items-center justify-center rounded-md text-white">
|
||||
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<div
|
||||
tabIndex={0}
|
||||
|
237
frontend/src/components/v2/Slider/Slider.tsx
Normal file
237
frontend/src/components/v2/Slider/Slider.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { cva, VariantProps } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
isDisabled?: boolean;
|
||||
isRequired?: boolean;
|
||||
showValue?: boolean;
|
||||
valuePosition?: "top" | "right";
|
||||
containerClassName?: string;
|
||||
trackClassName?: string;
|
||||
fillClassName?: string;
|
||||
thumbClassName?: string;
|
||||
onChange?: (value: number) => void;
|
||||
onChangeComplete?: (value: number) => void;
|
||||
};
|
||||
|
||||
const sliderTrackVariants = cva("h-1 w-full bg-mineshaft-600 rounded-full relative", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
thin: "h-0.5",
|
||||
thick: "h-1.5"
|
||||
},
|
||||
isDisabled: {
|
||||
true: "opacity-50 cursor-not-allowed",
|
||||
false: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sliderFillVariants = cva("absolute h-full rounded-full", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary-500",
|
||||
secondary: "bg-secondary-500",
|
||||
danger: "bg-red-500"
|
||||
},
|
||||
isDisabled: {
|
||||
true: "opacity-50",
|
||||
false: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sliderThumbVariants = cva(
|
||||
"absolute w-4 h-4 rounded-full shadow transform -translate-x-1/2 -mt-1.5 focus:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary-500 focus:ring-2 focus:ring-primary-400/50",
|
||||
secondary: "bg-secondary-500 focus:ring-2 focus:ring-secondary-400/50",
|
||||
danger: "bg-red-500 focus:ring-2 focus:ring-red-400/50"
|
||||
},
|
||||
isDisabled: {
|
||||
true: "opacity-50 cursor-not-allowed",
|
||||
false: "cursor-pointer"
|
||||
},
|
||||
size: {
|
||||
sm: "w-3 h-3 -mt-1",
|
||||
md: "w-4 h-4 -mt-1.5",
|
||||
lg: "w-5 h-5 -mt-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const sliderContainerVariants = cva("relative inline-flex font-inter", {
|
||||
variants: {
|
||||
isFullWidth: {
|
||||
true: "w-full",
|
||||
false: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange"> &
|
||||
VariantProps<typeof sliderTrackVariants> &
|
||||
VariantProps<typeof sliderThumbVariants> &
|
||||
VariantProps<typeof sliderContainerVariants> &
|
||||
Props;
|
||||
|
||||
export const Slider = forwardRef<HTMLInputElement, SliderProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
trackClassName,
|
||||
fillClassName,
|
||||
thumbClassName,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
value,
|
||||
defaultValue,
|
||||
isDisabled = false,
|
||||
isFullWidth = true,
|
||||
isRequired = false,
|
||||
showValue = false,
|
||||
valuePosition = "top",
|
||||
variant = "default",
|
||||
size = "md",
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
): JSX.Element => {
|
||||
let initialValue = min;
|
||||
if (value !== undefined) {
|
||||
initialValue = Number(value);
|
||||
} else if (defaultValue !== undefined) {
|
||||
initialValue = Number(defaultValue);
|
||||
}
|
||||
|
||||
const [currentValue, setCurrentValue] = useState<number>(initialValue);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const percentage = Math.max(0, Math.min(100, ((currentValue - min) / (max - min)) * 100));
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined && Number(value) !== currentValue) {
|
||||
setCurrentValue(Number(value));
|
||||
}
|
||||
}, [value, currentValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = Number(e.target.value);
|
||||
setCurrentValue(newValue);
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!isDisabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleChangeComplete = useCallback(() => {
|
||||
if (isDragging) {
|
||||
onChangeComplete?.(currentValue);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, [isDragging, currentValue, onChangeComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
const handleGlobalMouseUp = () => handleChangeComplete();
|
||||
|
||||
document.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
document.addEventListener("touchend", handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
document.removeEventListener("touchend", handleGlobalMouseUp);
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, [isDragging, handleChangeComplete]);
|
||||
|
||||
const ValueDisplay = showValue ? (
|
||||
<div className="text-xs text-bunker-300">{currentValue}</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderContainerVariants({ isFullWidth, className: containerClassName }),
|
||||
"my-2"
|
||||
)}
|
||||
>
|
||||
{showValue && valuePosition === "top" && ValueDisplay}
|
||||
|
||||
<div className="relative flex w-full items-center">
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderTrackVariants({ variant, isDisabled, className: trackClassName })
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderFillVariants({ variant, isDisabled, className: fillClassName }),
|
||||
"left-0"
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={twMerge(
|
||||
sliderThumbVariants({ variant, isDisabled, size, className: thumbClassName })
|
||||
)}
|
||||
style={{ left: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
disabled={isDisabled}
|
||||
required={isRequired}
|
||||
ref={inputRef}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{showValue && valuePosition === "right" && (
|
||||
<div className="ml-2 text-xs text-bunker-300">{currentValue}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Slider.displayName = "Slider";
|
2
frontend/src/components/v2/Slider/index.tsx
Normal file
2
frontend/src/components/v2/Slider/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { SliderProps } from "./Slider";
|
||||
export { Slider } from "./Slider";
|
@@ -24,10 +24,12 @@ export * from "./Modal";
|
||||
export * from "./NoticeBanner";
|
||||
export * from "./PageHeader";
|
||||
export * from "./Pagination";
|
||||
export * from "./PasswordGenerator";
|
||||
export * from "./Popoverv2";
|
||||
export * from "./SecretInput";
|
||||
export * from "./Select";
|
||||
export * from "./Skeleton";
|
||||
export * from "./Slider";
|
||||
export * from "./Spinner";
|
||||
export * from "./Stepper";
|
||||
export * from "./Switch";
|
||||
|
@@ -1,5 +1,16 @@
|
||||
import { EventType, UserAgentType } from "./enums";
|
||||
|
||||
export const secretEvents: EventType[] = [
|
||||
EventType.GET_SECRETS,
|
||||
EventType.GET_SECRET,
|
||||
EventType.DELETE_SECRETS,
|
||||
EventType.CREATE_SECRETS,
|
||||
EventType.UPDATE_SECRETS,
|
||||
EventType.CREATE_SECRET,
|
||||
EventType.UPDATE_SECRET,
|
||||
EventType.DELETE_SECRET
|
||||
];
|
||||
|
||||
export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.GET_SECRETS]: "List secrets",
|
||||
[EventType.GET_SECRET]: "Read secret",
|
||||
|
@@ -12,8 +12,8 @@ export enum UserAgentType {
|
||||
CLI = "cli",
|
||||
K8_OPERATOR = "k8-operator",
|
||||
TERRAFORM = "terraform",
|
||||
NODE_SDK = "node-sdk",
|
||||
PYTHON_SDK = "python-sdk",
|
||||
NODE_SDK = "InfisicalNodeSDK",
|
||||
PYTHON_SDK = "InfisicalPythonSDK",
|
||||
OTHER = "other"
|
||||
}
|
||||
|
||||
|
@@ -9,8 +9,10 @@ export type TGetAuditLogsFilter = {
|
||||
eventMetadata?: Record<string, string>;
|
||||
actorType?: ActorType;
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
actor?: string; // user ID format
|
||||
secretPath?: string;
|
||||
secretKey?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit: number;
|
||||
|
@@ -319,12 +319,13 @@ const fetchAccessibleSecrets = async ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
filterByAction,
|
||||
recursive = false
|
||||
}: TGetAccessibleSecretsDTO) => {
|
||||
const { data } = await apiRequest.get<{ secrets: SecretV3Raw[] }>(
|
||||
"/api/v1/dashboard/accessible-secrets",
|
||||
{
|
||||
params: { projectId, secretPath, environment, filterByAction }
|
||||
params: { projectId, secretPath, environment, filterByAction, recursive }
|
||||
}
|
||||
);
|
||||
|
||||
@@ -399,7 +400,8 @@ export const useGetAccessibleSecrets = ({
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction,
|
||||
options
|
||||
options,
|
||||
recursive = false
|
||||
}: TGetAccessibleSecretsDTO & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
@@ -417,8 +419,10 @@ export const useGetAccessibleSecrets = ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
filterByAction
|
||||
filterByAction,
|
||||
recursive
|
||||
}),
|
||||
queryFn: () => fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction })
|
||||
queryFn: () =>
|
||||
fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive })
|
||||
});
|
||||
};
|
||||
|
@@ -111,6 +111,7 @@ export type TGetAccessibleSecretsDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
recursive?: boolean;
|
||||
filterByAction:
|
||||
| ProjectPermissionSecretActions.DescribeSecret
|
||||
| ProjectPermissionSecretActions.ReadValue;
|
||||
|
@@ -121,6 +121,7 @@ export type TGetProjectSecretsKey = {
|
||||
includeImports?: boolean;
|
||||
viewSecretValue?: boolean;
|
||||
expandSecretReferences?: boolean;
|
||||
recursive?: boolean;
|
||||
};
|
||||
|
||||
export type TGetProjectSecretsDTO = TGetProjectSecretsKey;
|
||||
|
@@ -18,7 +18,7 @@ export const AuditLogsPage = () => {
|
||||
title="Audit logs"
|
||||
description="Audit logs for security and compliance teams to monitor information access."
|
||||
/>
|
||||
<LogsSection filterClassName="static py-2" showFilters />
|
||||
<LogsSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,32 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Button, Tooltip } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
hoverTooltip?: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
onClear: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LogFilterItem = ({ label, onClear, hoverTooltip, children, className }: Props) => {
|
||||
return (
|
||||
<Tooltip className="relative top-4" content={hoverTooltip} isDisabled={!hoverTooltip}>
|
||||
<div className={twMerge("flex flex-col justify-between", className)}>
|
||||
<div className="flex items-center justify-between pr-1">
|
||||
<p className="text-xs opacity-60">{label}</p>
|
||||
<Button
|
||||
onClick={() => onClear()}
|
||||
variant="link"
|
||||
className="font-normal text-mineshaft-400 transition-all duration-75 hover:text-mineshaft-300"
|
||||
size="xs"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
@@ -1,11 +1,26 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useState } from "react";
|
||||
import { Control, Controller, UseFormReset, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import { faCaretDown, faCheckCircle, faFilterCircleXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
UseFormGetFieldState,
|
||||
UseFormReset,
|
||||
UseFormResetField,
|
||||
UseFormSetValue,
|
||||
UseFormWatch
|
||||
} from "react-hook-form";
|
||||
import {
|
||||
faArrowRight,
|
||||
faCaretDown,
|
||||
faCheckCircle,
|
||||
faFilterCircleXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DatePicker,
|
||||
DropdownMenu,
|
||||
@@ -19,13 +34,17 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
|
||||
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { Actor } from "@app/hooks/api/auditLogs/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import {
|
||||
eventToNameMap,
|
||||
secretEvents,
|
||||
userAgentTTypeoNameMap
|
||||
} from "@app/hooks/api/auditLogs/constants";
|
||||
import { EventType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { UserAgentType } from "@app/hooks/api/auth/types";
|
||||
|
||||
import { AuditLogFilterFormData } from "./types";
|
||||
import { LogFilterItem } from "./LogFilterItem";
|
||||
import { AuditLogFilterFormData, Presets } from "./types";
|
||||
|
||||
const eventTypes = Object.entries(eventToNameMap).map(([value, label]) => ({ label, value }));
|
||||
const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label]) => ({
|
||||
@@ -34,26 +53,70 @@ const userAgentTypes = Object.entries(userAgentTTypeoNameMap).map(([value, label
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
presets?: {
|
||||
actorId?: string;
|
||||
eventType?: EventType[];
|
||||
};
|
||||
className?: string;
|
||||
isOrgAuditLogs?: boolean;
|
||||
setValue: UseFormSetValue<AuditLogFilterFormData>;
|
||||
presets?: Presets;
|
||||
control: Control<AuditLogFilterFormData>;
|
||||
reset: UseFormReset<AuditLogFilterFormData>;
|
||||
resetField: UseFormResetField<AuditLogFilterFormData>;
|
||||
watch: UseFormWatch<AuditLogFilterFormData>;
|
||||
getFieldState: UseFormGetFieldState<AuditLogFilterFormData>;
|
||||
setValue: UseFormSetValue<AuditLogFilterFormData>;
|
||||
};
|
||||
|
||||
const getActiveFilterCount = (
|
||||
getFieldState: UseFormGetFieldState<AuditLogFilterFormData>,
|
||||
watch: UseFormWatch<AuditLogFilterFormData>
|
||||
) => {
|
||||
const fields = [
|
||||
"actor",
|
||||
"project",
|
||||
"eventType",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"environment",
|
||||
"secretPath",
|
||||
"userAgentType",
|
||||
"secretKey"
|
||||
] as Partial<keyof AuditLogFilterFormData>[];
|
||||
|
||||
let filterCount = 0;
|
||||
|
||||
// either start or end date should only be counted as one filter
|
||||
let dateProcessed = false;
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldState = getFieldState(field);
|
||||
|
||||
if (
|
||||
field === "userAgentType" ||
|
||||
field === "environment" ||
|
||||
field === "secretKey" ||
|
||||
field === "secretPath"
|
||||
) {
|
||||
const value = watch(field);
|
||||
|
||||
if (value !== undefined && value !== "") {
|
||||
filterCount += 1;
|
||||
}
|
||||
} else if (fieldState.isDirty && !dateProcessed) {
|
||||
filterCount += 1;
|
||||
|
||||
if (field === "startDate" || field === "endDate") {
|
||||
dateProcessed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return filterCount;
|
||||
};
|
||||
|
||||
export const LogsFilter = ({
|
||||
presets,
|
||||
isOrgAuditLogs,
|
||||
className,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
watch
|
||||
resetField,
|
||||
watch,
|
||||
getFieldState,
|
||||
setValue
|
||||
}: Props) => {
|
||||
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
|
||||
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
|
||||
@@ -63,288 +126,423 @@ export const LogsFilter = ({
|
||||
|
||||
const workspacesInOrg = workspaces.filter((ws) => ws.orgId === currentOrg?.id);
|
||||
|
||||
const { data, isPending } = useGetAuditLogActorFilterOpts(workspaces?.[0]?.id ?? "");
|
||||
|
||||
const renderActorSelectItem = (actor: Actor) => {
|
||||
switch (actor.type) {
|
||||
case ActorType.USER:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.userId}`}
|
||||
key={`user-actor-filter-${actor.metadata.userId}`}
|
||||
>
|
||||
{actor.metadata.email}
|
||||
</SelectItem>
|
||||
);
|
||||
case ActorType.SERVICE:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.serviceId}`}
|
||||
key={`service-actor-filter-${actor.metadata.serviceId}`}
|
||||
>
|
||||
{actor.metadata.name}
|
||||
</SelectItem>
|
||||
);
|
||||
case ActorType.IDENTITY:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.identityId}`}
|
||||
key={`identity-filter-${actor.metadata.identityId}`}
|
||||
>
|
||||
{actor.metadata.name}
|
||||
</SelectItem>
|
||||
);
|
||||
case ActorType.KMIP_CLIENT:
|
||||
return (
|
||||
<SelectItem
|
||||
value={`${actor.type}-${actor.metadata.clientId}`}
|
||||
key={`kmip-client-filter-${actor.metadata.clientId}`}
|
||||
>
|
||||
{actor.metadata.name}
|
||||
</SelectItem>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<SelectItem value="actor-none" key="actor-none">
|
||||
N/A
|
||||
</SelectItem>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
|
||||
const selectedProject = watch("project");
|
||||
|
||||
const showSecretsSection =
|
||||
selectedEventTypes?.some(
|
||||
(eventType) => secretEvents.includes(eventType) && eventType !== EventType.GET_SECRETS
|
||||
) || selectedEventTypes?.length === 0;
|
||||
|
||||
const availableEnvironments = useMemo(() => {
|
||||
if (!selectedProject) return [];
|
||||
|
||||
return workspacesInOrg.find((ws) => ws.id === selectedProject.id)?.environments ?? [];
|
||||
}, [selectedProject, workspacesInOrg]);
|
||||
|
||||
const activeFilterCount = getActiveFilterCount(getFieldState, watch);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"sticky top-20 z-10 flex flex-wrap items-center justify-between bg-bunker-800",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Project"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-64"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
isClearable
|
||||
onChange={(e) => {
|
||||
if (e === null) {
|
||||
setValue("secretPath", "");
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
placeholder="Select a project..."
|
||||
options={workspacesInOrg.map(({ name, id, type }) => ({ name, id, type }))}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{selectedProject?.type === ProjectType.SecretManager && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormControl label="Secret path" className="w-40">
|
||||
<Input {...field} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="eventType"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Events">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{selectedEventTypes?.length === 1
|
||||
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
|
||||
?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
? "All events"
|
||||
: `${selectedEventTypes?.length} events selected`}
|
||||
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[100] max-h-80 overflow-hidden">
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{eventTypes && eventTypes.length > 0 ? (
|
||||
eventTypes.map((eventType) => {
|
||||
const isSelected = selectedEventTypes?.includes(
|
||||
eventType.value as EventType
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => eventTypes.length > 1 && event.preventDefault()}
|
||||
onClick={() => {
|
||||
if (selectedEventTypes?.includes(eventType.value as EventType)) {
|
||||
field.onChange(
|
||||
selectedEventTypes?.filter((e: string) => e !== eventType.value)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...(selectedEventTypes || []), eventType.value]);
|
||||
}
|
||||
}}
|
||||
key={`event-type-${eventType.value}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{eventType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline_bg" colorSchema="primary">
|
||||
<FontAwesomeIcon icon={faFilterCircleXmark} className="mr-3 px-[0.1rem]" />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge className="ml-2 px-1.5 py-0.5" variant="primary">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPending && data && data.length > 0 && !presets?.actorId && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="actor"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Actor"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-40"
|
||||
>
|
||||
<Select
|
||||
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
|
||||
>
|
||||
{data.map((actor) => renderActorSelectItem(actor))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="userAgentType"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Source"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-40"
|
||||
>
|
||||
<Select
|
||||
value={value === undefined ? "all" : value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else onChange(e);
|
||||
}}
|
||||
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
|
||||
>
|
||||
<SelectItem value="all" key="all">
|
||||
All sources
|
||||
</SelectItem>
|
||||
{userAgentTypes.map(({ label, value: userAgent }) => (
|
||||
<SelectItem value={userAgent} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl label="Start date" errorText={error?.message} isError={Boolean(error)}>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isStartDatePickerOpen,
|
||||
onOpenChange: setIsStartDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="endDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl label="End date" errorText={error?.message} isError={Boolean(error)}>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isEndDatePickerOpen,
|
||||
onOpenChange: setIsEndDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
isLoading={false}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
className="mt-[0.45rem]"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faFilterCircleXmark} />}
|
||||
onClick={() =>
|
||||
reset({
|
||||
eventType: presets?.eventType || [],
|
||||
actor: presets?.actorId,
|
||||
userAgentType: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
project: null
|
||||
})
|
||||
}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="mt-4 py-4">
|
||||
<div className="flex min-w-64 flex-col font-inter">
|
||||
<div className="mb-3 flex items-center border-b border-b-mineshaft-500 px-3 pb-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Filters</span>
|
||||
<Badge className="px-1.5 py-0.5" variant="primary">
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
reset({
|
||||
eventType: presets?.eventType || [],
|
||||
actor: presets?.actorId,
|
||||
userAgentType: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
project: null,
|
||||
secretPath: undefined,
|
||||
secretKey: undefined
|
||||
});
|
||||
}}
|
||||
variant="link"
|
||||
className="text-mineshaft-400"
|
||||
size="xs"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<LogFilterItem
|
||||
label="Events"
|
||||
onClear={() => {
|
||||
resetField("eventType");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="eventType"
|
||||
render={({ field }) => (
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="thin-scrollbar inline-flex w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-mineshaft-500 bg-mineshaft-700 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{selectedEventTypes?.length === 1
|
||||
? eventTypes.find(
|
||||
(eventType) => eventType.value === selectedEventTypes[0]
|
||||
)?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
? "All events"
|
||||
: `${selectedEventTypes?.length} events selected`}
|
||||
<FontAwesomeIcon icon={faCaretDown} className="ml-2 text-xs" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80 overflow-hidden"
|
||||
>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{eventTypes && eventTypes.length > 0 ? (
|
||||
eventTypes.map((eventType) => {
|
||||
const isSelected = selectedEventTypes?.includes(
|
||||
eventType.value as EventType
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) =>
|
||||
eventTypes.length > 1 && event.preventDefault()
|
||||
}
|
||||
onClick={() => {
|
||||
if (
|
||||
selectedEventTypes?.includes(eventType.value as EventType)
|
||||
) {
|
||||
field.onChange(
|
||||
selectedEventTypes?.filter(
|
||||
(e: string) => e !== eventType.value
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([
|
||||
...(selectedEventTypes || []),
|
||||
eventType.value
|
||||
]);
|
||||
}
|
||||
}}
|
||||
key={`event-type-${eventType.value}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{eventType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
<LogFilterItem
|
||||
label="Source"
|
||||
onClear={() => {
|
||||
resetField("userAgentType");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="userAgentType"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<Select
|
||||
value={value === undefined ? "all" : value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else setValue("userAgentType", e as UserAgentType, { shouldDirty: true });
|
||||
}}
|
||||
className={twMerge("w-full border border-mineshaft-500 bg-mineshaft-700")}
|
||||
>
|
||||
<SelectItem value="all" key="all">
|
||||
All sources
|
||||
</SelectItem>
|
||||
{userAgentTypes.map(({ label, value: userAgent }) => (
|
||||
<SelectItem value={userAgent} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
|
||||
<LogFilterItem
|
||||
label="Date"
|
||||
onClear={() => {
|
||||
resetField("startDate");
|
||||
resetField("endDate");
|
||||
}}
|
||||
>
|
||||
<div className="flex h-10 w-full items-center justify-between gap-2">
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="relative top-2"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isStartDatePickerOpen,
|
||||
onOpenChange: setIsStartDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center -space-x-3">
|
||||
<div className="h-[2px] w-[20px] rounded-full bg-mineshaft-500" />
|
||||
<FontAwesomeIcon icon={faArrowRight} className="text-mineshaft-500" />
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="endDate"
|
||||
control={control}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||
return (
|
||||
<FormControl
|
||||
className="relative top-2"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<DatePicker
|
||||
value={field.value || undefined}
|
||||
onChange={onChange}
|
||||
dateFormat="P"
|
||||
popUpProps={{
|
||||
open: isEndDatePickerOpen,
|
||||
onOpenChange: setIsEndDatePickerOpen
|
||||
}}
|
||||
popUpContentProps={{}}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</LogFilterItem>
|
||||
<AnimatePresence initial={false}>
|
||||
{showSecretsSection && (
|
||||
<motion.div
|
||||
className="mt-2 overflow-hidden"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="mb-3 mt-2">
|
||||
<p className="text-xs opacity-60">Secrets</p>
|
||||
<div className="h-[1px] w-full rounded-full bg-mineshaft-500" />
|
||||
</div>
|
||||
|
||||
<LogFilterItem
|
||||
label="Project"
|
||||
onClear={() => {
|
||||
resetField("project");
|
||||
resetField("environment");
|
||||
setValue("secretPath", "");
|
||||
setValue("secretKey", "");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
isClearable
|
||||
onChange={(e) => {
|
||||
if (e === null) {
|
||||
setValue("secretPath", "");
|
||||
setValue("secretKey", "");
|
||||
}
|
||||
resetField("environment");
|
||||
onChange(e);
|
||||
}}
|
||||
placeholder="All projects"
|
||||
options={workspacesInOrg.map(({ name, id, type }) => ({
|
||||
name,
|
||||
id,
|
||||
type
|
||||
}))}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
|
||||
<LogFilterItem
|
||||
label="Environment"
|
||||
hoverTooltip={
|
||||
!selectedProject
|
||||
? "Select a project before filtering by environment."
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(!selectedProject && "opacity-50")}
|
||||
onClear={() => {
|
||||
resetField("environment");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-full"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
key={value?.name || "filter-environment"}
|
||||
isClearable
|
||||
isDisabled={!selectedProject}
|
||||
onChange={(e) => onChange(e)}
|
||||
placeholder="All environments"
|
||||
options={availableEnvironments.map(({ name, slug }) => ({
|
||||
name,
|
||||
slug
|
||||
}))}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
<LogFilterItem
|
||||
label="Secret Path"
|
||||
hoverTooltip={
|
||||
!selectedProject
|
||||
? "Select a project before filtering by secret path."
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(!selectedProject && "opacity-50")}
|
||||
onClear={() => {
|
||||
setValue("secretPath", "");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormControl
|
||||
tooltipText="Filter audit logs related to events that occurred on a specific secret path."
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
placeholder="Enter secret path"
|
||||
className="disabled:cursor-not-allowed"
|
||||
isDisabled={!selectedProject}
|
||||
{...field}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
|
||||
<LogFilterItem
|
||||
hoverTooltip={
|
||||
!selectedProject
|
||||
? "Select a project before filtering by secret key."
|
||||
: undefined
|
||||
}
|
||||
className={twMerge(!selectedProject && "opacity-50")}
|
||||
label="Secret Key"
|
||||
onClear={() => {
|
||||
setValue("secretKey", "");
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretKey"
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<FormControl
|
||||
tooltipText="Filter audit logs related to a specific secret."
|
||||
className="w-full"
|
||||
>
|
||||
<Input
|
||||
isDisabled={!selectedProject}
|
||||
{...field}
|
||||
placeholder="Enter secret key"
|
||||
className="disabled:cursor-not-allowed"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setValue("secretKey", e.target.value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</LogFilterItem>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
@@ -6,46 +6,40 @@ import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { LogsFilter } from "./LogsFilter";
|
||||
import { LogsTable } from "./LogsTable";
|
||||
import { AuditLogFilterFormData, auditLogFilterFormSchema } from "./types";
|
||||
import { AuditLogFilterFormData, auditLogFilterFormSchema, Presets } from "./types";
|
||||
|
||||
type Props = {
|
||||
presets?: {
|
||||
actorId?: string;
|
||||
eventType?: EventType[];
|
||||
actorType?: ActorType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
eventMetadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
showFilters?: boolean;
|
||||
filterClassName?: string;
|
||||
presets?: Presets;
|
||||
refetchInterval?: number;
|
||||
showFilters?: boolean;
|
||||
};
|
||||
|
||||
export const LogsSection = withPermission(
|
||||
({ presets, filterClassName, refetchInterval, showFilters }: Props) => {
|
||||
({ presets, refetchInterval, showFilters = true }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
|
||||
|
||||
const { control, reset, watch, setValue } = useForm<AuditLogFilterFormData>({
|
||||
resolver: zodResolver(auditLogFilterFormSchema),
|
||||
defaultValues: {
|
||||
project: null,
|
||||
actor: presets?.actorId,
|
||||
eventType: presets?.eventType || [],
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)), // day before today
|
||||
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today
|
||||
}
|
||||
});
|
||||
const { control, reset, watch, getFieldState, resetField, setValue } =
|
||||
useForm<AuditLogFilterFormData>({
|
||||
resolver: zodResolver(auditLogFilterFormSchema),
|
||||
defaultValues: {
|
||||
project: null,
|
||||
environment: undefined,
|
||||
secretKey: "",
|
||||
secretPath: "",
|
||||
actor: presets?.actorId,
|
||||
eventType: presets?.eventType || [],
|
||||
userAgentType: undefined,
|
||||
startDate: presets?.startDate ?? new Date(new Date().setDate(new Date().getDate() - 1)),
|
||||
endDate: presets?.endDate ?? new Date(new Date(Date.now()).setHours(23, 59, 59, 999))
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription && !subscription.auditLogs) {
|
||||
@@ -57,30 +51,37 @@ export const LogsSection = withPermission(
|
||||
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
|
||||
const actor = watch("actor");
|
||||
const projectId = watch("project")?.id;
|
||||
const environment = watch("environment")?.slug;
|
||||
const secretPath = watch("secretPath");
|
||||
const secretKey = watch("secretKey");
|
||||
|
||||
const startDate = watch("startDate");
|
||||
const endDate = watch("endDate");
|
||||
|
||||
const [debouncedSecretPath] = useDebounce<string>(secretPath!, 500);
|
||||
const [debouncedSecretKey] = useDebounce<string>(secretKey!, 500);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showFilters && (
|
||||
<LogsFilter
|
||||
isOrgAuditLogs
|
||||
className={filterClassName}
|
||||
presets={presets}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
reset={reset}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full justify-end">
|
||||
{showFilters && (
|
||||
<LogsFilter
|
||||
presets={presets}
|
||||
control={control}
|
||||
watch={watch}
|
||||
reset={reset}
|
||||
resetField={resetField}
|
||||
getFieldState={getFieldState}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LogsTable
|
||||
refetchInterval={refetchInterval}
|
||||
filter={{
|
||||
secretPath: debouncedSecretPath || undefined,
|
||||
secretKey: debouncedSecretKey || undefined,
|
||||
eventMetadata: presets?.eventMetadata,
|
||||
projectId,
|
||||
actorType: presets?.actorType,
|
||||
@@ -89,6 +90,7 @@ export const LogsSection = withPermission(
|
||||
userAgentType,
|
||||
startDate,
|
||||
endDate,
|
||||
environment,
|
||||
actor
|
||||
}}
|
||||
/>
|
||||
|
@@ -1,10 +1,12 @@
|
||||
import { Fragment } from "react";
|
||||
import { faFile, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@@ -52,7 +54,9 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => {
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-24" />
|
||||
<Th className="w-24">
|
||||
<Spinner size="xs" className={twMerge(isPending ? "opacity-100" : "opacity-0")} />
|
||||
</Th>
|
||||
<Th className="w-64">
|
||||
Timestamp
|
||||
<Tooltip
|
||||
@@ -94,7 +98,7 @@ export const LogsTable = ({ filter, refetchInterval }: Props) => {
|
||||
<Button
|
||||
className="mb-20 mt-4 px-4 py-3 text-sm"
|
||||
isFullWidth
|
||||
variant="star"
|
||||
variant="outline_bg"
|
||||
isLoading={isFetchingNextPage}
|
||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
export const auditLogFilterFormSchema = z
|
||||
@@ -10,10 +10,12 @@ export const auditLogFilterFormSchema = z
|
||||
.object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) })
|
||||
.optional()
|
||||
.nullable(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }).optional().nullable(),
|
||||
eventType: z.nativeEnum(EventType).array(),
|
||||
actor: z.string().optional(),
|
||||
userAgentType: z.nativeEnum(UserAgentType),
|
||||
secretPath: z.string().optional(),
|
||||
secretKey: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
page: z.coerce.number().optional(),
|
||||
@@ -39,3 +41,12 @@ export type SetValueType = (
|
||||
shouldDirty?: boolean;
|
||||
}
|
||||
) => void;
|
||||
|
||||
export type Presets = {
|
||||
actorId?: string;
|
||||
eventType?: EventType[];
|
||||
actorType?: ActorType;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
eventMetadata?: Record<string, string>;
|
||||
};
|
||||
|
@@ -1,8 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
@@ -14,7 +9,6 @@ type Props = {
|
||||
|
||||
export const UserAuditLogsSection = withPermission(
|
||||
({ orgMembership }: Props) => {
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
@@ -23,25 +17,8 @@ export const UserAuditLogsSection = withPermission(
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<p className="text-lg font-semibold text-gray-200">Audit Logs</p>
|
||||
|
||||
<Tooltip content="Show audit log filters">
|
||||
<IconButton
|
||||
colorSchema="primary"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>Filter</p>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</div>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<LogsSection
|
||||
showFilters={showFilter}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
presets={{
|
||||
actorId: orgMembership.user.id
|
||||
}}
|
||||
|
@@ -35,7 +35,6 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
||||
eventType: INTEGRATION_EVENTS
|
||||
}}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
@@ -7,7 +7,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
PasswordGenerator
|
||||
} from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
@@ -226,10 +232,13 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<PasswordGenerator onUsePassword={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -17,10 +17,12 @@ import {
|
||||
faKey,
|
||||
faLock,
|
||||
faMinusSquare,
|
||||
faPaste,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import FileSaver from "file-saver";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -53,8 +55,19 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
||||
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretBatch,
|
||||
useDeleteSecretBatch,
|
||||
useMoveSecrets,
|
||||
useUpdateSecretBatch
|
||||
} from "@app/hooks/api";
|
||||
import {
|
||||
dashboardKeys,
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
||||
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
||||
|
||||
@@ -65,11 +78,19 @@ import {
|
||||
useSelectedSecrets
|
||||
} from "../../SecretMainPage.store";
|
||||
import { Filter, RowType } from "../../SecretMainPage.types";
|
||||
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
|
||||
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[]; secretPath?: string }>;
|
||||
type TParsedFolderEnv = Record<
|
||||
string,
|
||||
Record<string, { value: string; comments: string[]; secretPath?: string }>
|
||||
>;
|
||||
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
|
||||
|
||||
type Props = {
|
||||
// switch the secrets type as it gets decrypted after api call
|
||||
environment: string;
|
||||
@@ -114,7 +135,9 @@ export const ActionBar = ({
|
||||
"bulkDeleteSecrets",
|
||||
"moveSecrets",
|
||||
"misc",
|
||||
"upgradePlan"
|
||||
"upgradePlan",
|
||||
"replicateFolder",
|
||||
"confirmUpload"
|
||||
] as const);
|
||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||
const { subscription } = useSubscription();
|
||||
@@ -122,6 +145,13 @@ export const ActionBar = ({
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
const { mutateAsync: moveSecrets } = useMoveSecrets();
|
||||
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||
@@ -293,6 +323,285 @@ export const ActionBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Replicate Folder Logic
|
||||
const createSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
|
||||
).length;
|
||||
|
||||
const updateSecretCount = Object.keys(
|
||||
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
|
||||
).length;
|
||||
|
||||
const isNonConflictingUpload = !updateSecretCount;
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleParsedEnvMultiFolder = async (envByPath: TParsedFolderEnv) => {
|
||||
if (Object.keys(envByPath).length === 0) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to find secrets"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allUpdateSecrets: TParsedEnv = {};
|
||||
const allCreateSecrets: TParsedEnv = {};
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(envByPath).map(async ([folderPath, secrets]) => {
|
||||
// Normalize the path
|
||||
let normalizedPath = folderPath;
|
||||
|
||||
// If the path is "/", use the current secretPath
|
||||
if (normalizedPath === "/") {
|
||||
normalizedPath = secretPath;
|
||||
} else {
|
||||
// Otherwise, concatenate with the current secretPath, avoiding double slashes
|
||||
const baseSecretPath = secretPath.endsWith("/") ? secretPath.slice(0, -1) : secretPath;
|
||||
// Remove leading slash from folder path if present to avoid double slashes
|
||||
const cleanFolderPath = folderPath.startsWith("/")
|
||||
? folderPath.substring(1)
|
||||
: folderPath;
|
||||
normalizedPath = `${baseSecretPath}/${cleanFolderPath}`;
|
||||
}
|
||||
|
||||
const secretFolderKeys = Object.keys(secrets);
|
||||
|
||||
if (secretFolderKeys.length === 0) return;
|
||||
|
||||
// Check which secrets already exist in this path
|
||||
const batchSize = 50;
|
||||
const secretBatches = Array.from(
|
||||
{ length: Math.ceil(secretFolderKeys.length / batchSize) },
|
||||
(_, i) => secretFolderKeys.slice(i * batchSize, (i + 1) * batchSize)
|
||||
);
|
||||
|
||||
const existingSecretLookup: Record<string, boolean> = {};
|
||||
|
||||
const processBatches = async () => {
|
||||
await secretBatches.reduce(async (previous, batch) => {
|
||||
await previous;
|
||||
|
||||
const { secrets: batchSecrets } = await fetchDashboardProjectSecretsByKeys({
|
||||
secretPath: normalizedPath,
|
||||
environment,
|
||||
projectId: workspaceId,
|
||||
keys: batch
|
||||
});
|
||||
|
||||
batchSecrets.forEach((secret) => {
|
||||
existingSecretLookup[secret.secretKey] = true;
|
||||
});
|
||||
}, Promise.resolve());
|
||||
};
|
||||
|
||||
await processBatches();
|
||||
|
||||
// Categorize each secret as update or create
|
||||
secretFolderKeys.forEach((secretKey) => {
|
||||
const secretData = secrets[secretKey];
|
||||
|
||||
// Store the path with the secret for later batch processing
|
||||
const secretWithPath = {
|
||||
...secretData,
|
||||
secretPath: normalizedPath
|
||||
};
|
||||
|
||||
if (existingSecretLookup[secretKey]) {
|
||||
allUpdateSecrets[secretKey] = secretWithPath;
|
||||
} else {
|
||||
allCreateSecrets[secretKey] = secretWithPath;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
handlePopUpOpen("confirmUpload", {
|
||||
update: allUpdateSecrets,
|
||||
create: allCreateSecrets
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
createNotification({
|
||||
text: "Failed to check for secret conflicts",
|
||||
type: "error"
|
||||
});
|
||||
handlePopUpClose("confirmUpload");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFolderImport = async () => {
|
||||
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
|
||||
try {
|
||||
// Group secrets by their path for batch operations
|
||||
const groupedCreateSecrets: Record<
|
||||
string,
|
||||
Array<{
|
||||
type: SecretType;
|
||||
secretComment: string;
|
||||
secretValue: string;
|
||||
secretKey: string;
|
||||
}>
|
||||
> = {};
|
||||
|
||||
const groupedUpdateSecrets: Record<
|
||||
string,
|
||||
Array<{
|
||||
type: SecretType;
|
||||
secretComment: string;
|
||||
secretValue: string;
|
||||
secretKey: string;
|
||||
}>
|
||||
> = {};
|
||||
|
||||
// Collect all unique paths that need folders to be created
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
// Add paths from create secrets
|
||||
Object.values(create || {}).forEach((secData) => {
|
||||
if (secData.secretPath && secData.secretPath !== secretPath) {
|
||||
allPaths.add(secData.secretPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a map of folder paths to their folder name (last segment)
|
||||
const folderPaths = Array.from(allPaths).map((path) => {
|
||||
// Remove trailing slash if it exists
|
||||
const normalizedPath = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
// Split by '/' to get path segments
|
||||
const segments = normalizedPath.split("/");
|
||||
// Get the last segment as the folder name
|
||||
const folderName = segments[segments.length - 1];
|
||||
// Get the parent path (everything except the last segment)
|
||||
const parentPath = segments.slice(0, -1).join("/");
|
||||
|
||||
return {
|
||||
folderName,
|
||||
fullPath: normalizedPath,
|
||||
parentPath: parentPath || "/"
|
||||
};
|
||||
});
|
||||
|
||||
// Sort paths by depth (shortest first) to ensure parent folders are created before children
|
||||
folderPaths.sort(
|
||||
(a, b) => (a.fullPath.match(/\//g) || []).length - (b.fullPath.match(/\//g) || []).length
|
||||
);
|
||||
|
||||
// Track created folders to avoid duplicates
|
||||
const createdFolders = new Set<string>();
|
||||
|
||||
// Create all necessary folders in order using Promise.all and reduce
|
||||
await folderPaths.reduce(async (previousPromise, { folderName, fullPath, parentPath }) => {
|
||||
// Wait for the previous promise to complete
|
||||
await previousPromise;
|
||||
|
||||
// Skip if we've already created this folder
|
||||
if (createdFolders.has(fullPath)) return Promise.resolve();
|
||||
|
||||
try {
|
||||
await createFolder({
|
||||
name: folderName,
|
||||
path: parentPath,
|
||||
environment,
|
||||
projectId: workspaceId
|
||||
});
|
||||
|
||||
createdFolders.add(fullPath);
|
||||
} catch (err) {
|
||||
console.log(`Folder ${folderName} may already exist:`, err);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}, Promise.resolve());
|
||||
|
||||
if (Object.keys(create || {}).length > 0) {
|
||||
Object.entries(create).forEach(([secretKey, secData]) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
if (!groupedCreateSecrets[path]) {
|
||||
groupedCreateSecrets[path] = [];
|
||||
}
|
||||
|
||||
groupedCreateSecrets[path].push({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(groupedCreateSecrets).map(([path, secrets]) =>
|
||||
createSecretBatch({
|
||||
secretPath: path,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(update || {}).length > 0) {
|
||||
Object.entries(update).forEach(([secretKey, secData]) => {
|
||||
// Use the stored secretPath or fall back to the current secretPath
|
||||
const path = secData.secretPath || secretPath;
|
||||
|
||||
if (!groupedUpdateSecrets[path]) {
|
||||
groupedUpdateSecrets[path] = [];
|
||||
}
|
||||
|
||||
groupedUpdateSecrets[path].push({
|
||||
type: SecretType.Shared,
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretKey
|
||||
});
|
||||
});
|
||||
|
||||
// Update secrets for each path in parallel
|
||||
await Promise.all(
|
||||
Object.entries(groupedUpdateSecrets).map(([path, secrets]) =>
|
||||
updateSecretBatch({
|
||||
secretPath: path,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate appropriate queries to refresh UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretApprovalRequestKeys.count({ workspaceId })
|
||||
});
|
||||
|
||||
// Close the modal and show notification
|
||||
handlePopUpClose("confirmUpload");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: isProtectedBranch
|
||||
? "Uploaded changes have been sent for review"
|
||||
: "Successfully uploaded secrets"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to upload secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
@@ -570,6 +879,29 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, {
|
||||
environment,
|
||||
secretPath
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPaste} className="pr-2" />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("replicateFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Replicate Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -679,6 +1011,15 @@ export const ActionBar = ({
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
onMoveApproved={handleSecretsMove}
|
||||
/>
|
||||
<ReplicateFolderFromBoard
|
||||
isOpen={popUp.replicateFolder.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("replicateFolder", isOpen)}
|
||||
onParsedEnv={handleParsedEnvMultiFolder}
|
||||
environment={environment}
|
||||
environments={currentWorkspace.environments}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
@@ -690,6 +1031,58 @@ export const ActionBar = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={popUp?.confirmUpload?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Confirm Secret Upload"
|
||||
footerContent={[
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
|
||||
key="overwrite-btn"
|
||||
onClick={handleSaveFolderImport}
|
||||
>
|
||||
{isNonConflictingUpload ? "Upload" : "Overwrite"}
|
||||
</Button>,
|
||||
<Button
|
||||
key="keep-old-btn"
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpClose("confirmUpload")}
|
||||
variant="outline_bg"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{isNonConflictingUpload ? (
|
||||
<div>
|
||||
Are you sure you want to import {createSecretCount} secret
|
||||
{createSecretCount > 1 ? "s" : ""} to this environment?
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col text-gray-300">
|
||||
<div>Your project already contains the following {updateSecretCount} secrets:</div>
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
Are you sure you want to overwrite these secrets
|
||||
{createSecretCount > 0
|
||||
? ` and import ${createSecretCount} new
|
||||
one${createSecretCount > 1 ? "s" : ""}`
|
||||
: ""}
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faClone } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Switch
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetAccessibleSecrets } from "@app/hooks/api/dashboard";
|
||||
import { SecretV3Raw } from "@app/hooks/api/types";
|
||||
|
||||
import { SecretTreeView } from "./SecretTreeView";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z
|
||||
.object({
|
||||
secretKey: z.string(),
|
||||
secretValue: z.string().optional(),
|
||||
secretPath: z.string()
|
||||
})
|
||||
.array()
|
||||
.min(1, "Select one or more secrets to copy")
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
onParsedEnv: (
|
||||
env: Record<string, Record<string, { value: string; comments: string[]; secretPath?: string }>>
|
||||
) => void;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
type SecretFolder = {
|
||||
items: Partial<SecretV3Raw>[];
|
||||
subFolders: Record<string, SecretFolder>;
|
||||
};
|
||||
|
||||
type SecretStructure = {
|
||||
[rootPath: string]: SecretFolder;
|
||||
};
|
||||
|
||||
export const ReplicateFolderFromBoard = ({
|
||||
environments = [],
|
||||
workspaceId,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onParsedEnv
|
||||
}: Props) => {
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const { handleSubmit, control, watch, reset, setValue } = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0], secrets: [] }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const selectedSecrets = watch("secrets");
|
||||
const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: accessibleSecrets } = useGetAccessibleSecrets({
|
||||
projectId: workspaceId,
|
||||
secretPath: "/",
|
||||
environment: selectedEnvSlug.slug,
|
||||
recursive: true,
|
||||
filterByAction: shouldIncludeValues
|
||||
? ProjectPermissionSecretActions.ReadValue
|
||||
: ProjectPermissionSecretActions.DescribeSecret,
|
||||
options: { enabled: Boolean(workspaceId) && Boolean(selectedEnvSlug) && isOpen }
|
||||
});
|
||||
|
||||
const restructureSecrets = useMemo(() => {
|
||||
if (!accessibleSecrets) return {};
|
||||
|
||||
const result: SecretStructure = {};
|
||||
result["/"] = {
|
||||
items: [],
|
||||
subFolders: {}
|
||||
};
|
||||
|
||||
accessibleSecrets.forEach((secret) => {
|
||||
const path = secret.secretPath || "/";
|
||||
|
||||
if (path === "/") {
|
||||
result["/"]?.items.push(secret);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith("/") ? path.substring(1) : path;
|
||||
const pathParts = normalizedPath.split("/");
|
||||
|
||||
let currentFolder = result["/"];
|
||||
|
||||
for (let i = 0; i < pathParts.length; i += 1) {
|
||||
const part = pathParts[i];
|
||||
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!part) continue;
|
||||
|
||||
if (i === pathParts.length - 1) {
|
||||
if (!currentFolder.subFolders[part]) {
|
||||
currentFolder.subFolders[part] = {
|
||||
items: [],
|
||||
subFolders: {}
|
||||
};
|
||||
}
|
||||
currentFolder.subFolders[part].items.push(secret);
|
||||
} else {
|
||||
if (!currentFolder.subFolders[part]) {
|
||||
currentFolder.subFolders[part] = {
|
||||
items: [],
|
||||
subFolders: {}
|
||||
};
|
||||
}
|
||||
currentFolder = currentFolder.subFolders[part];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [accessibleSecrets, selectedEnvSlug]);
|
||||
|
||||
const secretsFilteredByPath = useMemo(() => {
|
||||
let normalizedPath = debouncedEnvCopySecretPath;
|
||||
normalizedPath = debouncedEnvCopySecretPath.startsWith("/")
|
||||
? debouncedEnvCopySecretPath
|
||||
: `/${debouncedEnvCopySecretPath}`;
|
||||
if (normalizedPath.length > 1 && normalizedPath.endsWith("/")) {
|
||||
normalizedPath = debouncedEnvCopySecretPath.slice(0, -1);
|
||||
}
|
||||
|
||||
if (normalizedPath === "/") {
|
||||
return restructureSecrets["/"];
|
||||
}
|
||||
|
||||
const segments = normalizedPath.split("/").filter((segment) => segment !== "");
|
||||
|
||||
let currentLevel = restructureSecrets["/"];
|
||||
let result = null;
|
||||
let currentPath = "";
|
||||
|
||||
if (!currentLevel) {
|
||||
setValue("secretPath", "/");
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const segment = segments[i];
|
||||
currentPath += `/${segment}`;
|
||||
|
||||
if (currentLevel?.subFolders?.[segment]) {
|
||||
currentLevel = currentLevel.subFolders[segment];
|
||||
|
||||
if (currentPath === normalizedPath) {
|
||||
result = currentLevel;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [restructureSecrets, debouncedEnvCopySecretPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", []);
|
||||
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<
|
||||
string,
|
||||
Record<string, { value: string; comments: string[]; secretPath: string }>
|
||||
> = {};
|
||||
data.secrets.forEach(({ secretKey, secretValue, secretPath: secretPathToRecreate }) => {
|
||||
const normalizedPath = secretPathToRecreate.startsWith(envCopySecPath)
|
||||
? secretPathToRecreate.slice(envCopySecPath.length)
|
||||
: secretPathToRecreate;
|
||||
|
||||
if (!secretsToBePulled[normalizedPath]) {
|
||||
secretsToBePulled[normalizedPath] = {};
|
||||
}
|
||||
|
||||
secretsToBePulled[normalizedPath][secretKey] = {
|
||||
value: (shouldIncludeValues && secretValue) || "",
|
||||
comments: [""],
|
||||
secretPath: normalizedPath
|
||||
};
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
onToggle(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(state) => {
|
||||
onToggle(state);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
className="max-w-2xl"
|
||||
title="Replicate Folder Content From An Environment"
|
||||
subTitle="Replicate folder content from other environments into this context"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={environments}
|
||||
placeholder="Select environment..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Secret Path" className="flex-grow" isRequired>
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
placeholder="Provide a path, default is /"
|
||||
environment={selectedEnvSlug?.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="secrets"
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormControl className="flex-grow" isRequired>
|
||||
<SecretTreeView
|
||||
data={secretsFilteredByPath}
|
||||
basePath={debouncedEnvCopySecretPath}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="my-6 ml-2">
|
||||
<Switch
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setValue("secrets", []);
|
||||
setShouldIncludeValues(isChecked as boolean);
|
||||
}}
|
||||
>
|
||||
Include secret values
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faClone} />}
|
||||
type="submit"
|
||||
isDisabled={!selectedSecrets || selectedSecrets.length === 0}
|
||||
>
|
||||
Replicate Folder
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@@ -0,0 +1,321 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faFolder,
|
||||
faFolderOpen,
|
||||
faFolderTree,
|
||||
faKey
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { Checkbox } from "@app/components/v2";
|
||||
|
||||
interface SecretItem {
|
||||
id?: string;
|
||||
secretKey?: string;
|
||||
secretValue?: string;
|
||||
secretPath?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface FolderStructure {
|
||||
items: SecretItem[];
|
||||
subFolders: {
|
||||
[key: string]: FolderStructure;
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeData {
|
||||
[key: string]: FolderStructure | null;
|
||||
}
|
||||
|
||||
interface FolderProps {
|
||||
name: string;
|
||||
structure: FolderStructure;
|
||||
path: string;
|
||||
selectedItems: SecretItem[];
|
||||
onItemSelect: (item: SecretItem, isChecked: boolean) => void;
|
||||
onFolderSelect: (folderPath: string, isChecked: boolean) => void;
|
||||
isExpanded?: boolean;
|
||||
level: number;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
interface TreeViewProps {
|
||||
data: FolderStructure | null;
|
||||
basePath?: string;
|
||||
className?: string;
|
||||
onChange: (items: SecretItem[]) => void;
|
||||
}
|
||||
|
||||
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
|
||||
let items: SecretItem[] = [];
|
||||
|
||||
items = items.concat(folder.items);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Object.entries(folder.subFolders).forEach(([_, subFolder]) => {
|
||||
items = items.concat(getAllItemsInFolder(subFolder));
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getDisplayName = (name: string): string => {
|
||||
const parts = name.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.Trigger;
|
||||
const CollapsibleContent = CollapsiblePrimitive.Content;
|
||||
|
||||
const Folder: React.FC<FolderProps> = ({
|
||||
name,
|
||||
structure,
|
||||
path,
|
||||
selectedItems,
|
||||
onItemSelect,
|
||||
onFolderSelect,
|
||||
isExpanded = false,
|
||||
level,
|
||||
basePath
|
||||
}) => {
|
||||
const [open, setOpen] = useState(isExpanded);
|
||||
const displayName = useMemo(() => getDisplayName(name), [name]);
|
||||
|
||||
const allItems = useMemo(() => getAllItemsInFolder(structure), [structure]);
|
||||
const allItemIds = useMemo(() => allItems.map((item) => item.id), [allItems]);
|
||||
const selectedItemIds = useMemo(() => selectedItems.map((item) => item.id), [selectedItems]);
|
||||
const allSelected = useMemo(
|
||||
() => allItemIds.length > 0 && allItemIds.every((id) => selectedItemIds.includes(id)),
|
||||
[allItemIds, selectedItemIds]
|
||||
);
|
||||
const someSelected = useMemo(
|
||||
() => allItemIds.some((id) => selectedItemIds.includes(id)) && !allSelected,
|
||||
[allItemIds, selectedItemIds, allSelected]
|
||||
);
|
||||
const hasContents = structure.items.length > 0 || Object.keys(structure.subFolders).length > 0;
|
||||
|
||||
const handleFolderSelect = (checked: boolean) => {
|
||||
onFolderSelect(path, checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`folder-container ml-${level > 0 ? "4" : 0}`}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div className="group flex items-center rounded px-2 py-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 flex h-6 w-6 items-center justify-center rounded focus:outline-none"
|
||||
disabled={!hasContents}
|
||||
aria-label={open ? "Collapse folder" : "Expand folder"}
|
||||
>
|
||||
{hasContents && (
|
||||
<FontAwesomeIcon icon={open ? faChevronDown : faChevronRight} className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<div className="mr-2">
|
||||
<FontAwesomeIcon
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
icon={level > 0 ? (open ? faFolderOpen : faFolder) : faFolderTree}
|
||||
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="folder-root"
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={allSelected || someSelected}
|
||||
onCheckedChange={handleFolderSelect}
|
||||
isIndeterminate={someSelected && !allSelected}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor={`folder-${path}`}
|
||||
className={`ml-2 flex-1 cursor-pointer truncate ${basePath ? "italic text-mineshaft-300" : ""}`}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName || `${basePath}`}
|
||||
</label>
|
||||
|
||||
{allItemIds.length > 0 && (
|
||||
<span className="ml-2 text-xs text-mineshaft-400">
|
||||
{allItemIds.length} {allItemIds.length === 1 ? "item" : "items"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden transition-all duration-300 ease-in-out">
|
||||
<div className="relative mt-1">
|
||||
<div className="absolute bottom-0 left-5 top-0 w-px bg-mineshaft-600" />
|
||||
{structure.items.map((item) => (
|
||||
<div key={item.id} className="group ml-6 flex items-center rounded px-2 py-1">
|
||||
<div className="ml-6 mr-2">
|
||||
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
|
||||
</div>
|
||||
<Checkbox
|
||||
id={`folder-${item.id}`}
|
||||
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
|
||||
isChecked={selectedItemIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="ml-2 flex-1 cursor-pointer truncate"
|
||||
title={item.secretKey}
|
||||
>
|
||||
{item.secretKey}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(structure.subFolders).map(([subName, subStructure]) => (
|
||||
<Folder
|
||||
key={subName}
|
||||
name={subName}
|
||||
structure={subStructure}
|
||||
path={path ? `${path}/${subName}` : subName}
|
||||
selectedItems={selectedItems}
|
||||
onItemSelect={onItemSelect}
|
||||
onFolderSelect={onFolderSelect}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SecretTreeView: React.FC<TreeViewProps> = ({
|
||||
data,
|
||||
basePath = "/",
|
||||
className = "",
|
||||
onChange
|
||||
}) => {
|
||||
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
|
||||
const rootPath = "/";
|
||||
const treeData: TreeData = data ? { [rootPath]: data as FolderStructure } : { [rootPath]: null };
|
||||
|
||||
const rootFolders = useMemo(() => {
|
||||
return Object.entries(treeData);
|
||||
}, [treeData]);
|
||||
|
||||
const isEmptyData = useMemo(() => {
|
||||
return (
|
||||
!data || (typeof data === "object" && Object.keys(data).length === 0) || !rootFolders.length
|
||||
);
|
||||
}, [data, rootFolders]);
|
||||
|
||||
const handleItemSelect = (item: SecretItem, isChecked: boolean) => {
|
||||
if (isChecked) {
|
||||
setSelectedItems((prev) => [...prev, item]);
|
||||
} else {
|
||||
setSelectedItems((prev) => prev.filter((i) => i.id !== item.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderSelect = (folderPath: string, isChecked: boolean) => {
|
||||
const getFolderFromPath = (tree: TreeData, path: string): FolderStructure | null => {
|
||||
if (rootFolders.length === 1 && rootFolders[0][0] === path) {
|
||||
return rootFolders[0][1];
|
||||
}
|
||||
|
||||
let adjustedPath = path;
|
||||
if (!path.startsWith(rootPath)) {
|
||||
adjustedPath = rootPath === path ? rootPath : `${rootPath}/${path}`;
|
||||
}
|
||||
|
||||
if (adjustedPath === "/") return tree["/"];
|
||||
|
||||
const parts = adjustedPath.split("/").filter((p) => p !== "");
|
||||
|
||||
let current: any;
|
||||
current = tree["/"];
|
||||
|
||||
const targetExists = parts.every((part) => {
|
||||
if (current?.subFolders?.[part]) {
|
||||
current = current.subFolders[part];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!targetExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const folder = getFolderFromPath(treeData, folderPath);
|
||||
if (!folder) return;
|
||||
|
||||
const folderItems = getAllItemsInFolder(folder);
|
||||
const folderItemIds = folderItems.map((item) => item.id);
|
||||
|
||||
if (isChecked) {
|
||||
setSelectedItems((prev) => {
|
||||
const prevIds = prev.map((item) => item.id);
|
||||
const newItems = [...prev];
|
||||
folderItems.forEach((item) => {
|
||||
if (!prevIds.includes(item.id)) {
|
||||
newItems.push(item);
|
||||
}
|
||||
});
|
||||
return newItems;
|
||||
});
|
||||
} else {
|
||||
setSelectedItems((prev) => prev.filter((item) => !folderItemIds.includes(item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItems([]);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange(selectedItems);
|
||||
}, [selectedItems]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-start gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900">
|
||||
<div className={`w-full rounded-lg shadow-sm ${className}`}>
|
||||
<div className="h-[25vh] overflow-auto p-3">
|
||||
{isEmptyData ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-center text-mineshaft-300">
|
||||
<p>No secrets or folders available</p>
|
||||
</div>
|
||||
) : (
|
||||
rootFolders.map(([folderName, folderStructure]) => (
|
||||
<Folder
|
||||
basePath={basePath}
|
||||
key={folderName}
|
||||
name={folderName}
|
||||
structure={folderStructure || { items: [], subFolders: {} }}
|
||||
path={folderName}
|
||||
selectedItems={selectedItems}
|
||||
onItemSelect={handleItemSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
isExpanded
|
||||
level={0}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pb-2 pr-2 pt-2">
|
||||
<h3 className="flex items-center text-mineshaft-400">
|
||||
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { Button, FormControl, Input, PasswordGenerator } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
@@ -162,12 +162,15 @@ export const CreateSecretForm = ({
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
<PasswordGenerator onUsePassword={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -64,7 +64,8 @@ export const SecretDropzone = ({
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
|
||||
// hide copy secrets from board due to import folders feature
|
||||
const shouldRenderCopySecrets = false;
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
@@ -308,16 +309,18 @@ export const SecretDropzone = ({
|
||||
secretPath={secretPath}
|
||||
isSmaller={isSmaller}
|
||||
/>
|
||||
<CopySecretsFromBoard
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
|
||||
onParsedEnv={handleParsedEnv}
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isSmaller={isSmaller}
|
||||
/>
|
||||
{shouldRenderCopySecrets && (
|
||||
<CopySecretsFromBoard
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
|
||||
onParsedEnv={handleParsedEnv}
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isSmaller={isSmaller}
|
||||
/>
|
||||
)}
|
||||
{!isSmaller && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
|
@@ -41,7 +41,6 @@ export const SecretSyncAuditLogsSection = ({ secretSync }: Props) => {
|
||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
||||
eventType: INTEGRATION_EVENTS
|
||||
}}
|
||||
filterClassName="bg-mineshaft-900 static"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg bg-mineshaft-800 text-sm text-mineshaft-200">
|
||||
|
Reference in New Issue
Block a user