mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 16:27:46 +00:00
feature: deep search for secrets dashboard
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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` });
|
||||||
|
|||||||
+3
-3
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const reverseTruncate = (text: string, maxLength = 42) => {
|
||||||
|
if (text.length < maxLength) return text;
|
||||||
|
|
||||||
|
return `...${text.substring(text.length - maxLength + 3)}`;
|
||||||
|
};
|
||||||
@@ -1 +1,5 @@
|
|||||||
export { useGetProjectSecretsDetails } from "./queries";
|
export {
|
||||||
|
useGetProjectSecretsDetails,
|
||||||
|
useGetProjectSecretsOverview,
|
||||||
|
useGetProjectSecretsQuickSearch
|
||||||
|
} from "./queries";
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+113
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+59
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+46
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+285
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+202
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./QuickSearchDynamicSecretItem";
|
||||||
|
export * from "./QuickSearchFolderItem";
|
||||||
|
export * from "./QuickSearchModal";
|
||||||
|
export * from "./QuickSearchSecretItem";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SecretSearchInput } from "./SecretSearchInput";
|
||||||
Reference in New Issue
Block a user