feature: deep search for secrets dashboard

This commit is contained in:
Scott Wilson
2024-10-29 15:08:19 -07:00
parent 1309f30af9
commit 21ea7dd317
30 changed files with 1538 additions and 238 deletions
@@ -9,7 +9,7 @@ import {
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection, ProjectServiceActor } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
@@ -22,6 +22,7 @@ import {
TDeleteDynamicSecretDTO, TDeleteDynamicSecretDTO,
TDetailsDynamicSecretDTO, TDetailsDynamicSecretDTO,
TGetDynamicSecretsCountDTO, TGetDynamicSecretsCountDTO,
TListDynamicSecretsByFolderMappingsDTO,
TListDynamicSecretsDTO, TListDynamicSecretsDTO,
TListDynamicSecretsMultiEnvDTO, TListDynamicSecretsMultiEnvDTO,
TUpdateDynamicSecretDTO TUpdateDynamicSecretDTO
@@ -454,8 +455,44 @@ export const dynamicSecretServiceFactory = ({
return dynamicSecretCfg; return dynamicSecretCfg;
}; };
const listDynamicSecretsByFolderIds = async (
{ folderMappings, filters, projectId }: TListDynamicSecretsByFolderMappingsDTO,
actor: ProjectServiceActor
) => {
const { permission } = await permissionService.getProjectPermission(
actor.type,
actor.id,
projectId,
actor.authMethod,
actor.orgId
);
const userAccessibleFolderMappings = folderMappings.filter(({ path, environment }) =>
permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath: path })
)
);
const groupedFolderMappings = new Map(userAccessibleFolderMappings.map((path) => [path.folderId, path]));
const dynamicSecrets = await dynamicSecretDAL.listDynamicSecretsByFolderIds({
folderIds: userAccessibleFolderMappings.map(({ folderId }) => folderId),
...filters
});
return dynamicSecrets.map((dynamicSecret) => {
const { environment, path } = groupedFolderMappings.get(dynamicSecret.folderId)!;
return {
...dynamicSecret,
environment,
path
};
});
};
// get dynamic secrets for multiple envs // get dynamic secrets for multiple envs
const listDynamicSecretsByFolderIds = async ({ const listDynamicSecretsByEnvs = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
@@ -521,9 +558,10 @@ export const dynamicSecretServiceFactory = ({
deleteByName, deleteByName,
getDetails, getDetails,
listDynamicSecretsByEnv, listDynamicSecretsByEnv,
listDynamicSecretsByFolderIds, listDynamicSecretsByEnvs,
getDynamicSecretCount, getDynamicSecretCount,
getCountMultiEnv, getCountMultiEnv,
fetchAzureEntraIdUsers fetchAzureEntraIdUsers,
listDynamicSecretsByFolderIds
}; };
}; };
@@ -48,17 +48,27 @@ export type TDetailsDynamicSecretDTO = {
projectSlug: string; projectSlug: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TListDynamicSecretsDTO = { export type ListDynamicSecretsFilters = {
path: string;
environmentSlug: string;
projectSlug?: string;
projectId?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
orderBy?: SecretsOrderBy; orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection; orderDirection?: OrderByDirection;
search?: string; search?: string;
} & Omit<TProjectPermission, "projectId">; };
export type TListDynamicSecretsDTO = {
path: string;
environmentSlug: string;
projectSlug?: string;
projectId?: string;
} & ListDynamicSecretsFilters &
Omit<TProjectPermission, "projectId">;
export type TListDynamicSecretsByFolderMappingsDTO = {
projectId: string;
folderMappings: { folderId: string; path: string; environment: string }[];
filters: ListDynamicSecretsFilters;
};
export type TListDynamicSecretsMultiEnvDTO = Omit< export type TListDynamicSecretsMultiEnvDTO = Omit<
TListDynamicSecretsDTO, TListDynamicSecretsDTO,
+7
View File
@@ -57,3 +57,10 @@ export enum OrderByDirection {
ASC = "asc", ASC = "asc",
DESC = "desc" DESC = "desc"
} }
export type ProjectServiceActor = {
type: ActorType;
id: string;
authMethod: ActorAuthMethod;
orgId: string;
};
@@ -20,6 +20,8 @@ import { AuthMode } from "@app/services/auth/auth-type";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
const MAX_DEEP_SEARCH_LIMIT = 500; // arbitrary limit to prevent excessive results
// handle querystring boolean values // handle querystring boolean values
const booleanSchema = z const booleanSchema = z
.union([z.boolean(), z.string().trim()]) .union([z.boolean(), z.string().trim()])
@@ -34,6 +36,35 @@ const booleanSchema = z
.optional() .optional()
.default(true); .default(true);
const parseSecretPathSearch = (search?: string) => {
if (!search)
return {
searchName: "",
searchPath: ""
};
if (!search.includes("/"))
return {
searchName: search,
searchPath: ""
};
if (search === "/")
return {
searchName: "",
searchPath: "/"
};
const [searchName, ...searchPathSegments] = search.split("/").reverse();
let searchPath = removeTrailingSlash(searchPathSegments.reverse().join("/").toLowerCase());
if (!searchPath.startsWith("/")) searchPath = `/${searchPath}`;
return {
searchName,
searchPath
};
};
export const registerDashboardRouter = async (server: FastifyZodProvider) => { export const registerDashboardRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "GET", method: "GET",
@@ -134,7 +165,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined; let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined; let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined;
let dynamicSecrets: let dynamicSecrets:
| Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByFolderIds>> | Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnvs>>
| undefined; | undefined;
let totalFolderCount: number | undefined; let totalFolderCount: number | undefined;
@@ -218,7 +249,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}); });
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) { if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByFolderIds({ dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByEnvs({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
@@ -633,4 +664,180 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
}; };
} }
}); });
server.route({
method: "GET",
url: "/secrets-deep-search",
config: {
rateLimit: secretsLimit
},
schema: {
security: [
{
bearerAuth: []
}
],
querystring: z.object({
projectId: z.string().trim(),
environments: z.string().trim().transform(decodeURIComponent),
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
search: z.string().trim().optional(),
tags: z.string().trim().transform(decodeURIComponent).optional()
}),
response: {
200: z.object({
folders: SecretFoldersSchema.extend({ path: z.string() }).array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.extend({ path: z.string(), environment: z.string() })
.array()
.optional(),
secrets: secretRawSchema
.extend({
secretPath: z.string().optional(),
tags: SecretTagsSchema.pick({
id: true,
slug: true,
color: true
})
.extend({ name: z.string() })
.array()
.optional()
})
.array()
.optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { secretPath, projectId, search } = req.query;
const environments = req.query.environments.split(",").filter((env) => Boolean(env.trim()));
if (!environments.length) throw new BadRequestError({ message: "One or more environments required" });
const tags = req.query.tags?.split(",").filter((tag) => Boolean(tag.trim())) ?? [];
if (!search && !tags.length) throw new BadRequestError({ message: "Search or tags required" });
const searchHasTags = Boolean(tags.length);
const allFolders = await server.services.folder.getFoldersDeepByEnvs(
{
projectId,
environments,
secretPath
},
req.permission
);
const { searchName, searchPath } = parseSecretPathSearch(search);
const folderMappings = allFolders.map((folder) => ({
folderId: folder.id,
path: folder.path,
environment: folder.environment
}));
const sharedFilters = {
search: searchName,
limit: MAX_DEEP_SEARCH_LIMIT,
orderBy: SecretsOrderBy.Name
};
const secrets = await server.services.secret.getSecretsRawByFolderMappings(
{
projectId,
folderMappings,
filters: {
...sharedFilters,
tagSlugs: tags,
includeTagsInSearch: true
}
},
req.permission
);
const dynamicSecrets = searchHasTags
? []
: await server.services.dynamicSecret.listDynamicSecretsByFolderIds(
{
projectId,
folderMappings,
filters: sharedFilters
},
req.permission
);
for await (const environment of environments) {
const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length;
if (secretCountForEnv) {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secretCountForEnv
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secretCountForEnv,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
const sliceQuickSearch = <T>(array: T[]) => array.slice(0, 25);
return {
secrets: sliceQuickSearch(
searchPath ? secrets.filter((secret) => secret.secretPath.endsWith(searchPath)) : secrets
),
dynamicSecrets: sliceQuickSearch(
searchPath
? dynamicSecrets.filter((dynamicSecret) => dynamicSecret.path.endsWith(searchPath))
: dynamicSecrets
),
folders: searchHasTags
? []
: sliceQuickSearch(
allFolders.filter((folder) => {
const [folderName, ...folderPathSegments] = folder.path.split("/").reverse();
const folderPath = folderPathSegments.reverse().join("/").toLowerCase() || "/";
if (searchPath) {
if (searchPath === "/") {
// only show root folders if no folder name search
if (!searchName) return folderPath === searchPath;
// start partial match on root folders
return folderName.toLowerCase().startsWith(searchName.toLowerCase());
}
// support ending partial path match
return (
folderPath.endsWith(searchPath) && folderName.toLowerCase().startsWith(searchName.toLowerCase())
);
}
// no search path, "fuzzy" match all folders
return folderName.toLowerCase().includes(searchName.toLowerCase());
})
)
};
}
});
}; };
+7 -7
View File
@@ -1,9 +1,9 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { FastifyRequest } from "fastify";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { ProjectServiceActor } from "@app/lib/types";
import { import {
TCmekDecryptDTO, TCmekDecryptDTO,
TCmekEncryptDTO, TCmekEncryptDTO,
@@ -23,7 +23,7 @@ type TCmekServiceFactoryDep = {
export type TCmekServiceFactory = ReturnType<typeof cmekServiceFactory>; export type TCmekServiceFactory = ReturnType<typeof cmekServiceFactory>;
export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => { export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => {
const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: FastifyRequest["permission"]) => { const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: ProjectServiceActor) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor.type, actor.type,
actor.id, actor.id,
@@ -43,7 +43,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
return cmek; return cmek;
}; };
const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: FastifyRequest["permission"]) => { const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: ProjectServiceActor) => {
const key = await kmsDAL.findById(keyId); const key = await kmsDAL.findById(keyId);
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
@@ -65,7 +65,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
return cmek; return cmek;
}; };
const deleteCmekById = async (keyId: string, actor: FastifyRequest["permission"]) => { const deleteCmekById = async (keyId: string, actor: ProjectServiceActor) => {
const key = await kmsDAL.findById(keyId); const key = await kmsDAL.findById(keyId);
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
@@ -89,7 +89,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
const listCmeksByProjectId = async ( const listCmeksByProjectId = async (
{ projectId, ...filters }: TListCmeksByProjectIdDTO, { projectId, ...filters }: TListCmeksByProjectIdDTO,
actor: FastifyRequest["permission"] actor: ProjectServiceActor
) => { ) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor.type, actor.type,
@@ -106,7 +106,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
return { cmeks, totalCount }; return { cmeks, totalCount };
}; };
const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: FastifyRequest["permission"]) => { const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: ProjectServiceActor) => {
const key = await kmsDAL.findById(keyId); const key = await kmsDAL.findById(keyId);
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
@@ -132,7 +132,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
return cipherTextBlob.toString("base64"); return cipherTextBlob.toString("base64");
}; };
const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: FastifyRequest["permission"]) => { const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: ProjectServiceActor) => {
const key = await kmsDAL.findById(keyId); const key = await kmsDAL.findById(keyId);
if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` }); if (!key) throw new NotFoundError({ message: `Key with ID ${keyId} not found` });
@@ -1,9 +1,9 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { FastifyRequest } from "fastify";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectServiceActor } from "@app/lib/types";
import { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns"; import { constructGroupOrgMembershipRoleMappings } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-fns";
import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types"; import { TSyncExternalGroupOrgMembershipRoleMappingsDTO } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-types";
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
@@ -25,7 +25,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({
permissionService, permissionService,
orgRoleDAL orgRoleDAL
}: TExternalGroupOrgRoleMappingServiceFactoryDep) => { }: TExternalGroupOrgRoleMappingServiceFactoryDep) => {
const listExternalGroupOrgRoleMappings = async (actor: FastifyRequest["permission"]) => { const listExternalGroupOrgRoleMappings = async (actor: ProjectServiceActor) => {
const { permission } = await permissionService.getOrgPermission( const { permission } = await permissionService.getOrgPermission(
actor.type, actor.type,
actor.id, actor.id,
@@ -46,7 +46,7 @@ export const externalGroupOrgRoleMappingServiceFactory = ({
const updateExternalGroupOrgRoleMappings = async ( const updateExternalGroupOrgRoleMappings = async (
dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO, dto: TSyncExternalGroupOrgMembershipRoleMappingsDTO,
actor: FastifyRequest["permission"] actor: ProjectServiceActor
) => { ) => {
const { permission } = await permissionService.getOrgPermission( const { permission } = await permissionService.getOrgPermission(
actor.type, actor.type,
@@ -8,6 +8,8 @@ import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { TFindFoldersDeepByParentIdsDTO } from "./secret-folder-types";
export const validateFolderName = (folderName: string) => { export const validateFolderName = (folderName: string) => {
const validNameRegex = /^[a-zA-Z0-9-_]+$/; const validNameRegex = /^[a-zA-Z0-9-_]+$/;
return validNameRegex.test(folderName); return validNameRegex.test(folderName);
@@ -444,6 +446,48 @@ export const secretFolderDALFactory = (db: TDbClient) => {
} }
}; };
const findByEnvsDeep = async ({ parentIds }: TFindFoldersDeepByParentIdsDTO, tx?: Knex) => {
try {
const folders = await (tx || db.replicaNode())
.withRecursive("parents", (qb) =>
qb
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw("0 as depth"),
db.raw(`'/' as path`),
db.ref(`${TableName.Environment}.slug`).as("environment")
)
.from(TableName.SecretFolder)
.join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.whereIn(`${TableName.SecretFolder}.id`, parentIds)
.union((un) => {
void un
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw("parents.depth + 1 as depth"),
db.raw(
`CONCAT(
CASE WHEN parents.path = '/' THEN '' ELSE parents.path END,
CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' ELSE CONCAT('/', secret_folders.name) END
)`
),
db.ref("parents.environment")
)
.from(TableName.SecretFolder)
.join("parents", `${TableName.SecretFolder}.parentId`, "parents.id");
})
)
.select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*")
.from("parents")
.orderBy("depth")
.orderBy(`name`);
return folders;
} catch (error) {
throw new DatabaseError({ error, name: "FindByEnvsDeep" });
}
};
return { return {
...secretFolderOrm, ...secretFolderOrm,
update, update,
@@ -454,6 +498,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
findSecretPathByFolderIds, findSecretPathByFolderIds,
findClosestFolder, findClosestFolder,
findByProjectId, findByProjectId,
findByMultiEnv findByMultiEnv,
findByEnvsDeep
}; };
}; };
@@ -7,7 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection, ProjectServiceActor } from "@app/lib/types";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -17,6 +17,7 @@ import {
TDeleteFolderDTO, TDeleteFolderDTO,
TGetFolderByIdDTO, TGetFolderByIdDTO,
TGetFolderDTO, TGetFolderDTO,
TGetFoldersDeepByEnvsDTO,
TUpdateFolderDTO, TUpdateFolderDTO,
TUpdateManyFoldersDTO TUpdateManyFoldersDTO
} from "./secret-folder-types"; } from "./secret-folder-types";
@@ -511,6 +512,30 @@ export const secretFolderServiceFactory = ({
}; };
}; };
const getFoldersDeepByEnvs = async (
{ projectId, environments, secretPath }: TGetFoldersDeepByEnvsDTO,
actor: ProjectServiceActor
) => {
// folder list is allowed to be read by anyone
// permission to check does user have access
await permissionService.getProjectPermission(actor.type, actor.id, projectId, actor.authMethod, actor.orgId);
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
if (!envs.length)
throw new NotFoundError({
message: `Environments '${environments.join(", ")}' not found`,
name: "GetFoldersDeep"
});
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
if (!parentFolders.length) return [];
const folders = await folderDAL.findByEnvsDeep({ parentIds: parentFolders.map((parent) => parent.id) });
return folders;
};
return { return {
createFolder, createFolder,
updateFolder, updateFolder,
@@ -519,6 +544,7 @@ export const secretFolderServiceFactory = ({
getFolders, getFolders,
getFolderById, getFolderById,
getProjectFolderCount, getProjectFolderCount,
getFoldersMultiEnv getFoldersMultiEnv,
getFoldersDeepByEnvs
}; };
}; };
@@ -47,3 +47,13 @@ export type TGetFolderDTO = {
export type TGetFolderByIdDTO = { export type TGetFolderByIdDTO = {
id: string; id: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetFoldersDeepByEnvsDTO = {
projectId: string;
environments: string[];
secretPath: string;
};
export type TFindFoldersDeepByParentIdsDTO = {
parentIds: string[];
};
@@ -14,6 +14,7 @@ import {
} from "@app/lib/knex"; } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { TFindSecretsByFolderIdsFilter } from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>; export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>;
@@ -339,14 +340,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
folderIds: string[], folderIds: string[],
userId?: string, userId?: string,
tx?: Knex, tx?: Knex,
filters?: { filters?: TFindSecretsByFolderIdsFilter
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
tagSlugs?: string[];
}
) => { ) => {
try { try {
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo) // check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
@@ -356,14 +350,20 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
} }
const query = (tx || db.replicaNode())(TableName.SecretV2) const query = (tx || db.replicaNode())(TableName.SecretV2)
.whereIn("folderId", folderIds) .whereIn(`${TableName.SecretV2}.folderId`, folderIds)
.where((bd) => { .where((bd) => {
if (filters?.search) { if (filters?.search) {
void bd.whereILike("key", `%${filters?.search}%`); if (filters?.includeTagsInSearch) {
void bd
.whereILike(`${TableName.SecretV2}.key`, `%${filters?.search}%`)
.orWhereILike(`${TableName.SecretTag}.slug`, `%${filters?.search}%`);
} else {
void bd.whereILike(`${TableName.SecretV2}.key`, `%${filters?.search}%`);
}
} }
}) })
.where((bd) => { .where((bd) => {
void bd.whereNull("userId").orWhere({ userId: userId || null }); void bd.whereNull(`${TableName.SecretV2}.userId`).orWhere({ userId: userId || null });
}) })
.leftJoin( .leftJoin(
TableName.SecretV2JnTag, TableName.SecretV2JnTag,
@@ -385,7 +385,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
.where((bd) => { .where((bd) => {
const slugs = filters?.tagSlugs?.filter(Boolean); const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) { if (slugs && slugs.length > 0) {
void bd.whereIn("slug", slugs); void bd.whereIn(`${TableName.SecretTag}.slug`, slugs);
} }
}) })
.orderBy( .orderBy(
@@ -43,6 +43,7 @@ import {
TGetASecretDTO, TGetASecretDTO,
TGetSecretReferencesTreeDTO, TGetSecretReferencesTreeDTO,
TGetSecretsDTO, TGetSecretsDTO,
TGetSecretsRawByFolderMappingsDTO,
TGetSecretVersionsDTO, TGetSecretVersionsDTO,
TMoveSecretsDTO, TMoveSecretsDTO,
TSecretReference, TSecretReference,
@@ -652,6 +653,56 @@ export const secretV2BridgeServiceFactory = ({
return count; return count;
}; };
const getSecretsByFolderMappings = async (
{ projectId, userId, filters, folderMappings }: TGetSecretsRawByFolderMappingsDTO,
projectPermission: Awaited<ReturnType<typeof permissionService.getProjectPermission>>["permission"]
) => {
const groupedFolderMappings = groupBy(folderMappings, (folderMapping) => folderMapping.folderId);
const secrets = await secretDAL.findByFolderIds(
folderMappings.map((folderMapping) => folderMapping.folderId),
userId,
undefined,
filters
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedSecrets = secrets
.filter((el) =>
projectPermission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: groupedFolderMappings[el.folderId][0].environment,
secretPath: groupedFolderMappings[el.folderId][0].path,
secretName: el.key,
secretTags: el.tags.map((i) => i.slug)
})
)
)
.map((secret) =>
reshapeBridgeSecret(
projectId,
groupedFolderMappings[secret.folderId][0].environment,
groupedFolderMappings[secret.folderId][0].path,
{
...secret,
value: secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
: "",
comment: secret.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
: ""
}
)
);
return decryptedSecrets;
};
// get secrets for multiple envs // get secrets for multiple envs
const getSecretsMultiEnv = async ({ const getSecretsMultiEnv = async ({
actorId, actorId,
@@ -678,59 +729,28 @@ export const secretV2BridgeServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
} }
let paths: { folderId: string; path: string; environment: string }[] = [];
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path); const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
if (!folders.length) { if (!folders.length) {
return []; return [];
} }
paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug })); const folderMappings = folders.map((folder) => ({
folderId: folder.id,
path,
environment: folder.environment.slug
}));
const groupedPaths = groupBy(paths, (p) => p.folderId); const decryptedSecrets = await getSecretsByFolderMappings(
{
const secrets = await secretDAL.findByFolderIds( projectId,
paths.map((p) => p.folderId), folderMappings,
actorId, filters: params,
undefined, userId: actorId
params },
permission
); );
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedSecrets = secrets
.filter((el) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: groupedPaths[el.folderId][0].environment,
secretPath: groupedPaths[el.folderId][0].path,
secretName: el.key,
secretTags: el.tags.map((i) => i.slug)
})
)
)
.map((secret) =>
reshapeBridgeSecret(
projectId,
groupedPaths[secret.folderId][0].environment,
groupedPaths[secret.folderId][0].path,
{
...secret,
value: secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
: "",
comment: secret.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
: ""
}
)
);
return decryptedSecrets; return decryptedSecrets;
}; };
@@ -2027,6 +2047,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretsCount, getSecretsCount,
getSecretsCountMultiEnv, getSecretsCountMultiEnv,
getSecretsMultiEnv, getSecretsMultiEnv,
getSecretReferenceTree getSecretReferenceTree,
getSecretsByFolderMappings
}; };
}; };
@@ -285,3 +285,20 @@ export type TGetSecretReferencesTreeDTO = {
environment: string; environment: string;
secretPath: string; secretPath: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TFindSecretsByFolderIdsFilter = {
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
tagSlugs?: string[];
includeTagsInSearch?: boolean;
};
export type TGetSecretsRawByFolderMappingsDTO = {
projectId: string;
folderMappings: { folderId: string; path: string; environment: string }[];
userId: string;
filters: TFindSecretsByFolderIdsFilter;
};
+25 -1
View File
@@ -27,6 +27,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { groupBy, pick } from "@app/lib/fn"; import { groupBy, pick } from "@app/lib/fn";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ProjectServiceActor } from "@app/lib/types";
import { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
@@ -2845,6 +2847,27 @@ export const secretServiceFactory = ({
return { message: "Migrating project to new KMS architecture" }; return { message: "Migrating project to new KMS architecture" };
}; };
const getSecretsRawByFolderMappings = async (
params: Omit<TGetSecretsRawByFolderMappingsDTO, "userId">,
actor: ProjectServiceActor
) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(params.projectId);
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
const { permission } = await permissionService.getProjectPermission(
actor.type,
actor.id,
params.projectId,
actor.authMethod,
actor.orgId
);
const secrets = secretV2BridgeService.getSecretsByFolderMappings({ ...params, userId: actor.id }, permission);
return secrets;
};
return { return {
attachTags, attachTags,
detachTags, detachTags,
@@ -2871,6 +2894,7 @@ export const secretServiceFactory = ({
getSecretsCount, getSecretsCount,
getSecretsCountMultiEnv, getSecretsCountMultiEnv,
getSecretsRawMultiEnv, getSecretsRawMultiEnv,
getSecretReferenceTree getSecretReferenceTree,
getSecretsRawByFolderMappings
}; };
}; };
@@ -16,6 +16,7 @@ export type CheckboxProps = Omit<
checkIndicatorBg?: string | undefined; checkIndicatorBg?: string | undefined;
isError?: boolean; isError?: boolean;
isIndeterminate?: boolean; isIndeterminate?: boolean;
containerClassName?: string;
}; };
export const Checkbox = ({ export const Checkbox = ({
@@ -28,10 +29,11 @@ export const Checkbox = ({
checkIndicatorBg, checkIndicatorBg,
isError, isError,
isIndeterminate, isIndeterminate,
containerClassName,
...props ...props
}: CheckboxProps): JSX.Element => { }: CheckboxProps): JSX.Element => {
return ( return (
<div className="flex items-center font-inter text-bunker-300"> <div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
className={twMerge( className={twMerge(
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500", "flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500",
+5
View File
@@ -0,0 +1,5 @@
export const reverseTruncate = (text: string, maxLength = 42) => {
if (text.length < maxLength) return text;
return `...${text.substring(text.length - maxLength + 3)}`;
};
+5 -1
View File
@@ -1 +1,5 @@
export { useGetProjectSecretsDetails } from "./queries"; export {
useGetProjectSecretsDetails,
useGetProjectSecretsOverview,
useGetProjectSecretsQuickSearch
} from "./queries";
+114 -3
View File
@@ -10,12 +10,15 @@ import {
DashboardProjectSecretsOverview, DashboardProjectSecretsOverview,
DashboardProjectSecretsOverviewResponse, DashboardProjectSecretsOverviewResponse,
DashboardSecretsOrderBy, DashboardSecretsOrderBy,
TDashboardProjectSecretsQuickSearch,
TDashboardProjectSecretsQuickSearchResponse,
TGetDashboardProjectSecretsDetailsDTO, TGetDashboardProjectSecretsDetailsDTO,
TGetDashboardProjectSecretsOverviewDTO TGetDashboardProjectSecretsOverviewDTO,
TGetDashboardProjectSecretsQuickSearchDTO
} from "@app/hooks/api/dashboard/types"; } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
import { unique } from "@app/lib/fn/array"; import { groupBy, unique } from "@app/lib/fn/array";
export const dashboardKeys = { export const dashboardKeys = {
all: () => ["dashboard"] as const, all: () => ["dashboard"] as const,
@@ -42,8 +45,18 @@ export const dashboardKeys = {
}: TGetDashboardProjectSecretsDetailsDTO) => }: TGetDashboardProjectSecretsDetailsDTO) =>
[ [
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }), ...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
environment,
"secrets-details", "secrets-details",
environment,
params
] as const,
getProjectSecretsQuickSearch: ({
projectId,
secretPath,
...params
}: TGetDashboardProjectSecretsQuickSearchDTO) =>
[
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
"quick-search",
params params
] as const ] as const
}; };
@@ -256,3 +269,101 @@ export const useGetProjectSecretsDetails = (
keepPreviousData: true keepPreviousData: true
}); });
}; };
export const fetchProjectSecretsQuickSearch = async ({
environments,
tags,
...params
}: TGetDashboardProjectSecretsQuickSearchDTO) => {
const { data } = await apiRequest.get<TDashboardProjectSecretsQuickSearchResponse>(
"/api/v1/dashboard/secrets-deep-search",
{
params: {
...params,
environments: encodeURIComponent(environments.join(",")),
tags: encodeURIComponent(
Object.entries(tags)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, enabled]) => enabled)
.map(([tag]) => tag)
.join(",")
)
}
}
);
return data;
};
export const useGetProjectSecretsQuickSearch = (
{
projectId,
secretPath,
search = "",
environments,
tags
}: TGetDashboardProjectSecretsQuickSearchDTO,
options?: Omit<
UseQueryOptions<
TDashboardProjectSecretsQuickSearchResponse,
unknown,
TDashboardProjectSecretsQuickSearch,
ReturnType<typeof dashboardKeys.getProjectSecretsQuickSearch>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
...options,
enabled:
Boolean(search?.trim() || Object.values(tags).length) &&
(options?.enabled ?? true) &&
Boolean(environments.length),
queryKey: dashboardKeys.getProjectSecretsQuickSearch({
secretPath,
search,
projectId,
environments,
tags
}),
queryFn: () =>
fetchProjectSecretsQuickSearch({
secretPath,
search,
projectId,
environments,
tags
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
createNotification({
title: "Error fetching secrets deep search",
type: "error",
text: serverResponse.message
});
}
},
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsQuickSearch>>) => {
const { secrets, folders, dynamicSecrets } = data;
const groupedFolders = groupBy(folders, (folder) => folder.path);
const groupedSecrets = groupBy(
mergePersonalSecrets(secrets),
(secret) => `${secret.path === "/" ? "" : secret.path}/${secret.key}`
);
const groupedDynamicSecrets = groupBy(
dynamicSecrets,
(dynamicSecret) =>
`${dynamicSecret.path === "/" ? "" : dynamicSecret.path}/${dynamicSecret.name}`
);
return {
folders: groupedFolders,
secrets: groupedSecrets,
dynamicSecrets: groupedDynamicSecrets
};
}, []),
keepPreviousData: true
});
};
+20
View File
@@ -69,3 +69,23 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
includeImports?: boolean; includeImports?: boolean;
tags: Record<string, boolean>; tags: Record<string, boolean>;
}; };
export type TDashboardProjectSecretsQuickSearchResponse = {
folders: (TSecretFolder & { environment: string; path: string })[];
dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[];
secrets: SecretV3Raw[];
};
export type TDashboardProjectSecretsQuickSearch = {
folders: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
secrets: Record<string, SecretV3RawSanitized[]>;
dynamicSecrets: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
};
export type TGetDashboardProjectSecretsQuickSearchDTO = {
projectId: string;
secretPath: string;
tags: Record<string, boolean>;
search: string;
environments: string[];
};
+2 -1
View File
@@ -66,7 +66,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
createdAt: el.createdAt, createdAt: el.createdAt,
updatedAt: el.updatedAt, updatedAt: el.updatedAt,
version: el.version, version: el.version,
skipMultilineEncoding: el.skipMultilineEncoding skipMultilineEncoding: el.skipMultilineEncoding,
path: el.secretPath
}; };
if (el.type === SecretType.Personal) { if (el.type === SecretType.Personal) {
+3 -1
View File
@@ -29,7 +29,7 @@ export type EncryptedSecret = {
tags: WsTag[]; tags: WsTag[];
}; };
// both personal and shared secret stitiched together for dashboard // both personal and shared secret stitched together for dashboard
export type SecretV3RawSanitized = { export type SecretV3RawSanitized = {
id: string; id: string;
version: number; version: number;
@@ -42,6 +42,7 @@ export type SecretV3RawSanitized = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
env: string; env: string;
path?: string;
valueOverride?: string; valueOverride?: string;
idOverride?: string; idOverride?: string;
overrideAction?: string; overrideAction?: string;
@@ -57,6 +58,7 @@ export type SecretV3Raw = {
version: number; version: number;
type: string; type: string;
secretKey: string; secretKey: string;
secretPath: string;
secretValue?: string; secretValue?: string;
secretComment?: string; secretComment?: string;
secretReminderNote?: string; secretReminderNote?: string;
@@ -186,7 +186,9 @@ const SecretMainPageContent = () => {
}); });
// fetch tags // fetch tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); const { data: tags } = useGetWsTags(
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
);
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
workspaceId, workspaceId,
@@ -305,6 +307,32 @@ const SecretMainPageContent = () => {
} }
}, [secretPath]); }, [secretPath]);
useEffect(() => {
if (!router.query.search && !router.query.tags) return;
const queryTags = router.query.tags
? (router.query.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
: [];
const updatedTags: Record<string, boolean> = {};
queryTags.forEach((tag) => {
updatedTags[tag] = true;
});
setFilter((prev) => ({
...prev,
...defaultFilterState,
searchFilter: (router.query.search as string) ?? "",
tags: updatedTags
}));
setDebouncedSearchFilter(router.query.search as string);
// this is a temp workaround until we fully transition state to query params,
const { search, tags: qTags, ...query } = router.query;
router.push({
pathname: router.pathname,
query
});
}, [router.query.search, router.query.tags]);
const selectedSecrets = useSelectedSecrets(); const selectedSecrets = useSelectedSecrets();
const selectedSecretActions = useSelectedSecretActions(); const selectedSecretActions = useSelectedSecretActions();
@@ -389,8 +417,29 @@ const SecretMainPageContent = () => {
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium" "sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)} )}
> >
<Tooltip
className="max-w-[20rem] whitespace-nowrap"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all secrets on page`
: ""
}
>
<div className="mr-[0.055rem] flex w-11 items-center justify-center pl-2.5">
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
onClick={(e) => e.stopPropagation()}
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
<div <div
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2" className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 py-2 pl-4"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={handleSortToggle} onClick={handleSortToggle}
@@ -398,27 +447,6 @@ const SecretMainPageContent = () => {
if (evt.key === "Enter") handleSortToggle(); if (evt.key === "Enter") handleSortToggle();
}} }}
> >
<Tooltip
className="max-w-[20rem] whitespace-nowrap"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all secrets on page`
: ""
}
>
<div className="mr-6 ml-1">
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
onClick={(e) => e.stopPropagation()}
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
Key Key
<FontAwesomeIcon <FontAwesomeIcon
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp} icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
@@ -427,53 +455,53 @@ const SecretMainPageContent = () => {
</div> </div>
<div className="flex-grow px-4 py-2">Value</div> <div className="flex-grow px-4 py-2">Value</div>
</div> </div>
)} )}
{canReadSecretImports && Boolean(imports?.length) && ( {canReadSecretImports && Boolean(imports?.length) && (
<SecretImportListView <SecretImportListView
searchTerm={debouncedSearchFilter} searchTerm={debouncedSearchFilter}
secretImports={imports} secretImports={imports}
isFetching={isDetailsFetching} isFetching={isDetailsFetching}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
importedSecrets={importedSecrets} importedSecrets={importedSecrets}
/> />
)} )}
{Boolean(folders?.length) && ( {Boolean(folders?.length) && (
<FolderListView <FolderListView
folders={folders} folders={folders}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
onNavigateToFolder={handleResetFilter} onNavigateToFolder={handleResetFilter}
/> />
)} )}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
<DynamicSecretListView <DynamicSecretListView
environment={environment} environment={environment}
projectSlug={projectSlug} projectSlug={projectSlug}
secretPath={secretPath} secretPath={secretPath}
dynamicSecrets={dynamicSecrets} dynamicSecrets={dynamicSecrets}
/> />
)} )}
{canReadSecret && Boolean(secrets?.length) && ( {canReadSecret && Boolean(secrets?.length) && (
<SecretListView <SecretListView
secrets={secrets} secrets={secrets}
tags={tags} tags={tags}
isVisible={isVisible} isVisible={isVisible}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
isProtectedBranch={isProtectedBranch} isProtectedBranch={isProtectedBranch}
/> />
)} )}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />} {canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret && {!canReadSecret &&
!canReadDynamicSecret && !canReadDynamicSecret &&
!canReadSecretImports && !canReadSecretImports &&
folders?.length === 0 && <PermissionDeniedBanner />} folders?.length === 0 && <PermissionDeniedBanner />}
</div>
</div> </div>
</div>
{!isDetailsLoading && totalCount > 0 && ( {!isDetailsLoading && totalCount > 0 && (
<Pagination <Pagination
startAdornment={ startAdornment={
@@ -15,7 +15,6 @@ import {
faFolder, faFolder,
faFolderPlus, faFolderPlus,
faKey, faKey,
faMagnifyingGlass,
faMinusSquare, faMinusSquare,
faPlus, faPlus,
faTrash faTrash
@@ -39,7 +38,6 @@ import {
DropdownSubMenuContent, DropdownSubMenuContent,
DropdownSubMenuTrigger, DropdownSubMenuTrigger,
IconButton, IconButton,
Input,
Modal, Modal,
ModalContent, ModalContent,
Tooltip, Tooltip,
@@ -49,12 +47,14 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
ProjectPermissionSub, ProjectPermissionSub,
useSubscription useSubscription,
useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api"; import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries"; import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
import { SecretType, WsTag } from "@app/hooks/api/types"; import { SecretType, WsTag } from "@app/hooks/api/types";
import { SecretSearchInput } from "@app/views/SecretOverviewPage/components/SecretSearchInput";
import { import {
PopUpNames, PopUpNames,
@@ -123,6 +123,8 @@ export const ActionBar = ({
const { reset: resetSelectedSecret } = useSelectedSecretActions(); const { reset: resetSelectedSecret } = useSelectedSecretActions();
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { currentWorkspace } = useWorkspace();
const handleFolderCreate = async (folderName: string) => { const handleFolderCreate = async (folderName: string) => {
try { try {
await createFolder({ await createFolder({
@@ -269,22 +271,15 @@ export const ActionBar = ({
return ( return (
<> <>
<div className="mt-4 flex items-center space-x-2"> <div className="mt-4 flex items-center space-x-2">
<div className="w-2/5"> <SecretSearchInput
<Input isSingleEnv
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" className="w-2/5"
placeholder="Search by folder name, key name, comment..." value={filter.searchFilter}
leftIcon={ onChange={onSearchChange}
<FontAwesomeIcon environments={[currentWorkspace?.environments.find((env) => env.slug === environment)!]}
className={filter.searchFilter ? "text-primary" : ""} projectId={workspaceId}
icon={faMagnifyingGlass} tags={tags}
/> />
}
value={filter.searchFilter}
onChange={(evt) => {
onSearchChange(evt.target.value);
}}
/>
</div>
<div> <div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -364,8 +359,10 @@ export const ActionBar = ({
> >
Tags Tags
</DropdownSubMenuTrigger> </DropdownSubMenuTrigger>
<DropdownSubMenuContent className="rounded-l-none"> <DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel>Apply tags to filter secrets</DropdownMenuLabel> <DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Tags to Filter Secrets
</DropdownMenuLabel>
{tags.map(({ id, slug, color }) => ( {tags.map(({ id, slug, color }) => (
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
@@ -466,7 +463,10 @@ export const ActionBar = ({
<div className="flex flex-col space-y-1 p-1.5"> <div className="flex flex-col space-y-1 p-1.5">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })} a={subject(ProjectPermissionSub.SecretFolders, {
environment,
secretPath
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -14,7 +14,6 @@ import {
faFolderPlus, faFolderPlus,
faKey, faKey,
faList, faList,
faMagnifyingGlass,
faPlus faPlus
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -33,7 +32,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton, IconButton,
Input,
Modal, Modal,
ModalContent, ModalContent,
Pagination, Pagination,
@@ -61,6 +59,7 @@ import {
useCreateSecretV3, useCreateSecretV3,
useDeleteSecretV3, useDeleteSecretV3,
useGetImportedSecretsAllEnvs, useGetImportedSecretsAllEnvs,
useGetWsTags,
useUpdateSecretV3 useUpdateSecretV3
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries";
@@ -76,6 +75,7 @@ import {
SecretNoAccessOverviewTableRow, SecretNoAccessOverviewTableRow,
SecretOverviewTableRow SecretOverviewTableRow
} from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow"; } from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow";
import { SecretSearchInput } from "@app/views/SecretOverviewPage/components/SecretSearchInput";
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount"; import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount";
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
@@ -231,6 +231,9 @@ export const SecretOverviewPage = () => {
useDynamicSecretOverview(dynamicSecrets); useDynamicSecretOverview(dynamicSecrets);
const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets); const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets);
const { data: tags } = useGetWsTags(
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
);
const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
@@ -601,6 +604,20 @@ export const SecretOverviewPage = () => {
setSelectedEntries(newChecks); setSelectedEntries(newChecks);
}; };
useEffect(() => {
if (router.query.search) {
const { search, ...query } = router.query;
// temp workaround until we transition state to query params
router.push({
pathname: router.pathname,
query
});
setFilter(DEFAULT_FILTER_STATE);
setSearchFilter(router.query.search as string);
setDebouncedSearchFilter(router.query.search as string);
}
}, [router.query.search]);
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) { if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
return ( return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -640,47 +657,49 @@ export const SecretOverviewPage = () => {
<NavHeader pageName={t("dashboard.title")} isProjectRelated /> <NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div> </div>
<div className="space-y-8"> <div className="space-y-8">
<div className="mt-6"> <div className="flex w-full items-baseline justify-between">
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p> <div className="mt-6">
<p className="text-md text-bunker-300"> <p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
Inject your secrets using <p className="text-md text-bunker-300">
<a Inject your secrets using
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600" <a
href="https://infisical.com/docs/cli/overview" className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
target="_blank" href="https://infisical.com/docs/cli/overview"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
Infisical CLI >
</a> Infisical CLI
, </a>
<a ,
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600" <a
href="https://infisical.com/docs/documentation/getting-started/api" className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
target="_blank" href="https://infisical.com/docs/documentation/getting-started/api"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
Infisical API >
</a> Infisical API
, </a>
<a ,
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600" <a
href="https://infisical.com/docs/sdks/overview" className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
target="_blank" href="https://infisical.com/docs/sdks/overview"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
Infisical SDKs >
</a> Infisical SDKs
, and </a>
<a , and
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600" <a
href="https://infisical.com/docs/documentation/getting-started/introduction" className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
target="_blank" href="https://infisical.com/docs/documentation/getting-started/introduction"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
more >
</a> more
. </a>
</p> .
</p>
</div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} /> <FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
@@ -780,20 +799,13 @@ export const SecretOverviewPage = () => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
<div className="w-80"> <SecretSearchInput
<Input value={searchFilter}
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" tags={tags}
placeholder="Search by secret/folder name..." onChange={setSearchFilter}
value={searchFilter} environments={userAvailableEnvs}
onChange={(e) => setSearchFilter(e.target.value)} projectId={currentWorkspace?.id!}
leftIcon={ />
<FontAwesomeIcon
icon={faMagnifyingGlass}
className={searchFilter ? "text-primary" : ""}
/>
}
/>
</div>
{userAvailableEnvs.length > 0 && ( {userAvailableEnvs.length > 0 && (
<div> <div>
<Button <Button
@@ -0,0 +1,113 @@
import { useState } from "react";
import { faCircleXmark, faFolderTree, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Combobox, Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { IconButton, Tooltip } from "@app/components/v2";
import { QuickSearchModal, QuickSearchModalProps } from "./components";
type ModalProps = Omit<
QuickSearchModalProps,
"isOpen" | "onClose" | "onOpenChange" | "initialValue"
> & {
value: string;
onChange: (search: string) => void;
className?: string;
};
export const SecretSearchInput = ({
value,
onChange,
className,
isSingleEnv,
...props
}: ModalProps) => {
const [isOpen, setIsOpen] = useState(false);
const hasSearch = Boolean(value.trim());
return (
<div className={twMerge("relative w-80", className)}>
<Combobox
// keeps combobox from internally controlling state, hacky use of combobox
value={undefined}
>
{({ activeIndex }) => (
<>
<div className="flex w-full items-center whitespace-nowrap">
<Tooltip content="Search Options">
<Combobox.Button className="button user-select-none relative inline-flex h-[2.42rem] cursor-pointer items-center justify-center rounded-md rounded-r-none border border-mineshaft-600 bg-mineshaft-600 p-3 font-inter text-sm font-medium text-bunker-200 transition-all duration-100 hover:border-primary-400/50 hover:bg-primary/[0.1] hover:text-bunker-100">
<FontAwesomeIcon
icon={faSearch}
size="sm"
className={hasSearch ? "text-primary" : ""}
aria-hidden="true"
/>
</Combobox.Button>
</Tooltip>
<div className="relative inline-flex w-full items-center rounded-md rounded-l-none border border-mineshaft-500 bg-bunker-800 font-inter text-gray-400">
<Combobox.Input
onKeyDown={(e) => {
if (activeIndex === 0 && e.key === "Enter") setIsOpen(true);
}}
autoComplete="off"
className="input text-md h-[2.3rem] w-full rounded-md rounded-l-none bg-mineshaft-800 py-[0.375rem] pr-8 pl-2.5 text-gray-400 placeholder-mineshaft-50 placeholder-opacity-50 outline-none duration-200 placeholder:text-sm hover:ring-bunker-400/60 focus:bg-mineshaft-700/80 focus:ring-1 focus:ring-primary-400/50"
placeholder="Search by secret/folder name..."
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{hasSearch && (
<IconButton
isRounded
variant="plain"
onClick={() => onChange("")}
className="absolute right-2 text-primary"
ariaLabel="Clear search"
>
<FontAwesomeIcon icon={faCircleXmark} />
</IconButton>
)}
</div>
</div>
<Transition
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Combobox.Options className="absolute z-30 mt-2 w-full min-w-[220px] overflow-y-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 text-bunker-300 shadow focus:outline-none">
<Combobox.Option
onClick={() => setIsOpen(true)}
value={value}
className={({ active }) =>
`flex w-full cursor-pointer items-start rounded-sm px-4 py-2 font-inter text-sm text-mineshaft-200 outline-none hover:bg-mineshaft-400 ${
active ? "bg-mineshaft-500" : ""
}`
}
>
<FontAwesomeIcon icon={faFolderTree} className="mr-2 mt-1 text-yellow-700" />
{value.trim()
? `Search for "${
value.length > 10 ? `${value.substring(0, 10)}...` : value
}" in all folders`
: "Search in all folders"}
</Combobox.Option>
</Combobox.Options>
</Transition>
</>
)}
</Combobox>
<QuickSearchModal
isSingleEnv={isSingleEnv}
isOpen={isOpen}
onOpenChange={setIsOpen}
initialValue={value}
onClose={() => {
setIsOpen(false);
onChange("");
}}
{...props}
/>
</div>
);
};
@@ -0,0 +1,59 @@
import { useRouter } from "next/router";
import { faChevronRight, faFingerprint, faFolder } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Td, Tooltip, Tr } from "@app/components/v2";
import { reverseTruncate } from "@app/helpers/reverseTruncate";
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
type Props = {
dynamicSecretGroup: TDashboardProjectSecretsQuickSearch["dynamicSecrets"][string];
onClose: () => void;
};
export const QuickSearchDynamicSecretItem = ({
dynamicSecretGroup,
onClose
}: Props) => {
const router = useRouter();
const [groupDynamicSecret] = dynamicSecretGroup;
const handleNavigate = () => {
router.push({
pathname: window.location.pathname,
query: {
secretPath: groupDynamicSecret.path,
search: groupDynamicSecret.name
}
});
onClose();
};
return (
<Tr
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
onClick={handleNavigate}
>
<Td className="w-full">
<div className="inline-flex max-w-[20rem] flex-col">
<span className="truncate">
<FontAwesomeIcon className="mr-2 self-center text-yellow-700" icon={faFingerprint} />
{groupDynamicSecret.name}
</span>
<span className="text-xs text-mineshaft-400">
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
<Tooltip className="max-w-7xl" content={groupDynamicSecret.path}>
<span>{reverseTruncate(groupDynamicSecret.path)}</span>
</Tooltip>
</span>
</div>
</Td>
<Td />
<Td>
<FontAwesomeIcon icon={faChevronRight} />
</Td>
</Tr>
);
};
@@ -0,0 +1,46 @@
import { useRouter } from "next/router";
import { faChevronRight, faFolder } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Td, Tooltip, Tr } from "@app/components/v2";
import { reverseTruncate } from "@app/helpers/reverseTruncate";
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
type Props = {
folderGroup: TDashboardProjectSecretsQuickSearch["folders"][string];
onClose: () => void;
};
export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => {
const router = useRouter();
const [groupFolder] = folderGroup;
const handleNavigate = () => {
router.push({
pathname: window.location.pathname,
query: {
secretPath: groupFolder.path
}
});
onClose();
};
return (
<Tr
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
onClick={handleNavigate}
>
<Td className="w-full whitespace-nowrap">
<FontAwesomeIcon className="text-yellow-700" icon={faFolder} />
<Tooltip content={groupFolder.path} className="max-w-7xl">
<div className="ml-2 inline-block">{reverseTruncate(groupFolder.path)}</div>
</Tooltip>
</Td>
<Td />
<Td>
<FontAwesomeIcon icon={faChevronRight} />
</Td>
</Tr>
);
};
@@ -0,0 +1,285 @@
import { useState } from "react";
import {
faCheckCircle,
faChevronLeft,
faFilter,
faFingerprint,
faFolder,
faKey,
faMagnifyingGlass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState,
IconButton,
Input,
Modal,
ModalContent,
Spinner,
Table,
TableContainer
} from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
import { WsTag } from "@app/hooks/api/tags/types";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
import { RowType } from "@app/views/SecretMainPage/SecretMainPage.types";
import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem";
import { QuickSearchFolderItem } from "./QuickSearchFolderItem";
import { QuickSearchSecretItem } from "./QuickSearchSecretItem";
export type QuickSearchModalProps = {
environments: WorkspaceEnv[];
projectId: string;
tags?: WsTag[];
isSingleEnv?: boolean;
initialValue: string;
onClose: () => void;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder;
const Content = ({
environments,
projectId,
onClose,
tags,
initialValue = "",
isSingleEnv
}: Omit<QuickSearchModalProps, "isOpen" | "onOpenChange">) => {
const [search, setSearch] = useState(initialValue);
const [debouncedSearch] = useDebounce(search);
const [filterTags, setFilterTags] = useState<Record<string, boolean>>({});
const [showFilter, setShowFilter] = useState<Record<ResourceType, boolean>>({
[RowType.Secret]: true,
[RowType.Folder]: true,
[RowType.DynamicSecret]: true
});
const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length);
const { data, isLoading } = useGetProjectSecretsQuickSearch(
{
secretPath: "/",
environments: environments.map((env) => env.slug),
projectId,
search: debouncedSearch,
tags: filterTags
},
{ enabled: isEnabled }
);
const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {};
const isEmpty =
(!showFilter[RowType.Folder] || Object.values(folders).length === 0) &&
(!showFilter[RowType.Secret] || Object.values(secrets).length === 0) &&
(!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0);
const handleToggleTag = (tag: string) => {
setFilterTags((prev) => {
const updated = { ...prev };
if (prev[tag]) delete updated[tag];
else updated[tag] = true;
return updated;
});
};
const handleToggleShowType = (type: ResourceType) => {
setShowFilter((prev) => ({
...prev,
[type]: !prev[type]
}));
};
return (
<div className="min-h-[14.6rem]">
<div className="flex gap-2">
<Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by secret, folder or tag name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={
<FontAwesomeIcon icon={faMagnifyingGlass} className={search ? "text-primary" : ""} />
}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
variant="outline_bg"
ariaLabel="Filter secrets by tag(s)"
className={twMerge(
"transition-all",
(Object.keys(filterTags).length ||
Object.values(showFilter).some((show) => !show)) &&
"border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleShowType(RowType.Folder);
}}
icon={showFilter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>Folders</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleShowType(RowType.DynamicSecret);
}}
icon={showFilter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFingerprint} className=" text-yellow-700" />
<span>Dynamic Secrets</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleShowType(RowType.Secret);
}}
icon={showFilter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faKey} className=" text-bunker-300" />
<span>Secrets</span>
</div>
</DropdownMenuItem>
{tags && tags.length > 0 && (
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="left"
icon={<FontAwesomeIcon icon={faChevronLeft} size="sm" />}
>
Tags
</DropdownSubMenuTrigger>
<DropdownSubMenuContent
collisionPadding={{ right: Infinity }} // forces dropdown to left
className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-r-none"
>
<DropdownMenuLabel>Filter Secrets by Tag(s)</DropdownMenuLabel>
{tags.map(({ id, slug, color }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleToggleTag(slug);
}}
key={id}
icon={filterTags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className=" mt-4 max-h-[19rem] min-h-[19rem] overflow-auto">
{/* eslint-disable-next-line no-nested-ternary */}
{isEnabled ? (
// eslint-disable-next-line no-nested-ternary
isLoading ? (
<Spinner size="lg" className="mx-auto mt-24 text-mineshaft-900" />
) : isEmpty ? (
<EmptyState
className="mt-24"
title="No results match search."
icon={faMagnifyingGlass}
/>
) : (
<TableContainer className="thin-scrollbar h-full overflow-y-auto">
<Table>
{showFilter[RowType.Folder] &&
Object.entries(folders).map(([key, folderGroup]) => (
<QuickSearchFolderItem onClose={onClose} folderGroup={folderGroup} key={key} />
))}
{showFilter[RowType.DynamicSecret] &&
Object.entries(dynamicSecrets).map(([key, dynamicSecretGroup]) => (
<QuickSearchDynamicSecretItem
onClose={onClose}
dynamicSecretGroup={dynamicSecretGroup}
key={key}
/>
))}
{showFilter[RowType.Secret] &&
Object.entries(secrets).map(([key, secretGroup]) => (
<QuickSearchSecretItem
search={debouncedSearch}
tags={Object.keys(filterTags)}
isSingleEnv={isSingleEnv}
environments={environments}
onClose={onClose}
secretGroup={secretGroup}
key={key}
/>
))}
</Table>
</TableContainer>
)
) : (
<EmptyState
className="mt-24"
title="Start typing to begin search..."
icon={faMagnifyingGlass}
/>
)}
</div>
</div>
);
};
export const QuickSearchModal = ({
isOpen,
isSingleEnv,
onOpenChange,
...props
}: QuickSearchModalProps) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title={`Search All Folders${isSingleEnv ? " In Environment" : ""}`}
subTitle={`Search the ${
isSingleEnv ? "current environment" : "entire project"
} to quickly reference secrets and navigate deeply.`}
>
<Content isSingleEnv={isSingleEnv} {...props} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,202 @@
import { useRouter } from "next/router";
import {
faCheck,
faChevronRight,
faCopy,
faEye,
faFolder,
faKey,
faTags
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { reverseTruncate } from "@app/helpers/reverseTruncate";
import { useTimedReset } from "@app/hooks";
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
type Props = {
environments: WorkspaceEnv[];
secretGroup: TDashboardProjectSecretsQuickSearch["secrets"][string];
onClose: () => void;
isSingleEnv?: boolean;
tags: string[];
search: string;
};
export const QuickSearchSecretItem = ({
secretGroup,
environments,
onClose,
tags,
isSingleEnv,
search
}: Props) => {
const router = useRouter();
const envSlugMap = new Map(environments.map((env) => [env.slug, env]));
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
});
const [groupSecret] = secretGroup;
const handleNavigate = () => {
router.push({
pathname: window.location.pathname,
query: {
secretPath: groupSecret.path,
search: groupSecret.key,
tags: tags.length ? tags.join(",") : undefined
}
});
onClose();
};
const handleCopy = (value: string, env: string) => {
navigator.clipboard.writeText(value);
createNotification({
type: "info",
title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`,
text: ""
});
setIsUrlCopied(true);
};
const secretGroupTags = secretGroup.flatMap((secret) => secret.tags);
const tagMatch =
search.trim() &&
secretGroupTags?.find((tag) => tag && tag.slug.toLowerCase().includes(search.toLowerCase()));
return (
<Tr
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
onClick={handleNavigate}
>
<Td className="w-full">
<div className="inline-flex max-w-[20rem] flex-col">
<span className="truncate">
<FontAwesomeIcon className="mr-2 self-center text-bunker-300" icon={faKey} />
{groupSecret.key}
</span>
<span className="text-xs text-mineshaft-400">
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
<Tooltip className="max-w-7xl" content={groupSecret.path}>
<span>{reverseTruncate(groupSecret.path ?? "")}</span>
</Tooltip>
</span>
</div>
</Td>
<Td>
<div className="flex w-full items-center justify-end gap-4">
{tagMatch && (
<Badge variant="primary" className="flex items-center gap-1 whitespace-nowrap">
<FontAwesomeIcon size="xs" icon={faTags} />
{tagMatch.slug}
</Badge>
)}
{isSingleEnv ? (
<IconButton
size="md"
variant="plain"
colorSchema="secondary"
ariaLabel="Copy secret value"
onClick={(e) => {
e.stopPropagation();
handleCopy(groupSecret.value!, envSlugMap.get(groupSecret.env)?.name!);
}}
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
size="md"
variant="plain"
colorSchema="secondary"
ariaLabel="Copy secret value"
>
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Copy Value From...</DropdownMenuLabel>
{secretGroup.map((secret) => (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleCopy(secret.value!, envSlugMap.get(secret.env)?.name!);
}}
key={secret.id}
>
<p className="text-sm">{envSlugMap.get(secret.env)?.name}</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
size="md"
variant="plain"
colorSchema="secondary"
ariaLabel="View secret value"
>
<FontAwesomeIcon icon={faEye} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Hover to Reveal...</DropdownMenuLabel>
{secretGroup.map((secret) => (
<DropdownMenuItem
className="group "
onClick={(e) => {
e.stopPropagation();
handleCopy(secret.value!, envSlugMap.get(secret.env)?.name!);
}}
key={secret.id}
>
{!isSingleEnv && (
<span className="text-xs text-mineshaft-400">
{envSlugMap.get(secret.env)?.name}
</span>
)}
<p
className={twMerge(
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
!secret.value && "text-mineshaft-400"
)}
>
{secret.value || "EMPTY"}
</p>
<p className="w-[12rem] text-sm group-hover:hidden">
***************************
</p>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
<Td>
<FontAwesomeIcon icon={faChevronRight} />
</Td>
</Tr>
);
};
@@ -0,0 +1,4 @@
export * from "./QuickSearchDynamicSecretItem";
export * from "./QuickSearchFolderItem";
export * from "./QuickSearchModal";
export * from "./QuickSearchSecretItem";
@@ -0,0 +1 @@
export { SecretSearchInput } from "./SecretSearchInput";