feature: server-side pagination/filtering/sorting for secrets overview and main pages

This commit is contained in:
Scott Wilson
2024-09-18 21:17:48 -07:00
parent d6b7045461
commit d0656358a2
43 changed files with 2899 additions and 721 deletions
@@ -1,10 +1,67 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory>; export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory>;
export const dynamicSecretDALFactory = (db: TDbClient) => { export const dynamicSecretDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.DynamicSecret); const orm = ormify(db, TableName.DynamicSecret);
return orm;
// find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination)
const findMultiEnv = async (
{
folderIds,
search,
limit,
offset = 0,
orderBy = SecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC
}: {
folderIds: string[];
search?: string;
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
},
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
.whereIn("folderId", folderIds)
.where((bd) => {
if (search) {
void bd.whereILike("name", `%${search}%`);
}
})
.select(
selectAllTableCols(TableName.DynamicSecret),
db.raw(`DENSE_RANK() OVER (ORDER BY "name" ${orderDirection ?? OrderByDirection.ASC}) as rank`)
)
.orderBy(orderBy, orderDirection);
if (limit) {
const rankOffset = offset + 1;
return await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit);
}
const dynamicSecrets = await query;
return dynamicSecrets;
} catch (error) {
throw new DatabaseError({ error, name: "List dynamic secret multi env" });
}
};
return { ...orm, findMultiEnv };
}; };
@@ -6,6 +6,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 { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } 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";
@@ -31,7 +32,7 @@ type TDynamicSecretServiceFactoryDep = {
"pruneDynamicSecret" | "unsetLeaseRevocation" "pruneDynamicSecret" | "unsetLeaseRevocation"
>; >;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">; folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
}; };
@@ -300,19 +301,75 @@ export const dynamicSecretServiceFactory = ({
return { ...dynamicSecretCfg, inputs: providerInputs }; return { ...dynamicSecretCfg, inputs: providerInputs };
}; };
const list = async ({ // get unique dynamic secret count across multiple envs
const getCountMultiEnv = async ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actorId, actorId,
actor, actor,
projectSlug, projectSlug,
path, path,
environmentSlug environmentSlugs,
search,
...params
}: Omit<TListDynamicSecretsDTO, "environmentSlug"> & { environmentSlugs: string[] }) => {
let { projectId } = params;
if (!projectId) {
if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" });
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
projectId = project.id;
}
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
if (!folders.length) throw new BadRequestError({ message: "Folders not found" });
const dynamicSecretCfg = await dynamicSecretDAL.find(
{ $in: { folderId: folders.map((folder) => folder.id) }, $search: search ? { name: `%${search}%` } : undefined },
{ countDistinct: "name" }
);
return Number(dynamicSecretCfg[0]?.count ?? 0);
};
// get dynamic secret count for a single env
const getCount = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
projectSlug,
path,
environmentSlug,
search,
...params
}: TListDynamicSecretsDTO) => { }: TListDynamicSecretsDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); let { projectId } = params;
if (!project) throw new BadRequestError({ message: "Project not found" });
if (!projectId) {
if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" });
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
projectId = project.id;
}
const projectId = project.id;
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
actorId, actorId,
@@ -328,15 +385,126 @@ export const dynamicSecretServiceFactory = ({
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" }); if (!folder) throw new BadRequestError({ message: "Folder not found" });
const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id }); const dynamicSecretCfg = await dynamicSecretDAL.find(
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
{ count: true }
);
return Number(dynamicSecretCfg[0]?.count ?? 0);
};
const list = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
projectSlug,
path,
environmentSlug,
limit,
offset,
orderBy,
orderDirection = OrderByDirection.ASC,
search,
...params
}: TListDynamicSecretsDTO) => {
let { projectId } = params;
if (!projectId) {
if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" });
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
projectId = project.id;
}
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
if (!folder) throw new BadRequestError({ message: "Folder not found" });
const dynamicSecretCfg = await dynamicSecretDAL.find(
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
{
limit,
offset,
sort: orderBy ? [[orderBy, orderDirection]] : undefined
}
);
return dynamicSecretCfg; return dynamicSecretCfg;
}; };
// get dynamic secrets for multiple envs
const listMultiEnv = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
projectSlug,
path,
environmentSlugs,
...params
}: Omit<TListDynamicSecretsDTO, "environmentSlug"> & { environmentSlugs: string[] }) => {
let { projectId } = params;
if (!projectId) {
if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" });
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
projectId = project.id;
}
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
if (!folders.length) throw new BadRequestError({ message: "Folders not found" });
const dynamicSecretCfg = await dynamicSecretDAL.findMultiEnv({
folderIds: folders.map((folder) => folder.id),
...params
});
// map dynamic secrets to their respective env
const folderEnvMap: Map<string, string> = new Map(folders.map((folder) => [folder.id, folder.environment.slug]));
const data: { [key: string]: Array<(typeof dynamicSecretCfg)[number]> } = {};
dynamicSecretCfg.forEach((secret) => {
const slug = folderEnvMap.get(secret.folderId);
data[slug!] = [...(data[slug!] ?? []), secret];
});
return data;
};
return { return {
create, create,
updateByName, updateByName,
deleteByName, deleteByName,
getDetails, getDetails,
list list,
listMultiEnv,
getCount,
getCountMultiEnv
}; };
}; };
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { DynamicSecretProviderSchema } from "./providers/models"; import { DynamicSecretProviderSchema } from "./providers/models";
@@ -50,5 +51,11 @@ export type TDetailsDynamicSecretDTO = {
export type TListDynamicSecretsDTO = { export type TListDynamicSecretsDTO = {
path: string; path: string;
environmentSlug: string; environmentSlug: string;
projectSlug: string; projectSlug?: string;
projectId?: string;
offset?: number;
limit?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
+32
View File
@@ -697,6 +697,38 @@ export const SECRET_IMPORTS = {
} }
} as const; } as const;
export const DASHBOARD = {
SECRET_OVERVIEW_LIST: {
projectId: "The ID of the project to list secrets/folders from.",
environments:
"The slugs of the environments to list secrets/folders from (comma separated, ie 'environments=dev,staging,prod').",
secretPath: "The secret path to list secrets/folders from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.",
limit: "The number of secrets/folders to return.",
orderBy: "The column to order secrets/folders by.",
orderDirection: "The direction to order secrets/folders in.",
search: "The text string to filter secret keys and folder names by.",
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
},
SECRET_DETAILS_LIST: {
projectId: "The ID of the project to list secrets/folders from.",
environment: "The slug of the environment to list secrets/folders from.",
secretPath: "The secret path to list secrets/folders from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.",
limit: "The number of secrets/folders to return.",
orderBy: "The column to order secrets/folders by.",
orderDirection: "The direction to order secrets/folders in.",
search: "The text string to filter secret keys and folder names by.",
tags: "The tags to filter secrets by (comma separated, ie 'tags=billing,engineering').",
includeSecrets: "Whether to include project secrets in the response.",
includeFolders: "Whether to include project folders in the response.",
includeImports: "Whether to include project secret imports in the response.",
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
}
} as const;
export const AUDIT_LOGS = { export const AUDIT_LOGS = {
EXPORT: { EXPORT: {
workspaceId: "The ID of the project to export audit logs from.", workspaceId: "The ID of the project to export audit logs from.",
+17 -5
View File
@@ -51,11 +51,17 @@ export type TFindReturn<TQuery extends Knex.QueryBuilder, TCount extends boolean
: unknown) : unknown)
>; >;
export type TFindOpt<R extends object = object, TCount extends boolean = boolean> = { export type TFindOpt<
R extends object = object,
TCount extends boolean = boolean,
TCountDistinct extends keyof R | undefined = undefined
> = {
limit?: number; limit?: number;
offset?: number; offset?: number;
sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>; sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>;
groupBy?: keyof R;
count?: TCount; count?: TCount;
countDistinct?: TCountDistinct;
tx?: Knex; tx?: Knex;
}; };
@@ -86,13 +92,18 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
throw new DatabaseError({ error, name: "Find one" }); throw new DatabaseError({ error, name: "Find one" });
} }
}, },
find: async <TCount extends boolean = false>( find: async <
TCount extends boolean = false,
TCountDistinct extends keyof Tables[Tname]["base"] | undefined = undefined
>(
filter: TFindFilter<Tables[Tname]["base"]>, filter: TFindFilter<Tables[Tname]["base"]>,
{ offset, limit, sort, count, tx }: TFindOpt<Tables[Tname]["base"], TCount> = {} { offset, limit, sort, count, tx, countDistinct }: TFindOpt<Tables[Tname]["base"], TCount, TCountDistinct> = {}
) => { ) => {
try { try {
const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter)); const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter));
if (count) { if (countDistinct) {
void query.countDistinct(countDistinct);
} else if (count) {
void query.select(db.raw("COUNT(*) OVER() AS count")); void query.select(db.raw("COUNT(*) OVER() AS count"));
void query.select("*"); void query.select("*");
} }
@@ -101,7 +112,8 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
if (sort) { if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
} }
const res = (await query) as TFindReturn<typeof query, TCount>;
const res = (await query) as TFindReturn<typeof query, TCountDistinct extends undefined ? TCount : true>;
return res; return res;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find one" }); throw new DatabaseError({ error, name: "Find one" });
@@ -0,0 +1,620 @@
import { z } from "zod";
import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { OrderByDirection } from "@app/lib/types";
import { secretsLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { getUserAgentType } from "@app/server/plugins/audit-log";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedDynamicSecretSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerDashboardRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/secrets-overview",
config: {
rateLimit: secretsLimit
},
schema: {
description: "List project secrets overview",
security: [
{
bearerAuth: []
}
],
querystring: z.object({
projectId: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.projectId),
environments: z
.string()
.trim()
.transform(decodeURIComponent)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.environments),
secretPath: z
.string()
.trim()
.default("/")
.transform(removeTrailingSlash)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.secretPath),
offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_OVERVIEW_LIST.offset),
limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_OVERVIEW_LIST.limit),
orderBy: z
.nativeEnum(SecretsOrderBy)
.default(SecretsOrderBy.Name)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderDirection)
.optional(),
search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(),
includeSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets),
includeFolders: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders),
includeDynamicSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets)
}),
response: {
200: z.object({
folders: z.record(z.string(), SecretFoldersSchema.array()).optional(),
dynamicSecrets: z.record(z.string(), SanitizedDynamicSecretSchema.array()).optional(),
secrets: z
.record(
z.string(),
secretRawSchema
.extend({
secretPath: z.string().optional(),
tags: SecretTagsSchema.pick({
id: true,
slug: true,
color: true
})
.extend({ name: z.string() })
.array()
.optional()
})
.array()
)
.optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const {
secretPath,
projectId,
limit,
offset,
search,
orderBy,
orderDirection,
includeFolders,
includeSecrets,
includeDynamicSecrets
} = req.query;
const environments = req.query.environments.split(",");
if (!projectId || environments.length === 0)
throw new BadRequestError({ message: "Missing workspace id or environment(s)" });
const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId);
// prevent older projects from accessing endpoint
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
let remainingLimit = limit;
let adjustedOffset = offset;
let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined;
let dynamicSecrets: Awaited<ReturnType<typeof server.services.dynamicSecret.listMultiEnv>> | undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
if (includeFolders) {
// this is the unique count, ie duplicate folders across envs only count as 1
totalFolderCount = await server.services.folder.getProjectFolderCount({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.projectId,
path: secretPath,
environments,
search
});
if (remainingLimit > 0 && totalFolderCount > adjustedOffset) {
folders = await server.services.folder.getFoldersMultiEnv({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environments,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset
});
// get the count of unique folder names to properly adjust remaining limit
const uniqueFolderCount = new Set(
Object.values(folders).flatMap((folderGroup) => folderGroup.flatMap((folder) => folder.name))
).size;
remainingLimit -= uniqueFolderCount;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount);
}
}
if (includeDynamicSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
environmentSlugs: environments,
path: secretPath
});
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
dynamicSecrets = await server.services.dynamicSecret.listMultiEnv({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
orderBy,
orderDirection,
environmentSlugs: environments,
path: secretPath,
limit: remainingLimit,
offset: adjustedOffset
});
// get the count of unique dynamic secret names to properly adjust remaining limit
const uniqueDynamicSecretsCount = new Set(
Object.values(dynamicSecrets).flatMap((dynamicSecretGroup) =>
dynamicSecretGroup.flatMap((dynamicSecret) => dynamicSecret.name)
)
).size;
remainingLimit -= uniqueDynamicSecretsCount;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
}
}
if (includeSecrets) {
// this is the unique count, ie duplicate secrets across envs only count as 1
totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environments,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
search
});
if (remainingLimit > 0 && totalSecretCount > adjustedOffset) {
secrets = await server.services.secret.getSecretsRawMultiEnv({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environments,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset
});
for await (const environment of environments) {
const secretCountFromEnv = secrets[environment]?.length;
if (secretCountFromEnv) {
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secretCountFromEnv
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secretCountFromEnv,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
}
}
return {
folders,
dynamicSecrets,
secrets,
totalFolderCount,
totalDynamicSecretCount,
totalSecretCount,
totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
};
}
});
server.route({
method: "GET",
url: "/secrets-details",
config: {
rateLimit: secretsLimit
},
schema: {
description: "List project secrets details",
security: [
{
bearerAuth: []
}
],
querystring: z.object({
projectId: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.projectId),
environment: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.environment),
secretPath: z
.string()
.trim()
.default("/")
.transform(removeTrailingSlash)
.describe(DASHBOARD.SECRET_DETAILS_LIST.secretPath),
offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_DETAILS_LIST.offset),
limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_DETAILS_LIST.limit),
orderBy: z
.nativeEnum(SecretsOrderBy)
.default(SecretsOrderBy.Name)
.describe(DASHBOARD.SECRET_DETAILS_LIST.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(DASHBOARD.SECRET_DETAILS_LIST.orderDirection)
.optional(),
search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(),
tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(),
includeSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets),
includeFolders: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders),
includeDynamicSecrets: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets),
includeImports: z.coerce
.boolean()
.optional()
.default(true)
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports)
}),
response: {
200: z.object({
imports: SecretImportsSchema.omit({ importEnv: true })
.extend({
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
})
.array()
.optional(),
folders: SecretFoldersSchema.array().optional(),
dynamicSecrets: SanitizedDynamicSecretSchema.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(),
totalImportCount: z.number().optional(),
totalFolderCount: z.number().optional(),
totalDynamicSecretCount: z.number().optional(),
totalSecretCount: z.number().optional(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const {
secretPath,
environment,
projectId,
limit,
offset,
search,
orderBy,
orderDirection,
includeFolders,
includeSecrets,
includeDynamicSecrets,
includeImports
} = req.query;
if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" });
const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId);
// prevent older projects from accessing endpoint
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
const tags = req.query.tags?.split(",") ?? [];
let remainingLimit = limit;
let adjustedOffset = offset;
let imports: Awaited<ReturnType<typeof server.services.secretImport.getImports>> | undefined;
let folders: Awaited<ReturnType<typeof server.services.folder.getFolders>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRaw>>["secrets"] | undefined;
let dynamicSecrets: Awaited<ReturnType<typeof server.services.dynamicSecret.list>> | undefined;
let totalImportCount: number | undefined;
let totalFolderCount: number | undefined;
let totalDynamicSecretCount: number | undefined;
let totalSecretCount: number | undefined;
if (includeImports) {
totalImportCount = await server.services.secretImport.getProjectImportCount({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environment,
path: secretPath,
search
});
if (remainingLimit > 0 && totalImportCount > adjustedOffset) {
imports = await server.services.secretImport.getImports({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environment,
path: secretPath,
search,
limit: remainingLimit,
offset: adjustedOffset
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
type: EventType.GET_SECRET_IMPORTS,
metadata: {
environment,
folderId: imports?.[0]?.folderId,
numberOfImports: imports.length
}
}
});
remainingLimit -= imports.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalImportCount);
}
}
if (includeFolders) {
totalFolderCount = await server.services.folder.getProjectFolderCount({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
path: secretPath,
environments: [environment],
search
});
if (remainingLimit > 0 && totalFolderCount > adjustedOffset) {
folders = await server.services.folder.getFolders({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
environment,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset
});
remainingLimit -= folders.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount);
}
}
if (includeDynamicSecrets) {
totalDynamicSecretCount = await server.services.dynamicSecret.getCount({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
environmentSlug: environment,
path: secretPath
});
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
dynamicSecrets = await server.services.dynamicSecret.list({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId,
search,
orderBy,
orderDirection,
environmentSlug: environment,
path: secretPath,
limit: remainingLimit,
offset: adjustedOffset
});
remainingLimit -= dynamicSecrets.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
}
}
if (includeSecrets) {
totalSecretCount = await server.services.secret.getSecretsCount({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environment,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
search,
tagSlugs: tags
});
if (remainingLimit > 0 && totalSecretCount > adjustedOffset) {
const secretsRaw = await server.services.secret.getSecretsRaw({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
environment,
actorAuthMethod: req.permission.authMethod,
projectId,
path: secretPath,
orderBy,
orderDirection,
search,
limit: remainingLimit,
offset: adjustedOffset,
tagSlugs: tags
});
secrets = secretsRaw.secrets;
await server.services.auditLog.createAuditLog({
projectId,
...req.auditLogInfo,
event: {
type: EventType.GET_SECRETS,
metadata: {
environment,
secretPath,
numberOfSecrets: secrets.length
}
}
});
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.SecretPulled,
distinctId: getTelemetryDistinctId(req),
properties: {
numberOfSecrets: secrets.length,
workspaceId: projectId,
environment,
secretPath,
channel: getUserAgentType(req.headers["user-agent"]),
...req.auditLogInfo
}
});
}
}
}
return {
imports,
folders,
dynamicSecrets,
secrets,
totalImportCount,
totalFolderCount,
totalDynamicSecretCount,
totalSecretCount,
totalCount:
(totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
};
}
});
};
+2
View File
@@ -1,3 +1,4 @@
import { registerDashboardRouter } from "./dashboard-router";
import { registerLoginRouter } from "./login-router"; import { registerLoginRouter } from "./login-router";
import { registerSecretBlindIndexRouter } from "./secret-blind-index-router"; import { registerSecretBlindIndexRouter } from "./secret-blind-index-router";
import { registerSecretRouter } from "./secret-router"; import { registerSecretRouter } from "./secret-router";
@@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretRouter, { prefix: "/secrets" });
await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" }); await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" });
await server.register(registerDashboardRouter, { prefix: "/dashboard" });
}; };
@@ -5,6 +5,8 @@ import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate }
import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { groupBy, removeTrailingSlash } from "@app/lib/fn"; import { groupBy, removeTrailingSlash } from "@app/lib/fn";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export const validateFolderName = (folderName: string) => { export const validateFolderName = (folderName: string) => {
const validNameRegex = /^[a-zA-Z0-9-_]+$/; const validNameRegex = /^[a-zA-Z0-9-_]+$/;
@@ -83,7 +85,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str
.from<TSecretFolders & { depth: number; path: string }>("parent"); .from<TSecretFolders & { depth: number; path: string }>("parent");
}; };
const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: string, secretPath: string) => { const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: string[], secretPath: string) => {
// this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2 // this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2
const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath; const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath;
// next goal to sanitize saw the raw sql query is safe // next goal to sanitize saw the raw sql query is safe
@@ -111,7 +113,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
projectId, projectId,
parentId: null parentId: null
}) })
.where(`${TableName.Environment}.slug`, environment) .whereIn(`${TableName.Environment}.slug`, environments)
.select(selectAllTableCols(TableName.SecretFolder)) .select(selectAllTableCols(TableName.SecretFolder))
.union( .union(
(qb) => (qb) =>
@@ -139,14 +141,14 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
.from<TSecretFolders & { depth: number; path: string }>("parent") .from<TSecretFolders & { depth: number; path: string }>("parent")
.leftJoin<TProjectEnvironments>(TableName.Environment, `${TableName.Environment}.id`, "parent.envId") .leftJoin<TProjectEnvironments>(TableName.Environment, `${TableName.Environment}.id`, "parent.envId")
.select< .select<
TSecretFolders & { (TSecretFolders & {
depth: number; depth: number;
path: string; path: string;
envId: string; envId: string;
envSlug: string; envSlug: string;
envName: string; envName: string;
projectId: string; projectId: string;
} })[]
>( >(
selectAllTableCols("parent" as TableName.SecretFolder), selectAllTableCols("parent" as TableName.SecretFolder),
db.ref("id").withSchema(TableName.Environment).as("envId"), db.ref("id").withSchema(TableName.Environment).as("envId"),
@@ -214,7 +216,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
const folder = await sqlFindFolderByPathQuery( const folder = await sqlFindFolderByPathQuery(
tx || db.replicaNode(), tx || db.replicaNode(),
projectId, projectId,
environment, [environment],
removeTrailingSlash(path) removeTrailingSlash(path)
) )
.orderBy("depth", "desc") .orderBy("depth", "desc")
@@ -230,6 +232,35 @@ export const secretFolderDALFactory = (db: TDbClient) => {
} }
}; };
// finds folders by path for multiple envs
const findBySecretPathMultiEnv = async (projectId: string, environments: string[], path: string, tx?: Knex) => {
try {
const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1;
const folders = await sqlFindFolderByPathQuery(
tx || db.replicaNode(),
projectId,
environments,
removeTrailingSlash(path)
)
.orderBy("depth", "desc")
.where("depth", pathDepth);
const firstFolder = folders[0];
if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) {
return [];
}
return folders.map((folder) => {
const { envId: id, envName: name, envSlug: slug, ...el } = folder;
return { ...el, envId: id, environment: { id, name, slug } };
});
} catch (error) {
throw new DatabaseError({ error, name: "Find folders by secret path multi env" });
}
};
// used in folder creation // used in folder creation
// even if its the original given /path1/path2 // even if its the original given /path1/path2
// it will stop automatically at /path2 // it will stop automatically at /path2
@@ -238,7 +269,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
const folder = await sqlFindFolderByPathQuery( const folder = await sqlFindFolderByPathQuery(
tx || db.replicaNode(), tx || db.replicaNode(),
projectId, projectId,
environment, [environment],
removeTrailingSlash(path) removeTrailingSlash(path)
) )
.orderBy("depth", "desc") .orderBy("depth", "desc")
@@ -352,14 +383,71 @@ export const secretFolderDALFactory = (db: TDbClient) => {
} }
}; };
// find project folders for multiple envs
const findByMultiEnv = async (
{
environmentIds,
parentIds,
search,
limit,
offset = 0,
orderBy = SecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC
}: {
environmentIds: string[];
parentIds: string[];
search?: string;
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
},
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.SecretFolder)
.whereIn("parentId", parentIds)
.whereIn("envId", environmentIds)
.where("isReserved", false)
.where((bd) => {
if (search) {
void bd.whereILike("name", `%${search}%`);
}
})
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw(`DENSE_RANK() OVER (ORDER BY "name" ${orderDirection ?? OrderByDirection.ASC}) as rank`)
)
.orderBy(orderBy, orderDirection);
if (limit) {
const rankOffset = offset + 1; // ranks start from 1
return await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit);
}
const folders = await query;
return folders;
} catch (error) {
throw new DatabaseError({ error, name: "Find folders multi env" });
}
};
return { return {
...secretFolderOrm, ...secretFolderOrm,
update, update,
findBySecretPath, findBySecretPath,
findBySecretPathMultiEnv,
findById, findById,
findByManySecretPath, findByManySecretPath,
findSecretPathByFolderIds, findSecretPathByFolderIds,
findClosestFolder, findClosestFolder,
findByProjectId findByProjectId,
findByMultiEnv
}; };
}; };
@@ -2,11 +2,12 @@ import { ForbiddenError, subject } from "@casl/ability";
import path from "path"; import path from "path";
import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { v4 as uuidv4, validate as uuidValidate } from "uuid";
import { TSecretFoldersInsert } from "@app/db/schemas"; import { TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
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 { 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";
@@ -26,7 +27,7 @@ type TSecretFolderServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">; snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory; folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
folderVersionDAL: TSecretFolderVersionDALFactory; folderVersionDAL: TSecretFolderVersionDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
}; };
@@ -396,7 +397,12 @@ export const secretFolderServiceFactory = ({
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
environment, environment,
path: secretPath path: secretPath,
search,
orderBy,
orderDirection,
limit,
offset
}: TGetFolderDTO) => { }: TGetFolderDTO) => {
// folder list is allowed to be read by anyone // folder list is allowed to be read by anyone
// permission to check does user has access // permission to check does user has access
@@ -408,11 +414,100 @@ export const secretFolderServiceFactory = ({
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!parentFolder) return []; if (!parentFolder) return [];
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false }); const folders = await folderDAL.find(
{
envId: env.id,
parentId: parentFolder.id,
isReserved: false,
$search: search ? { name: `%${search}%` } : undefined
},
{
sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined,
limit,
offset
}
);
return folders; return folders;
}; };
// get folders for multiple envs
const getFoldersMultiEnv = async ({
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
path: secretPath,
...params
}: Omit<TGetFolderDTO, "environment"> & { environments: string[] }) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
const data: { [key: string]: TSecretFolders[] } = {};
if (!envs.length)
throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" });
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
if (!parentFolders.length) return data;
const folders = await folderDAL.findByMultiEnv({
environmentIds: envs.map((env) => env.id),
parentIds: parentFolders.map((folder) => folder.id),
...params
});
// reorganize folders into respective envs
const envMap: Map<string, string> = new Map(envs.map((env) => [env.id, env.slug]));
folders.forEach((folder) => {
const slug = envMap.get(folder.envId);
data[slug!] = [...(data[slug!] ?? []), folder];
});
return data;
};
// get the unique count of folders within a project path
const getProjectFolderCount = async ({
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
path: secretPath,
search
}: Omit<TGetFolderDTO, "environment"> & { environments: string[] }) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
if (!envs.length)
throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" });
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
if (!parentFolders.length) return 0;
const folders = await folderDAL.find(
{
$in: {
envId: envs.map((env) => env.id),
parentId: parentFolders.map((folder) => folder.id)
},
isReserved: false,
$search: search ? { name: `%${search}%` } : undefined
},
{ countDistinct: "name" }
);
return Number(folders[0]?.count ?? 0);
};
const getFolderById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id }: TGetFolderByIdDTO) => { const getFolderById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id }: TGetFolderByIdDTO) => {
const folder = await folderDAL.findById(id); const folder = await folderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: "folder not found" }); if (!folder) throw new NotFoundError({ message: "folder not found" });
@@ -429,6 +524,8 @@ export const secretFolderServiceFactory = ({
updateManyFolders, updateManyFolders,
deleteFolder, deleteFolder,
getFolders, getFolders,
getFolderById getFolderById,
getProjectFolderCount,
getFoldersMultiEnv
}; };
}; };
@@ -1,4 +1,5 @@
import { TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export enum ReservedFolders { export enum ReservedFolders {
SecretReplication = "__reserve_replication_" SecretReplication = "__reserve_replication_"
@@ -36,6 +37,11 @@ export type TDeleteFolderDTO = {
export type TGetFolderDTO = { export type TGetFolderDTO = {
environment: string; environment: string;
path: string; path: string;
search?: string;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
limit?: number;
offset?: number;
} & TProjectPermission; } & TProjectPermission;
export type TGetFolderByIdDTO = { export type TGetFolderByIdDTO = {
@@ -49,10 +49,30 @@ export const secretImportDALFactory = (db: TDbClient) => {
} }
}; };
const find = async (filter: Partial<TSecretImports & { projectId: string }>, tx?: Knex) => { const find = async (
{
search,
limit,
offset,
...filter
}: Partial<
TSecretImports & {
projectId: string;
search?: string;
limit?: number;
offset?: number;
}
>,
tx?: Knex
) => {
try { try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport) const query = (tx || db.replicaNode())(TableName.SecretImport)
.where(filter) .where(filter)
.where((bd) => {
if (search) {
void bd.whereILike("importPath", `%${search}%`);
}
})
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
.select( .select(
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
@@ -61,6 +81,13 @@ export const secretImportDALFactory = (db: TDbClient) => {
db.ref("id").withSchema(TableName.Environment).as("envId") db.ref("id").withSchema(TableName.Environment).as("envId")
) )
.orderBy("position", "asc"); .orderBy("position", "asc");
if (limit) {
void query.limit(limit).offset(offset ?? 0);
}
const docs = await query;
return docs.map(({ envId, slug, name, ...el }) => ({ return docs.map(({ envId, slug, name, ...el }) => ({
...el, ...el,
importEnv: { id: envId, slug, name } importEnv: { id: envId, slug, name }
@@ -70,6 +97,27 @@ export const secretImportDALFactory = (db: TDbClient) => {
} }
}; };
const getProjectImportCount = async (
{ search, ...filter }: Partial<TSecretImports & { projectId: string; search?: string }>,
tx?: Knex
) => {
try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
.where(filter)
.where((bd) => {
if (search) {
void bd.whereILike("importPath", `%${search}%`);
}
})
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
.count();
return Number(docs[0]?.count ?? 0);
} catch (error) {
throw new DatabaseError({ error, name: "get secret imports count" });
}
};
const findByFolderIds = async (folderIds: string[], tx?: Knex) => { const findByFolderIds = async (folderIds: string[], tx?: Knex) => {
try { try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport) const docs = await (tx || db.replicaNode())(TableName.SecretImport)
@@ -97,6 +145,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
find, find,
findByFolderIds, findByFolderIds,
findLastImportPosition, findLastImportPosition,
updateAllPosition updateAllPosition,
getProjectImportCount
}; };
}; };
@@ -394,14 +394,15 @@ export const secretImportServiceFactory = ({
return { message: "replication started" }; return { message: "replication started" };
}; };
const getImports = async ({ const getProjectImportCount = async ({
path: secretPath, path: secretPath,
environment, environment,
projectId, projectId,
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId,
search
}: TGetSecretImportsDTO) => { }: TGetSecretImportsDTO) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
@@ -418,7 +419,39 @@ export const secretImportServiceFactory = ({
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" }); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
const secImports = await secretImportDAL.find({ folderId: folder.id }); const count = await secretImportDAL.getProjectImportCount({ folderId: folder.id, search });
return count;
};
const getImports = async ({
path: secretPath,
environment,
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
search,
limit,
offset
}: TGetSecretImportsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
const secImports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset });
return secImports; return secImports;
}; };
@@ -531,6 +564,7 @@ export const secretImportServiceFactory = ({
getSecretsFromImports, getSecretsFromImports,
getRawSecretsFromImports, getRawSecretsFromImports,
resyncSecretImportReplication, resyncSecretImportReplication,
getProjectImportCount,
fnSecretsFromImports fnSecretsFromImports
}; };
}; };
@@ -32,6 +32,9 @@ export type TDeleteSecretImportDTO = {
export type TGetSecretImportsDTO = { export type TGetSecretImportsDTO = {
environment: string; environment: string;
path: string; path: string;
search?: string;
limit?: number;
offset?: number;
} & TProjectPermission; } & TProjectPermission;
export type TGetSecretsFromImportDTO = { export type TGetSecretsFromImportDTO = {
@@ -5,6 +5,8 @@ import { TDbClient } from "@app/db";
import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>; export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>;
@@ -181,7 +183,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
} }
}; };
const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => { // get unique secret count by folder IDs
const countByFolderIds = async (
folderIds: string[],
userId?: string,
tx?: Knex,
filters?: {
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)
if (userId && !uuidValidate(userId)) { if (userId && !uuidValidate(userId)) {
@@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
userId = undefined; userId = undefined;
} }
const secs = await (tx || db.replicaNode())(TableName.SecretV2) const query = (tx || db.replicaNode())(TableName.SecretV2)
.whereIn("folderId", folderIds) .whereIn("folderId", folderIds)
.where((bd) => {
if (filters?.search) {
void bd.whereILike("key", `%${filters?.search}%`);
}
})
.where((bd) => {
void bd.whereNull("userId").orWhere({ userId: userId || null });
})
.countDistinct("key");
// only need to join tags if filtering by tag slugs
const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) {
void query
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.whereIn("slug", slugs);
}
const secrets = await query;
return Number(secrets[0]?.count ?? 0);
} catch (error) {
throw new DatabaseError({ error, name: "get folder secret count" });
}
};
const findByFolderIds = async (
folderIds: string[],
userId?: string,
tx?: Knex,
filters?: {
limit?: number;
offset?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
tagSlugs?: string[];
}
) => {
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)
if (userId && !uuidValidate(userId)) {
// eslint-disable-next-line no-param-reassign
userId = undefined;
}
const query = (tx || db.replicaNode())(TableName.SecretV2)
.whereIn("folderId", folderIds)
.where((bd) => {
if (filters?.search) {
void bd.whereILike("key", `%${filters?.search}%`);
}
})
.where((bd) => { .where((bd) => {
void bd.whereNull("userId").orWhere({ userId: userId || null }); void bd.whereNull("userId").orWhere({ userId: userId || null });
}) })
@@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id` `${TableName.SecretTag}.id`
) )
.select(selectAllTableCols(TableName.SecretV2)) .select(
selectAllTableCols(TableName.SecretV2),
db.raw(`DENSE_RANK() OVER (ORDER BY "key" ${filters?.orderDirection ?? OrderByDirection.ASC}) as rank`)
)
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.orderBy("id", "asc"); .where((bd) => {
const slugs = filters?.tagSlugs?.filter(Boolean);
if (slugs && slugs.length > 0) {
void bd.whereIn("slug", slugs);
}
})
.orderBy(
filters?.orderBy === SecretsOrderBy.Name ? "key" : "id",
filters?.orderDirection ?? OrderByDirection.ASC
);
let secs: Awaited<typeof query>;
if (filters?.limit) {
const rankOffset = (filters?.offset ?? 0) + 1; // ranks start at 1
secs = await (tx || db)
.with("w", query)
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + filters.limit);
} else {
secs = await query;
}
const data = sqlNestRelationships({ const data = sqlNestRelationships({
data: secs, data: secs,
@@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
findBySecretKeys, findBySecretKeys,
upsertSecretReferences, upsertSecretReferences,
findReferencedSecretReferences, findReferencedSecretReferences,
findAllProjectSecretValues findAllProjectSecretValues,
countByFolderIds
}; };
}; };
@@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
folderDAL: Pick< folderDAL: Pick<
TSecretFolderDALFactory, TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
@@ -431,6 +431,175 @@ export const secretV2BridgeServiceFactory = ({
}); });
}; };
// get unique secrets count for multiple envs
const getSecretsCountMultiEnv = async ({
actorId,
path,
projectId,
actor,
actorOrgId,
actorAuthMethod,
environments,
...params
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
environments: string[];
}) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
// verify user has access to all environments
environments.forEach((environment) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
if (!folders.length) return 0;
const count = await secretDAL.countByFolderIds(
folders.map((folder) => folder.id),
actorId,
undefined,
params
);
return count;
};
// get secret count for individual env
const getSecretsCount = async ({
actorId,
path,
environment,
projectId,
actor,
actorOrgId,
actorAuthMethod,
...params
}: Pick<
TGetSecretsDTO,
| "actorId"
| "actor"
| "path"
| "projectId"
| "actorOrgId"
| "actorAuthMethod"
| "tagSlugs"
| "environment"
| "search"
>) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) return 0;
const count = await secretDAL.countByFolderIds([folder.id], actorId, undefined, params);
return count;
};
// get secrets for multiple envs
const getSecretsMultiEnv = async ({
actorId,
path,
environments,
projectId,
actor,
actorOrgId,
actorAuthMethod,
...params
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
environments: string[];
}) => {
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
let paths: { folderId: string; path: string; environment: string }[] = [];
// verify user has access to all environments
environments.forEach((environment) =>
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
)
);
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
const data: { [key: string]: typeof decryptedSecrets } = {};
if (!folders.length) {
environments.forEach((env) => {
data[env] = [];
});
return data;
}
paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug }));
const groupedPaths = groupBy(paths, (p) => p.folderId);
const secrets = await secretDAL.findByFolderIds(
paths.map((p) => p.folderId),
actorId,
undefined,
params
);
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedSecrets = secrets.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()
: ""
}
)
);
decryptedSecrets.forEach((secret) => {
data[secret.environment] = [...(data[secret.environment] ?? []), secret];
});
return data;
};
const getSecrets = async ({ const getSecrets = async ({
actorId, actorId,
path, path,
@@ -441,8 +610,8 @@ export const secretV2BridgeServiceFactory = ({
actorAuthMethod, actorAuthMethod,
includeImports, includeImports,
recursive, recursive,
tagSlugs = [], expandSecretReferences: shouldExpandSecretReferences,
expandSecretReferences: shouldExpandSecretReferences ...params
}: TGetSecretsDTO) => { }: TGetSecretsDTO) => {
const { permission } = await permissionService.getProjectPermission( const { permission } = await permissionService.getProjectPermission(
actor, actor,
@@ -490,7 +659,9 @@ export const secretV2BridgeServiceFactory = ({
const secrets = await secretDAL.findByFolderIds( const secrets = await secretDAL.findByFolderIds(
paths.map((p) => p.folderId), paths.map((p) => p.folderId),
actorId actorId,
undefined,
params
); );
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -509,9 +680,7 @@ export const secretV2BridgeServiceFactory = ({
: "" : ""
}) })
); );
const filteredSecrets = tagSlugs.length
? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug))))
: decryptedSecrets;
const expandSecretReferences = expandSecretReferencesFactory({ const expandSecretReferences = expandSecretReferencesFactory({
projectId, projectId,
folderDAL, folderDAL,
@@ -520,7 +689,7 @@ export const secretV2BridgeServiceFactory = ({
}); });
if (shouldExpandSecretReferences) { if (shouldExpandSecretReferences) {
const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath); const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
await Promise.allSettled( await Promise.allSettled(
Object.keys(secretsGroupByPath).map((groupedPath) => Object.keys(secretsGroupByPath).map((groupedPath) =>
Promise.allSettled( Promise.allSettled(
@@ -541,7 +710,7 @@ export const secretV2BridgeServiceFactory = ({
if (!includeImports) { if (!includeImports) {
return { return {
secrets: filteredSecrets secrets: decryptedSecrets
}; };
} }
@@ -569,7 +738,7 @@ export const secretV2BridgeServiceFactory = ({
}); });
return { return {
secrets: filteredSecrets, secrets: decryptedSecrets,
imports: importedSecrets imports: importedSecrets
}; };
}; };
@@ -1416,6 +1585,9 @@ export const secretV2BridgeServiceFactory = ({
getSecrets, getSecrets,
getSecretVersions, getSecretVersions,
backfillSecretReferences, backfillSecretReferences,
moveSecrets moveSecrets,
getSecretsCount,
getSecretsCountMultiEnv,
getSecretsMultiEnv
}; };
}; };
@@ -1,8 +1,9 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas"; import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
@@ -21,6 +22,11 @@ export type TGetSecretsDTO = {
includeImports?: boolean; includeImports?: boolean;
recursive?: boolean; recursive?: boolean;
tagSlugs?: string[]; tagSlugs?: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
offset?: number;
limit?: number;
search?: string;
} & TProjectPermission; } & TProjectPermission;
export type TGetASecretDTO = { export type TGetASecretDTO = {
+122 -3
View File
@@ -954,6 +954,120 @@ export const secretServiceFactory = ({
return secretsDeleted; return secretsDeleted;
}; };
const getSecretsCount = async ({
projectId,
path,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environment,
tagSlugs = [],
...v2Params
}: Pick<
TGetSecretsRawDTO,
| "projectId"
| "path"
| "actor"
| "actorId"
| "actorOrgId"
| "actorAuthMethod"
| "environment"
| "tagSlugs"
| "search"
>) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({
message: "Project version does not support pagination",
name: "pagination_not_supported"
});
const count = await secretV2BridgeService.getSecretsCount({
projectId,
actorId,
actor,
actorOrgId,
environment,
path,
actorAuthMethod,
tagSlugs,
...v2Params
});
return count;
};
const getSecretsCountMultiEnv = async ({
projectId,
path,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
...v2Params
}: Pick<
TGetSecretsRawDTO,
"projectId" | "path" | "actor" | "actorId" | "actorOrgId" | "actorAuthMethod" | "search"
> & { environments: string[] }) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({
message: "Project version does not support pagination",
name: "pagination_not_supported"
});
const count = await secretV2BridgeService.getSecretsCountMultiEnv({
projectId,
actorId,
actor,
actorOrgId,
environments,
path,
actorAuthMethod,
...v2Params
});
return count;
};
const getSecretsRawMultiEnv = async ({
projectId,
path,
actor,
actorId,
actorOrgId,
actorAuthMethod,
environments,
...params
}: Omit<TGetSecretsRawDTO, "environment" | "includeImports" | "expandSecretReferences" | "recursive" | "tagSlugs"> & {
environments: string[];
}) => {
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (!shouldUseSecretV2Bridge)
throw new BadRequestError({
message: "Project version does not support pagination",
name: "pagination_not_supported"
});
const secrets = await secretV2BridgeService.getSecretsMultiEnv({
projectId,
actorId,
actor,
actorOrgId,
environments,
path,
actorAuthMethod,
...params
});
return secrets;
};
const getSecretsRaw = async ({ const getSecretsRaw = async ({
projectId, projectId,
path, path,
@@ -965,7 +1079,8 @@ export const secretServiceFactory = ({
includeImports, includeImports,
expandSecretReferences, expandSecretReferences,
recursive, recursive,
tagSlugs = [] tagSlugs = [],
...paramsV2
}: TGetSecretsRawDTO) => { }: TGetSecretsRawDTO) => {
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
@@ -980,7 +1095,8 @@ export const secretServiceFactory = ({
recursive, recursive,
actorAuthMethod, actorAuthMethod,
includeImports, includeImports,
tagSlugs tagSlugs,
...paramsV2
}); });
return { secrets, imports }; return { secrets, imports };
} }
@@ -2656,6 +2772,9 @@ export const secretServiceFactory = ({
getSecretVersions, getSecretVersions,
backfillSecretReferences, backfillSecretReferences,
moveSecrets, moveSecrets,
startSecretV2Migration startSecretV2Migration,
getSecretsCount,
getSecretsCountMultiEnv,
getSecretsRawMultiEnv
}; };
}; };
+12 -1
View File
@@ -1,7 +1,7 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal"; import { TSecretDALFactory } from "@app/services/secret/secret-dal";
@@ -81,6 +81,8 @@ export type TGetSecretsDTO = {
environment: string; environment: string;
includeImports?: boolean; includeImports?: boolean;
recursive?: boolean; recursive?: boolean;
limit?: number;
offset?: number;
} & TProjectPermission; } & TProjectPermission;
export type TGetASecretDTO = { export type TGetASecretDTO = {
@@ -143,6 +145,10 @@ export type TDeleteBulkSecretDTO = {
}>; }>;
} & TProjectPermission; } & TProjectPermission;
export enum SecretsOrderBy {
Name = "name" // "key" for secrets but using name for use across resources
}
export type TGetSecretsRawDTO = { export type TGetSecretsRawDTO = {
expandSecretReferences?: boolean; expandSecretReferences?: boolean;
path: string; path: string;
@@ -150,6 +156,11 @@ export type TGetSecretsRawDTO = {
includeImports?: boolean; includeImports?: boolean;
recursive?: boolean; recursive?: boolean;
tagSlugs?: string[]; tagSlugs?: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
offset?: number;
limit?: number;
search?: string;
} & TProjectPermission; } & TProjectPermission;
export type TGetASecretRawDTO = { export type TGetASecretRawDTO = {
@@ -100,7 +100,7 @@ export default function NavHeader({
onValueChange={(value) => { onValueChange={(value) => {
if (value && onEnvChange) onEnvChange(value); if (value && onEnvChange) onEnvChange(value);
}} }}
className="bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary" className="border-none bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl" dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl"
> >
{userAvailableEnvs?.map(({ name, slug }) => ( {userAvailableEnvs?.map(({ name, slug }) => (
@@ -1,3 +1,4 @@
import { ReactElement } from "react";
import { import {
faCaretDown, faCaretDown,
faCheck, faCheck,
@@ -23,6 +24,7 @@ export type PaginationProps = {
onChangePerPage: (newRows: number) => void; onChangePerPage: (newRows: number) => void;
className?: string; className?: string;
perPageList?: number[]; perPageList?: number[];
startAdornment?: ReactElement;
}; };
export const Pagination = ({ export const Pagination = ({
@@ -32,7 +34,8 @@ export const Pagination = ({
onChangePage, onChangePage,
onChangePerPage, onChangePerPage,
perPageList = [10, 20, 50, 100], perPageList = [10, 20, 50, 100],
className className,
startAdornment
}: PaginationProps) => { }: PaginationProps) => {
const prevPageNumber = Math.max(1, page - 1); const prevPageNumber = Math.max(1, page - 1);
const canGoPrev = page > 1; const canGoPrev = page > 1;
@@ -46,11 +49,12 @@ export const Pagination = ({
return ( return (
<div <div
className={twMerge( className={twMerge(
"flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white", "flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white",
className className
)} )}
> >
<div className="mr-6 flex items-center space-x-2"> {startAdornment}
<div className="ml-auto mr-6 flex items-center space-x-2">
<div className="text-xs"> <div className="text-xs">
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count} {(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div> </div>
@@ -0,0 +1 @@
export { useGetProjectSecretsDetails } from "./queries";
@@ -0,0 +1,274 @@
import { useCallback } from "react";
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import axios from "axios";
import { createNotification } from "@app/components/notifications";
import { apiRequest } from "@app/config/request";
import {
DashboardProjectSecretsDetails,
DashboardProjectSecretsDetailsResponse,
DashboardProjectSecretsOverview,
DashboardProjectSecretsOverviewResponse,
DashboardSecretsOrderBy,
TGetDashboardProjectSecretsDetailsDTO,
TGetDashboardProjectSecretsOverviewDTO
} from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
export const dashboardKeys = {
all: () => ["dashboard"] as const,
getDashboardSecrets: ({
projectId,
secretPath
}: Pick<TGetDashboardProjectSecretsDetailsDTO, "projectId" | "secretPath">) =>
[...dashboardKeys.all(), { projectId, secretPath }] as const,
getProjectSecretsOverview: ({
projectId,
secretPath,
...params
}: TGetDashboardProjectSecretsOverviewDTO) =>
[
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
"secrets-overview",
params
] as const,
getProjectSecretsDetails: ({
projectId,
secretPath,
environment,
...params
}: TGetDashboardProjectSecretsDetailsDTO) =>
[
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
environment,
"secrets-details",
params
] as const
};
export const fetchProjectSecretsOverview = async ({
includeFolders,
includeSecrets,
includeDynamicSecrets,
environments,
...params
}: TGetDashboardProjectSecretsOverviewDTO) => {
const { data } = await apiRequest.get<DashboardProjectSecretsOverviewResponse>(
"/api/v3/dashboard/secrets-overview",
{
params: {
...params,
environments: encodeURIComponent(environments.join(",")),
includeFolders: includeFolders ? "1" : "",
includeSecrets: includeSecrets ? "1" : "",
includeDynamicSecrets: includeDynamicSecrets ? "1" : ""
}
}
);
return data;
};
export const fetchProjectSecretsDetails = async ({
includeFolders,
includeImports,
includeSecrets,
includeDynamicSecrets,
tags,
...params
}: TGetDashboardProjectSecretsDetailsDTO) => {
const { data } = await apiRequest.get<DashboardProjectSecretsDetailsResponse>(
"/api/v3/dashboard/secrets-details",
{
params: {
...params,
includeImports: includeImports ? "1" : "",
includeFolders: includeFolders ? "1" : "",
includeSecrets: includeSecrets ? "1" : "",
includeDynamicSecrets: includeDynamicSecrets ? "1" : "",
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 useGetProjectSecretsOverview = (
{
projectId,
secretPath,
offset = 0,
limit = 100,
orderBy = DashboardSecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC,
search = "",
includeSecrets,
includeFolders,
includeDynamicSecrets,
environments
}: TGetDashboardProjectSecretsOverviewDTO,
options?: Omit<
UseQueryOptions<
DashboardProjectSecretsOverviewResponse,
unknown,
DashboardProjectSecretsOverview,
ReturnType<typeof dashboardKeys.getProjectSecretsOverview>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
...options,
// wait for all values to be available
enabled: Boolean(projectId) && (options?.enabled ?? true) && Boolean(environments.length),
queryKey: dashboardKeys.getProjectSecretsOverview({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
includeSecrets,
includeFolders,
includeDynamicSecrets,
environments
}),
queryFn: () =>
fetchProjectSecretsOverview({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
includeSecrets,
includeFolders,
includeDynamicSecrets,
environments
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
createNotification({
title: "Error fetching secret details",
type: "error",
text: serverResponse.message
});
}
},
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => {
const { secrets, ...select } = data;
let sanitizedSecrets: Record<string, Record<string, SecretV3RawSanitized>> = {};
if (secrets) {
sanitizedSecrets = {};
Object.entries(secrets).forEach(([env, rawSecrets]) => {
sanitizedSecrets[env] = mergePersonalSecrets(rawSecrets).reduce<
Record<string, SecretV3RawSanitized>
>((prev, curr) => ({ ...prev, [curr.key]: curr }), {});
});
}
return {
...select,
...(secrets && {
secrets: sanitizedSecrets
})
};
}, []),
keepPreviousData: true
});
};
export const useGetProjectSecretsDetails = (
{
projectId,
secretPath,
environment,
offset = 0,
limit = 100,
orderBy = DashboardSecretsOrderBy.Name,
orderDirection = OrderByDirection.ASC,
search = "",
includeSecrets,
includeFolders,
includeImports,
includeDynamicSecrets,
tags
}: TGetDashboardProjectSecretsDetailsDTO,
options?: Omit<
UseQueryOptions<
DashboardProjectSecretsDetailsResponse,
unknown,
DashboardProjectSecretsDetails,
ReturnType<typeof dashboardKeys.getProjectSecretsDetails>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
...options,
// wait for all values to be available
enabled: Boolean(projectId) && (options?.enabled ?? true),
queryKey: dashboardKeys.getProjectSecretsDetails({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
environment,
includeSecrets,
includeFolders,
includeImports,
includeDynamicSecrets,
tags
}),
queryFn: () =>
fetchProjectSecretsDetails({
secretPath,
search,
limit,
orderBy,
orderDirection,
offset,
projectId,
environment,
includeSecrets,
includeFolders,
includeImports,
includeDynamicSecrets,
tags
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
createNotification({
title: "Error fetching secret details",
type: "error",
text: serverResponse.message
});
}
},
select: useCallback(
(data: Awaited<ReturnType<typeof fetchProjectSecretsDetails>>) => ({
...data,
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined
}),
[]
),
keepPreviousData: true
});
};
+68
View File
@@ -0,0 +1,68 @@
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
export type DashboardProjectSecretsOverviewResponse = {
folders?: { [key: string]: TSecretFolder[] };
dynamicSecrets?: { [key: string]: TDynamicSecret[] };
secrets?: { [key: string]: SecretV3Raw[] };
totalSecretCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalCount: number;
};
export type DashboardProjectSecretsDetailsResponse = {
imports?: TSecretImport[];
folders?: TSecretFolder[];
dynamicSecrets?: TDynamicSecret[];
secrets?: SecretV3Raw[];
totalImportCount?: number;
totalFolderCount?: number;
totalDynamicSecretCount?: number;
totalSecretCount?: number;
totalCount: number;
};
export type DashboardProjectSecretsOverview = Omit<
DashboardProjectSecretsOverviewResponse,
"secrets"
> & {
secrets?: Record<string, Record<string, SecretV3RawSanitized>>;
};
export type DashboardProjectSecretsDetails = Omit<
DashboardProjectSecretsDetailsResponse,
"secrets"
> & {
secrets?: SecretV3RawSanitized[];
};
export enum DashboardSecretsOrderBy {
Name = "name"
}
export type TGetDashboardProjectSecretsOverviewDTO = {
projectId: string;
secretPath: string;
offset?: number;
limit?: number;
orderBy?: DashboardSecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
includeSecrets?: boolean;
includeFolders?: boolean;
includeDynamicSecrets?: boolean;
environments: string[];
};
export type TGetDashboardProjectSecretsDetailsDTO = Omit<
TGetDashboardProjectSecretsOverviewDTO,
"environments"
> & {
environment: string;
includeImports?: boolean;
tags: Record<string, boolean>;
};
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { dynamicSecretKeys } from "./queries"; import { dynamicSecretKeys } from "./queries";
import { import {
@@ -22,6 +23,8 @@ export const useCreateDynamicSecret = () => {
return data.dynamicSecret; return data.dynamicSecret;
}, },
onSuccess: (_, { path, environmentSlug, projectSlug }) => { onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but we currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
} }
}); });
@@ -39,6 +42,8 @@ export const useUpdateDynamicSecret = () => {
return data.dynamicSecret; return data.dynamicSecret;
}, },
onSuccess: (_, { path, environmentSlug, projectSlug }) => { onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
} }
}); });
@@ -56,6 +61,8 @@ export const useDeleteDynamicSecret = () => {
return data.dynamicSecret; return data.dynamicSecret;
}, },
onSuccess: (_, { path, environmentSlug, projectSlug }) => { onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug })); queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
} }
}); });
@@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries";
import { import {
@@ -124,6 +125,12 @@ export const useCreateFolder = () => {
return data; return data;
}, },
onSuccess: (_, { projectId, environment, path }) => { onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path }) folderQueryKeys.getSecretFolders({ projectId, environment, path })
); );
@@ -151,6 +158,12 @@ export const useUpdateFolder = () => {
return data; return data;
}, },
onSuccess: (_, { projectId, environment, path }) => { onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path }) folderQueryKeys.getSecretFolders({ projectId, environment, path })
); );
@@ -179,6 +192,12 @@ export const useDeleteFolder = () => {
return data; return data;
}, },
onSuccess: (_, { path = "/", projectId, environment }) => { onSuccess: (_, { path = "/", projectId, environment }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path
})
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path }) folderQueryKeys.getSecretFolders({ projectId, environment, path })
); );
@@ -206,6 +225,12 @@ export const useUpdateFolderBatch = () => {
}, },
onSuccess: (_, { projectId, folders }) => { onSuccess: (_, { projectId, folders }) => {
folders.forEach((folder) => { folders.forEach((folder) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: folder.path ?? "/"
})
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ folderQueryKeys.getSecretFolders({
projectId, projectId,
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretImportKeys } from "./queries"; import { secretImportKeys } from "./queries";
import { import {
@@ -31,6 +32,9 @@ export const useCreateSecretImport = () => {
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
); );
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
} }
}); });
}; };
@@ -55,6 +59,9 @@ export const useUpdateSecretImport = () => {
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ environment, path, projectId }) secretImportKeys.getSecretImportSecrets({ environment, path, projectId })
); );
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
} }
}); });
}; };
@@ -93,6 +100,9 @@ export const useDeleteSecretImport = () => {
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ projectId, environment, path }) secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
); );
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
} }
}); });
}; };
@@ -1,6 +1,7 @@
import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query"; import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries"; import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries";
@@ -44,6 +45,9 @@ export const useCreateSecretV3 = ({
return data; return data;
}, },
onSuccess: (_, { workspaceId, environment, secretPath }) => { onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -96,6 +100,9 @@ export const useUpdateSecretV3 = ({
return data; return data;
}, },
onSuccess: (_, { workspaceId, environment, secretPath }) => { onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -139,6 +146,9 @@ export const useDeleteSecretV3 = ({
return data; return data;
}, },
onSuccess: (_, { workspaceId, environment, secretPath }) => { onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -172,6 +182,9 @@ export const useCreateSecretBatch = ({
return data; return data;
}, },
onSuccess: (_, { workspaceId, environment, secretPath }) => { onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -205,6 +218,9 @@ export const useUpdateSecretBatch = ({
return data; return data;
}, },
onSuccess: (_, { workspaceId, environment, secretPath }) => { onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -240,6 +256,9 @@ export const useDeleteSecretBatch = ({
return data; return data;
}, },
onSuccess: (_, { workspaceId, environment, secretPath }) => { onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -295,6 +314,12 @@ export const useMoveSecrets = ({
return data; return data;
}, },
onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => { onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: sourceSecretPath
})
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ secretKeys.getProjectSecret({
workspaceId: projectId, workspaceId: projectId,
+1
View File
@@ -0,0 +1 @@
export * from "./secrets-overview";
@@ -0,0 +1,99 @@
import { useCallback, useMemo } from "react";
import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
export const useFolderOverview = (
folders: DashboardProjectSecretsOverview["folders"],
orderDirection: OrderByDirection
) => {
const folderNames = useMemo(() => {
const names = new Set<string>();
Object.values(folders ?? {})?.forEach((folderGroup) => {
folderGroup.forEach((folder) => {
names.add(folder.name);
});
});
return [...names].sort((a, b) =>
orderDirection === OrderByDirection.ASC ? a.localeCompare(b) : b.localeCompare(a)
);
}, [folders]);
const isFolderPresentInEnv = useCallback(
(name: string, env: string) => {
return Boolean(folders?.[env]?.find(({ name: folderName }) => folderName === name));
},
[folders]
);
const getFolderByNameAndEnv = useCallback(
(name: string, env: string) => {
return folders?.[env]?.find(({ name: folderName }) => folderName === name);
},
[folders]
);
return { folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
};
export const useDynamicSecretOverview = (
dynamicSecrets: DashboardProjectSecretsOverview["dynamicSecrets"],
orderDirection: OrderByDirection
) => {
const dynamicSecretNames = useMemo(() => {
const names = new Set<string>();
Object.values(dynamicSecrets ?? {})?.forEach((folderGroup) => {
folderGroup.forEach((folder) => {
names.add(folder.name);
});
});
return [...names].sort((a, b) =>
orderDirection === OrderByDirection.ASC ? a.localeCompare(b) : b.localeCompare(a)
);
}, [dynamicSecrets]);
const isDynamicSecretPresentInEnv = useCallback(
(name: string, env: string) => {
return Boolean(
dynamicSecrets?.[env]?.find(({ name: dynamicSecretName }) => dynamicSecretName === name)
);
},
[dynamicSecrets]
);
return { dynamicSecretNames, isDynamicSecretPresentInEnv };
};
export const useSecretOverview = (
secrets: DashboardProjectSecretsOverview["secrets"],
orderDirection: OrderByDirection
) => {
const secKeys = useMemo(() => {
const keys = new Set<string>();
Object.values(secrets ?? {})?.forEach((secretGroup) => {
Object.keys(secretGroup || {}).forEach((key) => {
keys.add(key);
});
});
return [...keys].sort((a, b) =>
orderDirection === OrderByDirection.ASC ? a.localeCompare(b) : b.localeCompare(a)
);
}, [secrets]);
const getEnvSecretKeyCount = useCallback(
(env: string) => {
return Object.keys(secrets?.[env] || {}).length;
},
[secrets]
);
const getSecretByKey = useCallback(
(env: string, key: string) => {
const sec = secrets?.[env]?.[key];
return sec;
},
[secrets]
);
return { secKeys, getSecretByKey, getEnvSecretKeyCount };
};
@@ -46,12 +46,12 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({ const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({
organizationId, organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will replace with combobox in separate PR limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
}); });
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships; const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({ const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
workspaceId, workspaceId,
limit: 20000 // TODO: this is temp to preserve functionality for bitcoindepot, will optimize in PR referenced above limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above
}); });
const identityMemberships = identityMembershipsData?.identityMemberships; const identityMemberships = identityMembershipsData?.identityMemberships;
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -17,29 +18,28 @@ import {
} from "@app/context"; } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks"; import { useDebounce, usePopUp } from "@app/hooks";
import { import {
useGetDynamicSecrets,
useGetImportedSecretsSingleEnv, useGetImportedSecretsSingleEnv,
useGetProjectFolders,
useGetProjectSecrets,
useGetSecretApprovalPolicyOfABoard, useGetSecretApprovalPolicyOfABoard,
useGetSecretImports,
useGetWorkspaceSnapshotList, useGetWorkspaceSnapshotList,
useGetWsSnapshotCount, useGetWsSnapshotCount,
useGetWsTags useGetWsTags
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { DynamicSecretListView } from "@app/views/SecretMainPage/components/DynamicSecretListView";
import { FolderListView } from "@app/views/SecretMainPage/components/FolderListView";
import { SecretImportListView } from "@app/views/SecretMainPage/components/SecretImportListView";
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount/SecretTableResourceCount";
import { SecretV2MigrationSection } from "../SecretOverviewPage/components/SecretV2MigrationSection"; import { SecretV2MigrationSection } from "../SecretOverviewPage/components/SecretV2MigrationSection";
import { ActionBar } from "./components/ActionBar"; import { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm"; import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer"; import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone"; import { SecretDropzone } from "./components/SecretDropzone";
import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView } from "./components/SecretListView"; import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView"; import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store"; import { StoreProvider } from "./SecretMainPage.store";
import { Filter, SortDir } from "./SecretMainPage.types"; import { Filter, RowType } from "./SecretMainPage.types";
const LOADER_TEXT = [ const LOADER_TEXT = [
"Retrieving your encrypted secrets...", "Retrieving your encrypted secrets...",
@@ -55,12 +55,8 @@ export const SecretMainPage = () => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC); const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const [filter, setFilter] = useState<Filter>({
tags: {},
searchFilter: (router.query.searchFilter as string) || ""
});
const debouncedSearchFilter = useDebounce(filter.searchFilter);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE); const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const paginationOffset = (page - 1) * perPage; const paginationOffset = (page - 1) * perPage;
@@ -83,6 +79,18 @@ export const SecretMainPage = () => {
ProjectPermissionSub.SecretRollback ProjectPermissionSub.SecretRollback
); );
const [filter, setFilter] = useState<Filter>({
tags: {},
searchFilter: (router.query.searchFilter as string) || "",
include: {
[RowType.Folder]: true,
[RowType.Import]: canReadSecret,
[RowType.DynamicSecret]: canReadSecret,
[RowType.Secret]: canReadSecret
}
});
const debouncedSearchFilter = useDebounce(filter.searchFilter);
useEffect(() => { useEffect(() => {
if ( if (
!isWorkspaceLoading && !isWorkspaceLoading &&
@@ -91,43 +99,43 @@ export const SecretMainPage = () => {
) { ) {
router.push(`/project/${workspaceId}/secrets/overview`); router.push(`/project/${workspaceId}/secrets/overview`);
createNotification({ createNotification({
text: "No envronment found with given slug", text: "No environment found with given slug",
type: "error" type: "error"
}); });
} }
}, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]); }, [isWorkspaceLoading, currentWorkspace, environment, router.isReady]);
// fetch secrets
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
environment,
workspaceId,
secretPath,
options: {
enabled: canReadSecret
}
});
// fetch folders
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
projectId: workspaceId,
environment,
path: secretPath
});
// fetch secret imports
const { const {
data: secretImports, data,
isLoading: isSecretImportsLoading, isLoading: isDetailsLoading,
isFetching: isSecretImportsFetching isFetching: isDetailsFetching
} = useGetSecretImports({ } = useGetProjectSecretsDetails({
projectId: workspaceId,
environment, environment,
path: secretPath, projectId: workspaceId,
options: { secretPath,
enabled: canReadSecret offset: paginationOffset,
} limit: perPage,
search: debouncedSearchFilter,
orderDirection,
includeImports: canReadSecret && filter.include.import,
includeFolders: filter.include.folder,
includeDynamicSecrets: canReadSecret && filter.include.dynamic,
includeSecrets: canReadSecret && filter.include.secret,
tags: filter.tags
}); });
const {
imports,
folders,
dynamicSecrets,
secrets,
totalImportCount = 0,
totalFolderCount = 0,
totalDynamicSecretCount = 0,
totalSecretCount = 0,
totalCount = 0
} = data ?? {};
// fetch imported secrets to show user the overriden ones // fetch imported secrets to show user the overriden ones
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({ const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
projectId: workspaceId, projectId: workspaceId,
@@ -138,13 +146,7 @@ export const SecretMainPage = () => {
} }
}); });
const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({ // fetch tags
projectSlug,
environmentSlug: environment,
path: secretPath
});
// fech tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : ""); const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
@@ -174,12 +176,14 @@ export const SecretMainPage = () => {
isPaused: !canDoReadRollback isPaused: !canDoReadRollback
}); });
const isNotEmtpy = Boolean( const isNotEmpty = Boolean(
secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length
); );
const handleSortToggle = () => const handleSortToggle = () =>
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC)); setOrderDirection((state) =>
state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
const handleEnvChange = (slug: string) => { const handleEnvChange = (slug: string) => {
const query: Record<string, string> = { ...router.query, env: slug }; const query: Record<string, string> = { ...router.query, env: slug };
@@ -191,17 +195,31 @@ export const SecretMainPage = () => {
}; };
const handleTagToggle = useCallback( const handleTagToggle = useCallback(
(tagId: string) => (tagSlug: string) =>
setFilter((state) => { setFilter((state) => {
const isTagPresent = Boolean(state.tags?.[tagId]); const isTagPresent = Boolean(state.tags?.[tagSlug]);
const newTagFilter = { ...state.tags }; const newTagFilter = { ...state.tags };
if (isTagPresent) delete newTagFilter[tagId]; if (isTagPresent) delete newTagFilter[tagSlug];
else newTagFilter[tagId] = true; else newTagFilter[tagSlug] = true;
return { ...state, tags: newTagFilter }; return { ...state, tags: newTagFilter };
}), }),
[] []
); );
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
include: {
...state.include,
[rowType]: !state.include[rowType]
}
};
}),
[]
);
const handleSearchChange = useCallback( const handleSearchChange = useCallback(
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })), (searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
[] []
@@ -219,126 +237,18 @@ export const SecretMainPage = () => {
handlePopUpClose("snapshots"); handlePopUpClose("snapshots");
}, []); }, []);
// loading screen when u have permission
const loadingOnAccess =
canReadSecret &&
(isSecretsLoading || isSecretImportsLoading || isFoldersLoading || isDynamicSecretLoading);
const rows = useMemo(() => {
const filteredSecrets =
secrets
?.filter(({ key, tags: secretTags, value }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
return (
(!isTagFilterActive || secretTags?.some(({ id }) => filter.tags?.[id])) &&
(key.toUpperCase().includes(debouncedSearchFilter.toUpperCase()) ||
value?.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
);
})
.sort((a, b) =>
sortDir === SortDir.ASC ? a.key.localeCompare(b.key) : b.key.localeCompare(a.key)
) ?? [];
const filteredFolders =
folders
?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) =>
sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
) ?? [];
const filteredDynamicSecrets =
dynamicSecrets
?.filter(({ name }) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) =>
sortDir === "asc" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
) ?? [];
const filteredSecretImports =
secretImports
?.filter(({ importPath }) =>
importPath.toLowerCase().includes(debouncedSearchFilter.toLowerCase())
)
.sort((a, b) =>
sortDir === "asc"
? a.importPath.localeCompare(b.importPath)
: b.importPath.localeCompare(a.importPath)
) ?? [];
const totalRows =
filteredSecretImports.length +
filteredFolders.length +
filteredDynamicSecrets.length +
filteredSecrets.length;
const paginatedImports = filteredSecretImports.slice(
paginationOffset,
paginationOffset + perPage
);
let remainingRows = perPage - paginatedImports.length;
const foldersStartIndex = Math.max(0, paginationOffset - filteredSecretImports.length);
const paginatedFolders =
remainingRows > 0
? filteredFolders.slice(foldersStartIndex, foldersStartIndex + remainingRows)
: [];
remainingRows -= paginatedFolders.length;
const dynamicSecretStartIndex = Math.max(
0,
paginationOffset - filteredSecretImports.length - filteredFolders.length
);
const paginatiedDynamicSecrets =
remainingRows > 0
? filteredDynamicSecrets.slice(
dynamicSecretStartIndex,
dynamicSecretStartIndex + remainingRows
)
: [];
remainingRows -= paginatiedDynamicSecrets.length;
const secretStartIndex = Math.max(
0,
paginationOffset -
filteredSecretImports.length -
filteredFolders.length -
filteredDynamicSecrets.length
);
const paginatiedSecrets =
remainingRows > 0
? filteredSecrets.slice(secretStartIndex, secretStartIndex + remainingRows)
: [];
return {
imports: paginatedImports,
folders: paginatedFolders,
secrets: paginatiedSecrets,
dynamicSecrets: paginatiedDynamicSecrets,
totalRows
};
}, [
sortDir,
debouncedSearchFilter,
folders,
secrets,
dynamicSecrets,
paginationOffset,
perPage,
filter.tags,
importedSecrets
]);
useEffect(() => { useEffect(() => {
// reset page if no longer valid // reset page if no longer valid
if (rows.totalRows < paginationOffset) setPage(1); if (totalCount < paginationOffset) setPage(1);
}, [rows.totalRows]); }, [totalCount]);
// loading screen when you don't have permission but as folder's is viewable need to wait for that if (isDetailsLoading) {
const loadingOnDenied = !canReadSecret && isFoldersLoading;
if (loadingOnAccess || loadingOnDenied) {
return <ContentLoader text={LOADER_TEXT} />; return <ContentLoader text={LOADER_TEXT} />;
} }
return ( return (
<StoreProvider> <StoreProvider>
<div className="container mx-auto flex h-full flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection /> <SecretV2MigrationSection />
<div className="relative right-6 -top-2 mb-2 ml-6"> <div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader <NavHeader
@@ -372,17 +282,22 @@ export const SecretMainPage = () => {
isVisible={isVisible} isVisible={isVisible}
filter={filter} filter={filter}
tags={tags} tags={tags}
onVisiblilityToggle={handleToggleVisibility} onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle} onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0} snapshotCount={snapshotCount || 0}
isSnapshotCountLoading={isSnapshotCountLoading} isSnapshotCountLoading={isSnapshotCountLoading}
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)} onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
/> />
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md bg-mineshaft-800 text-left text-sm text-bunker-300"> <div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard"> <div className="flex flex-col" id="dashboard">
{isNotEmtpy && ( {isNotEmpty && (
<div className="flex border-b border-mineshaft-600 font-medium"> <div
className={twMerge(
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
)}
>
<div style={{ width: "2.8rem" }} className="flex-shrink-0 px-4 py-3" /> <div style={{ width: "2.8rem" }} className="flex-shrink-0 px-4 py-3" />
<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 px-4 py-2"
@@ -395,45 +310,43 @@ export const SecretMainPage = () => {
> >
Key Key
<FontAwesomeIcon <FontAwesomeIcon
icon={sortDir === SortDir.ASC ? faArrowDown : faArrowUp} icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2" className="ml-2"
/> />
</div> </div>
<div className="flex-grow px-4 py-2">Value</div> <div className="flex-grow px-4 py-2">Value</div>
</div> </div>
)} )}
{canReadSecret && ( {canReadSecret && imports?.length && (
<SecretImportListView <SecretImportListView
searchTerm={filter.searchFilter} searchTerm={debouncedSearchFilter}
secretImports={rows.imports} secretImports={imports}
isFetching={isSecretImportsLoading || isSecretImportsFetching} isFetching={isDetailsFetching}
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
secrets={secrets}
importedSecrets={importedSecrets} importedSecrets={importedSecrets}
/> />
)} )}
<FolderListView {folders?.length && (
folders={rows.folders} <FolderListView
environment={environment} folders={folders}
workspaceId={workspaceId} environment={environment}
secretPath={secretPath} workspaceId={workspaceId}
sortDir={sortDir} secretPath={secretPath}
searchTerm={filter.searchFilter} />
/> )}
{canReadSecret && ( {canReadSecret && dynamicSecrets?.length && (
<DynamicSecretListView <DynamicSecretListView
sortDir={sortDir}
environment={environment} environment={environment}
projectSlug={projectSlug} projectSlug={projectSlug}
secretPath={secretPath} secretPath={secretPath}
dynamicSecrets={rows.dynamicSecrets || []} dynamicSecrets={dynamicSecrets}
/> />
)} )}
{canReadSecret && ( {canReadSecret && secrets?.length && (
<SecretListView <SecretListView
secrets={rows.secrets} secrets={secrets}
tags={tags} tags={tags}
isVisible={isVisible} isVisible={isVisible}
environment={environment} environment={environment}
@@ -443,18 +356,26 @@ export const SecretMainPage = () => {
/> />
)} )}
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />} {!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
{!loadingOnAccess && rows.totalRows > INIT_PER_PAGE && (
<Pagination
className="border-t border-solid border-t-mineshaft-600"
count={rows.totalRows}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
</div> </div>
</div> </div>
{!isDetailsLoading && totalCount > INIT_PER_PAGE && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
<CreateSecretForm <CreateSecretForm
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
@@ -467,7 +388,7 @@ export const SecretMainPage = () => {
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
isSmaller={isNotEmtpy} isSmaller={isNotEmpty}
environments={currentWorkspace?.environments} environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch} isProtectedBranch={isProtectedBranch}
/> />
@@ -1,15 +1,14 @@
export type Filter = { export type Filter = {
tags: Record<string, boolean>; tags: Record<string, boolean>;
searchFilter: string; searchFilter: string;
include: {
[key in RowType]: boolean;
};
}; };
export enum SortDir {
ASC = "asc",
DESC = "desc"
}
export enum RowType { export enum RowType {
Folder = "folder", Folder = "folder",
Import = "import",
DynamicSecret = "dynamic", DynamicSecret = "dynamic",
Secret = "Secret" Secret = "secret"
} }
@@ -13,7 +13,9 @@ import {
faFileImport, faFileImport,
faFilter, faFilter,
faFingerprint, faFingerprint,
faFolder,
faFolderPlus, faFolderPlus,
faKey,
faMagnifyingGlass, faMagnifyingGlass,
faMinusSquare, faMinusSquare,
faPlus, faPlus,
@@ -62,7 +64,7 @@ import {
useSelectedSecretActions, useSelectedSecretActions,
useSelectedSecrets useSelectedSecrets
} from "../../SecretMainPage.store"; } from "../../SecretMainPage.store";
import { Filter } from "../../SecretMainPage.types"; import { Filter, RowType } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm"; import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm"; import { FolderForm } from "./FolderForm";
@@ -83,7 +85,8 @@ type Props = {
isSnapshotCountLoading?: boolean; isSnapshotCountLoading?: boolean;
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void; onToggleTagFilter: (tagId: string) => void;
onVisiblilityToggle: () => void; onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void; onClickRollbackMode: () => void;
}; };
@@ -100,8 +103,9 @@ export const ActionBar = ({
isSnapshotCountLoading, isSnapshotCountLoading,
onSearchChange, onSearchChange,
onToggleTagFilter, onToggleTagFilter,
onVisiblilityToggle, onVisibilityToggle,
onClickRollbackMode onClickRollbackMode,
onToggleRowType
}: Props) => { }: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder", "addFolder",
@@ -298,7 +302,9 @@ export const ActionBar = ({
ariaLabel="Download" ariaLabel="Download"
className={twMerge( className={twMerge(
"transition-all", "transition-all",
Object.keys(filter.tags).length && "border-primary/50 text-primary" (Object.keys(filter.tags).length ||
Object.values(filter.include).filter((include) => !include).length) &&
"border-primary/50 text-primary"
)} )}
> >
<FontAwesomeIcon icon={faFilter} /> <FontAwesomeIcon icon={faFilter} />
@@ -306,6 +312,60 @@ export const ActionBar = ({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0"> <DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Filter By</DropdownMenuGroup> <DropdownMenuGroup>Filter By</DropdownMenuGroup>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Import);
}}
icon={filter?.include[RowType.Import] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className=" text-green-700" />
<span>Imports</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
onToggleRowType(RowType.Folder);
}}
icon={filter?.include[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();
onToggleRowType(RowType.DynamicSecret);
}}
icon={
filter?.include[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();
onToggleRowType(RowType.Secret);
}}
icon={filter?.include[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>
<DropdownSubMenu> <DropdownSubMenu>
<DropdownSubMenuTrigger <DropdownSubMenuTrigger
iconPos="right" iconPos="right"
@@ -319,10 +379,10 @@ export const ActionBar = ({
<DropdownMenuItem <DropdownMenuItem
onClick={(evt) => { onClick={(evt) => {
evt.preventDefault(); evt.preventDefault();
onToggleTagFilter(id); onToggleTagFilter(slug);
}} }}
key={id} key={id}
icon={filter?.tags[id] && <FontAwesomeIcon icon={faCheckCircle} />} icon={filter?.tags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right" iconPos="right"
> >
<div className="flex items-center"> <div className="flex items-center">
@@ -346,7 +406,7 @@ export const ActionBar = ({
</IconButton> </IconButton>
</div> </div>
<div> <div>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisiblilityToggle}> <IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisibilityToggle}>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} /> <FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton> </IconButton>
</div> </div>
@@ -27,7 +27,6 @@ import {
TDynamicSecret TDynamicSecret
} from "@app/hooks/api/dynamicSecret/types"; } from "@app/hooks/api/dynamicSecret/types";
import { SortDir } from "../../SecretMainPage.types";
import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease"; import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease";
import { DynamicSecretLease } from "./DynamicSecretLease"; import { DynamicSecretLease } from "./DynamicSecretLease";
import { EditDynamicSecretForm } from "./EditDynamicSecretForm"; import { EditDynamicSecretForm } from "./EditDynamicSecretForm";
@@ -38,19 +37,17 @@ const formatProviderName = (type: DynamicSecretProviders) => {
}; };
type Props = { type Props = {
dynamicSecrets: TDynamicSecret[]; dynamicSecrets?: TDynamicSecret[];
environment: string; environment: string;
projectSlug: string; projectSlug: string;
secretPath?: string; secretPath?: string;
sortDir: SortDir;
}; };
export const DynamicSecretListView = ({ export const DynamicSecretListView = ({
dynamicSecrets = [], dynamicSecrets = [],
environment, environment,
projectSlug, projectSlug,
secretPath = "/", secretPath = "/"
sortDir = SortDir.ASC
}: Props) => { }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"dynamicSecretLeases", "dynamicSecretLeases",
@@ -59,7 +56,6 @@ export const DynamicSecretListView = ({
"deleteDynamicSecret" "deleteDynamicSecret"
] as const); ] as const);
const deleteDynamicSecret = useDeleteDynamicSecret(); const deleteDynamicSecret = useDeleteDynamicSecret();
const handleDynamicSecretDelete = async () => { const handleDynamicSecretDelete = async () => {
@@ -90,158 +86,148 @@ export const DynamicSecretListView = ({
return ( return (
<> <>
{dynamicSecrets {dynamicSecrets.map((secret) => {
.sort((a, b) => const isRevoking = secret.status === DynamicSecretStatus.Deleting;
sortDir === SortDir.ASC return (
? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) <Modal
: b.name.toLowerCase().localeCompare(a.name.toLowerCase()) key={secret.id}
) isOpen={
.map((secret) => { popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
const isRevocking = secret.status === DynamicSecretStatus.Deleting; }
return ( onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
<Modal >
key={secret.id} <div
isOpen={ className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id role="button"
} tabIndex={0}
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)} onKeyDown={(evt) => {
if (evt.key === "Enter" && !isRevoking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
if (!isRevoking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
}}
> >
<div <div className="flex w-11 items-center px-5 py-3 text-yellow-700">
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700" <FontAwesomeIcon icon={faFingerprint} />
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter" && !isRevocking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
if (!isRevocking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
}}
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFingerprint} />
</div>
<div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
>
{secret.name}
<Tag className="ml-4 py-0 px-2 text-xs normal-case">
{formatProviderName(secret.type)}
</Tag>
{Boolean(secret.status) && (
<Tooltip content={secret?.statusDetails || secret.status || ""}>
<FontAwesomeIcon
className={
secret.status === DynamicSecretStatus.Deleting
? "text-yellow-600"
: "text-red-600"
}
icon={faWarning}
/>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-2 px-4 py-2">
<Button
size="xs"
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
isDisabled={isRevocking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
>
Generate
</Button>
{secret.status === DynamicSecretStatus.FailedDeletion && (
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
<Button
size="xs"
className="m-0 py-0.5 px-2"
colorSchema="danger"
isDisabled={isRevocking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", {
...secret,
isForced: true
});
}}
>
Force Delete
</Button>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-dynamic-secret"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("updateDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevocking}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-dynamic-secret"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevocking}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div> </div>
<ModalContent <div className="flex flex-grow items-center px-4 py-3" role="button" tabIndex={0}>
title="Dynamic secret leases" {secret.name}
subTitle="Revoke or renew your secret leases" <Tag className="ml-4 py-0 px-2 text-xs normal-case">
className="max-w-3xl" {formatProviderName(secret.type)}
> </Tag>
<DynamicSecretLease {Boolean(secret.status) && (
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)} <Tooltip content={secret?.statusDetails || secret.status || ""}>
onClose={() => handlePopUpClose("dynamicSecretLeases")} <FontAwesomeIcon
projectSlug={projectSlug} className={
key={secret.id} secret.status === DynamicSecretStatus.Deleting
dynamicSecretName={secret.name} ? "text-yellow-600"
secretPath={secretPath} : "text-red-600"
environment={environment} }
/> icon={faWarning}
</ModalContent> />
</Modal> </Tooltip>
); )}
})} </div>
<div className="flex items-center space-x-2 px-4 py-2">
<Button
size="xs"
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
isDisabled={isRevoking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
>
Generate
</Button>
{secret.status === DynamicSecretStatus.FailedDeletion && (
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
<Button
size="xs"
className="m-0 py-0.5 px-2"
colorSchema="danger"
isDisabled={isRevoking}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", {
...secret,
isForced: true
});
}}
>
Force Delete
</Button>
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-dynamic-secret"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("updateDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevoking}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-dynamic-secret"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("deleteDynamicSecret", secret);
}}
isDisabled={!isAllowed || isRevoking}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
<ModalContent
title="Dynamic secret leases"
subTitle="Revoke or renew your secret leases"
className="max-w-3xl"
>
<DynamicSecretLease
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug}
key={secret.id}
dynamicSecretName={secret.name}
secretPath={secretPath}
environment={environment}
/>
</ModalContent>
</Modal>
);
})}
<Modal <Modal
isOpen={popUp.createDynamicSecretLease.isOpen} isOpen={popUp.createDynamicSecretLease.isOpen}
onOpenChange={(state) => handlePopUpToggle("createDynamicSecretLease", state)} onOpenChange={(state) => handlePopUpToggle("createDynamicSecretLease", state)}
@@ -11,7 +11,6 @@ import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { SortDir } from "../../SecretMainPage.types";
import { FolderForm } from "../ActionBar/FolderForm"; import { FolderForm } from "../ActionBar/FolderForm";
type Props = { type Props = {
@@ -19,17 +18,13 @@ type Props = {
environment: string; environment: string;
workspaceId: string; workspaceId: string;
secretPath?: string; secretPath?: string;
sortDir: SortDir;
searchTerm?: string;
}; };
export const FolderListView = ({ export const FolderListView = ({
folders = [], folders = [],
environment, environment,
workspaceId, workspaceId,
searchTerm, secretPath = "/"
secretPath = "/",
sortDir = SortDir.ASC
}: Props) => { }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"updateFolder", "updateFolder",
@@ -104,84 +99,77 @@ export const FolderListView = ({
return ( return (
<> <>
{folders {folders.map(({ name, id }) => (
.filter(({ name }) => name.toUpperCase().includes(String(searchTerm?.toUpperCase()))) <div
.sort((a, b) => key={id}
sortDir === SortDir.ASC className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) >
: b.name.toLowerCase().localeCompare(a.name.toLowerCase()) <div className="flex w-11 items-center px-5 py-3 text-yellow-700">
) <FontAwesomeIcon icon={faFolder} />
.map(({ name, id }) => (
<div
key={id}
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
</div>
<div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleFolderClick(name);
}}
onClick={() => handleFolderClick(name)}
>
{name}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div> </div>
))} <div
className="flex flex-grow items-center px-4 py-3"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleFolderClick(name);
}}
onClick={() => handleFolderClick(name)}
>
{name}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
))}
<Modal <Modal
isOpen={popUp.updateFolder.isOpen} isOpen={popUp.updateFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
@@ -14,9 +14,10 @@ import { Button, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/types"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
@@ -190,6 +191,9 @@ export const SecretDropzone = ({
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId })); queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
handlePopUpClose("overlapKeyWarning"); handlePopUpClose("overlapKeyWarning");
createNotification({ createNotification({
@@ -291,7 +291,7 @@ export const SecretImportItem = ({
<tr> <tr>
<td style={{ padding: "0.25rem 1rem" }}>Key</td> <td style={{ padding: "0.25rem 1rem" }}>Key</td>
<td style={{ padding: "0.25rem 1rem" }}>Value</td> <td style={{ padding: "0.25rem 1rem" }}>Value</td>
<td style={{ padding: "0.25rem 1rem" }}>Override</td> {/* <td style={{ padding: "0.25rem 1rem" }}>Override</td> */}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -304,7 +304,7 @@ export const SecretImportItem = ({
)} )}
{importedSecrets {importedSecrets
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase())) .filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
.map(({ key, value, overriden }, index) => ( .map(({ key, value }, index) => (
<tr key={`${id}-${key}-${index + 1}`}> <tr key={`${id}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key} {key}
@@ -312,9 +312,9 @@ export const SecretImportItem = ({
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<SecretInput value={value} isReadOnly /> <SecretInput value={value} isReadOnly />
</td> </td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}> {/* <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} /> <EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
</td> </td> */}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -90,18 +90,18 @@ type Props = {
secretPath?: string; secretPath?: string;
secretImports?: TSecretImport[]; secretImports?: TSecretImport[];
isFetching?: boolean; isFetching?: boolean;
secrets?: SecretV3RawSanitized[]; // secrets?: SecretV3RawSanitized[];
importedSecrets?: TImportedSecrets; importedSecrets?: TImportedSecrets;
searchTerm: string; searchTerm: string;
}; };
export const SecretImportListView = ({ export const SecretImportListView = ({
secretImports = [], secretImports,
environment, environment,
workspaceId, workspaceId,
secretPath, secretPath,
importedSecrets, importedSecrets,
secrets = [], // secrets = [],
isFetching, isFetching,
searchTerm searchTerm
}: Props) => { }: Props) => {
@@ -117,11 +117,11 @@ export const SecretImportListView = ({
useSensor(KeyboardSensor, {}) useSensor(KeyboardSensor, {})
); );
const [items, setItems] = useState(secretImports); const [items, setItems] = useState(secretImports ?? []);
useEffect(() => { useEffect(() => {
if (!isFetching) { if (!isFetching) {
setItems(secretImports); setItems(secretImports ?? []);
} }
}, [isFetching, secretImports]); }, [isFetching, secretImports]);
@@ -170,7 +170,7 @@ export const SecretImportListView = ({
}; };
const handleOpenReplicationSecrets = (replicationImportId: string) => { const handleOpenReplicationSecrets = (replicationImportId: string) => {
const reservedImport = secretImports.find( const reservedImport = secretImports?.find(
({ isReserved, importPath, importEnv }) => ({ isReserved, importPath, importEnv }) =>
importEnv.slug === environment && importEnv.slug === environment &&
isReserved && isReserved &&
@@ -208,8 +208,8 @@ export const SecretImportListView = ({
importedSecrets={computeImportedSecretRows( importedSecrets={computeImportedSecretRows(
item.importEnv.slug, item.importEnv.slug,
item.importPath, item.importPath,
importedSecrets, importedSecrets
secrets // secrets scott - now that secrets are paginated we are not showing if they are overridden (yet?)
)} )}
secretPath={secretPath} secretPath={secretPath}
environment={environment} environment={environment}
@@ -7,6 +7,7 @@ import { CreateTagModal } from "@app/components/tags/CreateTagModal";
import { DeleteActionModal } from "@app/components/v2"; import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries"; import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries"; import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
@@ -213,6 +214,12 @@ export const SecretListView = ({
}); });
if (cb) cb(); if (cb) cb();
} }
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId: workspaceId,
secretPath
})
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -246,6 +253,9 @@ export const SecretListView = ({
try { try {
await handleSecretOperation("delete", SecretType.Shared, key, { secretId }); await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
// wrap this in another function and then reuse // wrap this in another function and then reuse
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -1,20 +1,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { import {
faAngleDown, faAngleDown,
faArrowDown, faArrowDown,
faArrowUp, faArrowUp,
faFingerprint,
faFolder,
faFolderBlank, faFolderBlank,
faFolderPlus, faFolderPlus,
faKey,
faList, faList,
faMagnifyingGlass, 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";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -55,22 +59,25 @@ import {
useCreateFolder, useCreateFolder,
useCreateSecretV3, useCreateSecretV3,
useDeleteSecretV3, useDeleteSecretV3,
useGetDynamicSecretsOfAllEnv,
useGetFoldersByEnv,
useGetImportedSecretsAllEnvs, useGetImportedSecretsAllEnvs,
useGetProjectSecretsAllEnv,
useUpdateSecretV3 useUpdateSecretV3
} from "@app/hooks/api"; } from "@app/hooks/api";
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
import { SecretType, TSecretFolder } from "@app/hooks/api/types"; import { SecretType, TSecretFolder } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
import { SecretOverviewTableRow } from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow";
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount";
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
import { CreateSecretForm } from "./components/CreateSecretForm"; import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs"; import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection"; import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection";
import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel";
@@ -82,9 +89,12 @@ export enum EntryType {
enum RowType { enum RowType {
Folder = "folder", Folder = "folder",
DynamicSecret = "dynamic", DynamicSecret = "dynamic",
Secret = "Secret" Secret = "secret"
} }
type Filter = {
[key in RowType]: boolean;
};
const INIT_PER_PAGE = 20; const INIT_PER_PAGE = 20;
export const SecretOverviewPage = () => { export const SecretOverviewPage = () => {
@@ -96,7 +106,7 @@ export const SecretOverviewPage = () => {
// coz when overflow the table goes to the right // coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null); const parentTableRef = useRef<HTMLTableElement>(null);
const [expandableTableWidth, setExpandableTableWidth] = useState(0); const [expandableTableWidth, setExpandableTableWidth] = useState(0);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
useEffect(() => { useEffect(() => {
@@ -106,6 +116,7 @@ export const SecretOverviewPage = () => {
}, [parentTableRef.current]); }, [parentTableRef.current]);
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace(); const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3;
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const workspaceId = currentWorkspace?.id as string; const workspaceId = currentWorkspace?.id as string;
const projectSlug = currentWorkspace?.slug as string; const projectSlug = currentWorkspace?.slug as string;
@@ -113,6 +124,12 @@ export const SecretOverviewPage = () => {
const debouncedSearchFilter = useDebounce(searchFilter); const debouncedSearchFilter = useDebounce(searchFilter);
const secretPath = (router.query?.secretPath as string) || "/"; const secretPath = (router.query?.secretPath as string) || "/";
const [filter, setFilter] = useState<Filter>({
[RowType.Folder]: true,
[RowType.DynamicSecret]: true,
[RowType.Secret]: true
});
const [selectedEntries, setSelectedEntries] = useState<{ const [selectedEntries, setSelectedEntries] = useState<{
[EntryType.FOLDER]: Record<string, boolean>; [EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>; [EntryType.SECRET]: Record<string, boolean>;
@@ -173,61 +190,72 @@ export const SecretOverviewPage = () => {
}, [isWorkspaceLoading, workspaceId, router.isReady]); }, [isWorkspaceLoading, workspaceId, router.isReady]);
const userAvailableEnvs = currentWorkspace?.environments || []; const userAvailableEnvs = currentWorkspace?.environments || [];
const [visibleEnvs, setVisibleEnvs] = useState(
userAvailableEnvs?.filter(({ slug }) => const readableEnvs = userAvailableEnvs?.filter(({ slug }) =>
permission.can( permission.can(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
environment: slug, environment: slug,
secretPath secretPath
}) })
)
) )
); );
const [visibleEnvs, setVisibleEnvs] = useState(readableEnvs);
useEffect(() => { useEffect(() => {
setVisibleEnvs( setVisibleEnvs(readableEnvs);
userAvailableEnvs?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
)
);
}, [userAvailableEnvs, secretPath]); }, [userAvailableEnvs, secretPath]);
const {
data: secrets,
getSecretByKey,
secKeys,
getEnvSecretKeyCount
} = useGetProjectSecretsAllEnv({
workspaceId,
envs: userAvailableEnvs.map(({ slug }) => slug),
secretPath
});
const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({
projectId: workspaceId,
path: secretPath,
environments: userAvailableEnvs.map(({ slug }) => slug)
});
const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({ const { isImportedSecretPresentInEnv, getImportedSecretByKey } = useGetImportedSecretsAllEnvs({
projectId: workspaceId, projectId: workspaceId,
path: secretPath, path: secretPath,
environments: userAvailableEnvs.map(({ slug }) => slug) environments: userAvailableEnvs.map(({ slug }) => slug)
}); });
const { dynamicSecretNames, dynamicSecrets, isDynamicSecretPresentInEnv } = const paginationOffset = (page - 1) * perPage;
useGetDynamicSecretsOfAllEnv({
projectSlug, const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
environmentSlugs: userAvailableEnvs.map(({ slug }) => slug), {
path: secretPath projectId: workspaceId,
}); environments: visibleEnvs.map((env) => env.slug),
secretPath,
orderDirection,
orderBy: DashboardSecretsOrderBy.Name,
includeFolders: filter.folder,
includeDynamicSecrets: filter.dynamic,
includeSecrets: filter.secret,
search: debouncedSearchFilter,
limit: perPage,
offset: paginationOffset
},
{ enabled: isProjectV3 }
);
const {
secrets,
folders,
dynamicSecrets,
totalCount = 0,
totalFolderCount,
totalSecretCount,
totalDynamicSecretCount
} = overview ?? {};
const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(
folders,
orderDirection
);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } = useDynamicSecretOverview(
dynamicSecrets,
orderDirection
);
const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(
secrets,
orderDirection
);
const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
@@ -472,40 +500,23 @@ export const SecretOverviewPage = () => {
} }
}; };
const rows = useMemo(() => {
const filteredSecretNames =
secKeys
?.filter((name) => name.toUpperCase().includes(debouncedSearchFilter.toUpperCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
const filteredFolderNames =
folderNames
?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
const filteredDynamicSecrets =
dynamicSecretNames
?.filter((name) => name.toLowerCase().includes(debouncedSearchFilter.toLowerCase()))
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a))) ?? [];
return [
...filteredFolderNames.map((name) => ({ name, type: RowType.Folder })),
...filteredDynamicSecrets.map((name) => ({ name, type: RowType.DynamicSecret })),
...filteredSecretNames.map((name) => ({ name, type: RowType.Secret }))
];
}, [sortDir, debouncedSearchFilter, secKeys, folderNames, dynamicSecretNames]);
const paginationOffset = (page - 1) * perPage;
useEffect(() => { useEffect(() => {
// reset page if no longer valid // reset page if no longer valid
if (rows.length < paginationOffset) setPage(1); if (totalCount < paginationOffset) setPage(1);
}, [rows.length]); }, [totalCount]);
const isTableLoading = const handleToggleRowType = useCallback(
folders?.some(({ isLoading }) => isLoading) || (rowType: RowType) =>
secrets?.some(({ isLoading }) => isLoading) || setFilter((state) => {
dynamicSecrets?.some(({ isLoading }) => isLoading); return {
...state,
[rowType]: !state[rowType]
};
}),
[]
);
if (isWorkspaceLoading || isTableLoading) { 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]">
<img <img
@@ -524,17 +535,22 @@ export const SecretOverviewPage = () => {
// This is needed to also show imports from other paths – right now those are missing. // This is needed to also show imports from other paths – right now those are missing.
// const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()];
const isTableEmpty = const isTableEmpty = totalCount === 0;
!(
folders?.every(({ isLoading }) => isLoading) && const isTableFiltered =
secrets?.every(({ isLoading }) => isLoading) && Boolean(Object.values(filter).filter((enabled) => !enabled).length) ||
dynamicSecrets?.every(({ isLoading }) => isLoading) visibleEnvs.length !== readableEnvs?.length;
) && rows.length === 0;
if (!isProjectV3)
return (
<div className="container flex h-full w-full flex-col items-center justify-center px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
</div>
);
return ( return (
<> <>
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative right-5 ml-4"> <div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated /> <NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div> </div>
@@ -591,7 +607,10 @@ export const SecretOverviewPage = () => {
ariaLabel="Environments" ariaLabel="Environments"
variant="plain" variant="plain"
size="sm" size="sm"
className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10" className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
> >
<Tooltip content="Choose visible environments" className="mb-2"> <Tooltip content="Choose visible environments" className="mb-2">
<FontAwesomeIcon icon={faList} /> <FontAwesomeIcon icon={faList} />
@@ -600,37 +619,25 @@ export const SecretOverviewPage = () => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel> <DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs {readableEnvs.map((availableEnv) => {
.filter(({ slug }) => const { id: envId, name } = availableEnv;
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
)
.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleEnvSelect(envId)} onClick={(e) => {
key={envId} e.preventDefault();
icon={ handleEnvSelect(envId);
isEnvSelected ? ( }}
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> key={envId}
) : ( disabled={visibleEnvs?.length === 1}
<FontAwesomeIcon className="text-mineshaft-400" icon={faCircle} /> icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
) iconPos="right"
} >
iconPos="left" <div className="flex items-center">{name}</div>
> </DropdownMenuItem>
<div className="flex items-center">{name}</div> );
</DropdownMenuItem> })}
);
})}
{/* <DropdownMenuItem className="px-1.5" asChild> {/* <DropdownMenuItem className="px-1.5" asChild>
<Button <Button
size="xs" size="xs"
@@ -643,6 +650,48 @@ export const SecretOverviewPage = () => {
Create an environment Create an environment
</Button> </Button>
</DropdownMenuItem> */} </DropdownMenuItem> */}
<DropdownMenuLabel>Filter project resources</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Folder);
}}
icon={filter[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();
handleToggleRowType(RowType.DynamicSecret);
}}
icon={
filter[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();
handleToggleRowType(RowType.Secret);
}}
icon={filter[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>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -727,9 +776,17 @@ export const SecretOverviewPage = () => {
variant="plain" variant="plain"
className="ml-2" className="ml-2"
ariaLabel="sort" ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))} onClick={() =>
setOrderDirection((prev) =>
prev === OrderByDirection.ASC
? OrderByDirection.DESC
: OrderByDirection.ASC
)
}
> >
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} /> <FontAwesomeIcon
icon={orderDirection === "asc" ? faArrowDown : faArrowUp}
/>
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
@@ -766,7 +823,7 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{canViewOverviewPage && isTableLoading && ( {canViewOverviewPage && isOverviewLoading && (
<TableSkeleton <TableSkeleton
columns={visibleEnvs.length + 1} columns={visibleEnvs.length + 1}
innerKey="secret-overview-loading" innerKey="secret-overview-loading"
@@ -808,7 +865,7 @@ export const SecretOverviewPage = () => {
</Td> </Td>
</Tr> </Tr>
)} )}
{isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && ( {isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && (
<Tr> <Tr>
<Td colSpan={visibleEnvs.length + 1}> <Td colSpan={visibleEnvs.length + 1}>
<EmptyState <EmptyState
@@ -833,60 +890,51 @@ export const SecretOverviewPage = () => {
</Td> </Td>
</Tr> </Tr>
)} )}
{!isTableLoading && {!isOverviewLoading && visibleEnvs.length > 0 && (
rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => { <>
switch (row.type) { {folderNames.map((folderName, index) => (
case RowType.Secret: <SecretOverviewFolderRow
if (visibleEnvs?.length === 0) return null; folderName={folderName}
return ( isFolderPresentInEnv={isFolderPresentInEnv}
<SecretOverviewTableRow isSelected={selectedEntries.folder[folderName]}
isSelected={selectedEntries.secret[row.name]} onToggleFolderSelect={() =>
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.FOLDER, folderName)
toggleSelectedEntry(EntryType.SECRET, row.name) }
} environments={visibleEnvs}
secretPath={secretPath} key={`overview-${folderName}-${index + 1}`}
getImportedSecretByKey={getImportedSecretByKey} onClick={handleFolderClick}
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onToggleFolderEdit={(name: string) =>
onSecretCreate={handleSecretCreate} handlePopUpOpen("updateFolder", { name })
onSecretDelete={handleSecretDelete} }
onSecretUpdate={handleSecretUpdate} />
key={`overview-${row.name}-${index + 1}`} ))}
environments={visibleEnvs} {dynamicSecretNames.map((dynamicSecretName, index) => (
secretKey={row.name} <SecretOverviewDynamicSecretRow
getSecretByKey={getSecretByKey} dynamicSecretName={dynamicSecretName}
expandableColWidth={expandableTableWidth} isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
/> environments={visibleEnvs}
); key={`overview-${dynamicSecretName}-${index + 1}`}
case RowType.DynamicSecret: />
return ( ))}
<SecretOverviewDynamicSecretRow {secKeys.map((key, index) => (
dynamicSecretName={row.name} <SecretOverviewTableRow
isDynamicSecretInEnv={isDynamicSecretPresentInEnv} isSelected={selectedEntries.secret[key]}
environments={visibleEnvs} onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
key={`overview-${row.name}-${index + 1}`} secretPath={secretPath}
/> getImportedSecretByKey={getImportedSecretByKey}
); isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
case RowType.Folder: onSecretCreate={handleSecretCreate}
return ( onSecretDelete={handleSecretDelete}
<SecretOverviewFolderRow onSecretUpdate={handleSecretUpdate}
folderName={row.name} key={`overview-${key}-${index + 1}`}
isFolderPresentInEnv={isFolderPresentInEnv} environments={visibleEnvs}
isSelected={selectedEntries.folder[row.name]} secretKey={key}
onToggleFolderSelect={() => getSecretByKey={getSecretByKey}
toggleSelectedEntry(EntryType.FOLDER, row.name) expandableColWidth={expandableTableWidth}
} />
environments={visibleEnvs} ))}
key={`overview-${row.name}-${index + 1}`} </>
onClick={handleFolderClick} )}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
);
default:
return null;
}
})}
</TBody> </TBody>
<TFoot> <TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800"> <Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
@@ -913,10 +961,17 @@ export const SecretOverviewPage = () => {
</Tr> </Tr>
</TFoot> </TFoot>
</Table> </Table>
{!isTableLoading && rows.length > INIT_PER_PAGE && ( {!isOverviewLoading && totalCount > INIT_PER_PAGE && (
<Pagination <Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="border-t border-solid border-t-mineshaft-600" className="border-t border-solid border-t-mineshaft-600"
count={rows.length} count={totalCount}
page={page} page={page}
perPage={perPage} perPage={perPage}
onChangePage={(newPage) => setPage(newPage)} onChangePage={(newPage) => setPage(newPage)}
@@ -0,0 +1,45 @@
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
folderCount?: number;
importCount?: number;
secretCount?: number;
dynamicSecretCount?: number;
};
export const SecretTableResourceCount = ({
folderCount = 0,
dynamicSecretCount = 0,
secretCount = 0,
importCount = 0
}: Props) => {
return (
<div className="flex items-center gap-2 divide-x divide-mineshaft-500 text-sm text-mineshaft-400">
{importCount > 0 && (
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className=" text-green-700" />
<span>{importCount}</span>
</div>
)}
{folderCount > 0 && (
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
<span>{folderCount}</span>
</div>
)}
{dynamicSecretCount > 0 && (
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
<span>{dynamicSecretCount}</span>
</div>
)}
{secretCount > 0 && (
<div className="flex items-center gap-2 pl-2">
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
<span>{secretCount}</span>
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export { SecretTableResourceCount } from "./SecretTableResourceCount";
@@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -78,7 +78,7 @@ export const SecretV2MigrationSection = () => {
const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin); const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin);
return ( return (
<div className="mt-4 rounded-lg border border-primary-600 bg-mineshaft-900 p-4"> <div className="mt-4 flex max-w-2xl flex-col rounded-lg border border-primary/50 bg-primary/10 px-6 py-5">
{isUpgrading && ( {isUpgrading && (
<div className="absolute top-0 left-0 z-50 flex h-screen w-screen items-center justify-center bg-bunker-500 bg-opacity-80"> <div className="absolute top-0 left-0 z-50 flex h-screen w-screen items-center justify-center bg-bunker-500 bg-opacity-80">
<Spinner size="lg" className="text-primary" /> <Spinner size="lg" className="text-primary" />
@@ -88,18 +88,30 @@ export const SecretV2MigrationSection = () => {
</div> </div>
</div> </div>
)} )}
<p className="mb-2 text-lg font-semibold">Action Required</p> <div className="mb-4 flex items-start gap-2">
<p className="mb-4 leading-7 text-gray-400"> <FontAwesomeIcon icon={faWarning} size="xl" className="mt-1 text-primary" />
Infisical secrets engine is now 10x faster and allows you to encrypt secrets with your own <p className="text-xl font-semibold">
KMS. Upgrade your project to receive these improvements. Secrets Dashboard no longer supports your project version
</p>
</div>
<p className="mx-1 mb-4 leading-7 text-mineshaft-300">
While you can still access your project&apos;s secrets through the API, Infisical encourages
you to upgrade your project to continue using the Dashboard experience.
</p>
<p className="mx-1 mb-4 leading-7 text-mineshaft-300">
Upgrading your project uses Infisical&apos;s new secrets engine, which is 10x faster and
allows you to encrypt secrets with your own KMS.
</p>
<p className="mx-1 mb-6 leading-7 text-mineshaft-300">
This update should only take a few moments and cannot be ran in the background.
</p> </p>
<Button <Button
onClick={() => handlePopUpOpen("migrationInfo")} onClick={() => handlePopUpOpen("migrationInfo")}
isDisabled={!isAdmin || isUpgrading} isDisabled={!isAdmin || isUpgrading}
color="mineshaft"
isLoading={migrateProjectToV3.isLoading} isLoading={migrateProjectToV3.isLoading}
className="w-full "
> >
{ isAdmin ? "Upgrade Project" : "Upgrade requires admin privilege"} {isAdmin ? "Upgrade Project" : "Upgrade requires admin privilege"}
</Button> </Button>
{didProjectUpgradeFailed && ( {didProjectUpgradeFailed && (
<p className="mt-2 text-sm leading-7 text-red-400"> <p className="mt-2 text-sm leading-7 text-red-400">