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 2900 additions and 722 deletions

View File

@@ -1,10 +1,67 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
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 const dynamicSecretDALFactory = (db: TDbClient) => {
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 };
};

View File

@@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
@@ -31,7 +32,7 @@ type TDynamicSecretServiceFactoryDep = {
"pruneDynamicSecret" | "unsetLeaseRevocation"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
@@ -300,19 +301,75 @@ export const dynamicSecretServiceFactory = ({
return { ...dynamicSecretCfg, inputs: providerInputs };
};
const list = async ({
// get unique dynamic secret count across multiple envs
const getCountMultiEnv = async ({
actorAuthMethod,
actorOrgId,
actorId,
actor,
projectSlug,
path,
environmentSlug
}: TListDynamicSecretsDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
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) => {
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 projectId = project.id;
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
@@ -328,15 +385,126 @@ export const dynamicSecretServiceFactory = ({
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 });
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;
};
// 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 {
create,
updateByName,
deleteByName,
getDetails,
list
list,
listMultiEnv,
getCount,
getCountMultiEnv
};
};

View File

@@ -1,6 +1,7 @@
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";
@@ -50,5 +51,11 @@ export type TDetailsDynamicSecretDTO = {
export type TListDynamicSecretsDTO = {
path: string;
environmentSlug: string;
projectSlug: string;
projectSlug?: string;
projectId?: string;
offset?: number;
limit?: number;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
search?: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -697,6 +697,38 @@ export const SECRET_IMPORTS = {
}
} 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: {
workspaceId: "The ID of the project to export audit logs from.",

View File

@@ -51,11 +51,17 @@ export type TFindReturn<TQuery extends Knex.QueryBuilder, TCount extends boolean
: 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;
offset?: number;
sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>;
groupBy?: keyof R;
count?: TCount;
countDistinct?: TCountDistinct;
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" });
}
},
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"]>,
{ offset, limit, sort, count, tx }: TFindOpt<Tables[Tname]["base"], TCount> = {}
{ offset, limit, sort, count, tx, countDistinct }: TFindOpt<Tables[Tname]["base"], TCount, TCountDistinct> = {}
) => {
try {
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("*");
}
@@ -101,7 +112,8 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
if (sort) {
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;
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });

View File

@@ -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)
};
}
});
};

View File

@@ -1,3 +1,4 @@
import { registerDashboardRouter } from "./dashboard-router";
import { registerLoginRouter } from "./login-router";
import { registerSecretBlindIndexRouter } from "./secret-blind-index-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(registerSecretRouter, { prefix: "/secrets" });
await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" });
await server.register(registerDashboardRouter, { prefix: "/dashboard" });
};

View File

@@ -5,6 +5,8 @@ import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate }
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { groupBy, removeTrailingSlash } from "@app/lib/fn";
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) => {
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");
};
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
const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath;
// next goal to sanitize saw the raw sql query is safe
@@ -111,7 +113,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
projectId,
parentId: null
})
.where(`${TableName.Environment}.slug`, environment)
.whereIn(`${TableName.Environment}.slug`, environments)
.select(selectAllTableCols(TableName.SecretFolder))
.union(
(qb) =>
@@ -139,14 +141,14 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
.from<TSecretFolders & { depth: number; path: string }>("parent")
.leftJoin<TProjectEnvironments>(TableName.Environment, `${TableName.Environment}.id`, "parent.envId")
.select<
TSecretFolders & {
(TSecretFolders & {
depth: number;
path: string;
envId: string;
envSlug: string;
envName: string;
projectId: string;
}
})[]
>(
selectAllTableCols("parent" as TableName.SecretFolder),
db.ref("id").withSchema(TableName.Environment).as("envId"),
@@ -214,7 +216,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
const folder = await sqlFindFolderByPathQuery(
tx || db.replicaNode(),
projectId,
environment,
[environment],
removeTrailingSlash(path)
)
.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
// even if its the original given /path1/path2
// it will stop automatically at /path2
@@ -238,7 +269,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
const folder = await sqlFindFolderByPathQuery(
tx || db.replicaNode(),
projectId,
environment,
[environment],
removeTrailingSlash(path)
)
.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 {
...secretFolderOrm,
update,
findBySecretPath,
findBySecretPathMultiEnv,
findById,
findByManySecretPath,
findSecretPathByFolderIds,
findClosestFolder,
findByProjectId
findByProjectId,
findByMultiEnv
};
};

View File

@@ -2,11 +2,12 @@ import { ForbiddenError, subject } from "@casl/ability";
import path from "path";
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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -26,7 +27,7 @@ type TSecretFolderServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
folderVersionDAL: TSecretFolderVersionDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
};
@@ -396,7 +397,12 @@ export const secretFolderServiceFactory = ({
actorOrgId,
actorAuthMethod,
environment,
path: secretPath
path: secretPath,
search,
orderBy,
orderDirection,
limit,
offset
}: TGetFolderDTO) => {
// folder list is allowed to be read by anyone
// permission to check does user has access
@@ -408,11 +414,100 @@ export const secretFolderServiceFactory = ({
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
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;
};
// 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 folder = await folderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: "folder not found" });
@@ -429,6 +524,8 @@ export const secretFolderServiceFactory = ({
updateManyFolders,
deleteFolder,
getFolders,
getFolderById
getFolderById,
getProjectFolderCount,
getFoldersMultiEnv
};
};

View File

@@ -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 {
SecretReplication = "__reserve_replication_"
@@ -36,6 +37,11 @@ export type TDeleteFolderDTO = {
export type TGetFolderDTO = {
environment: string;
path: string;
search?: string;
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetFolderByIdDTO = {

View File

@@ -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 {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
const query = (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`)
.select(
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")
)
.orderBy("position", "asc");
if (limit) {
void query.limit(limit).offset(offset ?? 0);
}
const docs = await query;
return docs.map(({ envId, slug, name, ...el }) => ({
...el,
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) => {
try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
@@ -97,6 +145,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
find,
findByFolderIds,
findLastImportPosition,
updateAllPosition
updateAllPosition,
getProjectImportCount
};
};

View File

@@ -394,14 +394,15 @@ export const secretImportServiceFactory = ({
return { message: "replication started" };
};
const getImports = async ({
const getProjectImportCount = async ({
path: secretPath,
environment,
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId
actorOrgId,
search
}: TGetSecretImportsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -418,7 +419,39 @@ export const secretImportServiceFactory = ({
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 });
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;
};
@@ -531,6 +564,7 @@ export const secretImportServiceFactory = ({
getSecretsFromImports,
getRawSecretsFromImports,
resyncSecretImportReplication,
getProjectImportCount,
fnSecretsFromImports
};
};

View File

@@ -32,6 +32,9 @@ export type TDeleteSecretImportDTO = {
export type TGetSecretImportsDTO = {
environment: string;
path: string;
search?: string;
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetSecretsFromImportDTO = {

View File

@@ -5,6 +5,8 @@ import { TDbClient } from "@app/db";
import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
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>;
@@ -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 {
// 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)) {
@@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
userId = undefined;
}
const secs = await (tx || db.replicaNode())(TableName.SecretV2)
const query = (tx || db.replicaNode())(TableName.SecretV2)
.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) => {
void bd.whereNull("userId").orWhere({ userId: userId || null });
})
@@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${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("color").withSchema(TableName.SecretTag).as("tagColor"))
.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({
data: secs,
@@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
findBySecretKeys,
upsertSecretReferences,
findReferencedSecretReferences,
findAllProjectSecretValues
findAllProjectSecretValues,
countByFolderIds
};
};

View File

@@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
folderDAL: Pick<
TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find"
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv"
>;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
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 ({
actorId,
path,
@@ -441,8 +610,8 @@ export const secretV2BridgeServiceFactory = ({
actorAuthMethod,
includeImports,
recursive,
tagSlugs = [],
expandSecretReferences: shouldExpandSecretReferences
expandSecretReferences: shouldExpandSecretReferences,
...params
}: TGetSecretsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -490,7 +659,9 @@ export const secretV2BridgeServiceFactory = ({
const secrets = await secretDAL.findByFolderIds(
paths.map((p) => p.folderId),
actorId
actorId,
undefined,
params
);
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({
projectId,
folderDAL,
@@ -520,7 +689,7 @@ export const secretV2BridgeServiceFactory = ({
});
if (shouldExpandSecretReferences) {
const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath);
const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
await Promise.allSettled(
Object.keys(secretsGroupByPath).map((groupedPath) =>
Promise.allSettled(
@@ -541,7 +710,7 @@ export const secretV2BridgeServiceFactory = ({
if (!includeImports) {
return {
secrets: filteredSecrets
secrets: decryptedSecrets
};
}
@@ -569,7 +738,7 @@ export const secretV2BridgeServiceFactory = ({
});
return {
secrets: filteredSecrets,
secrets: decryptedSecrets,
imports: importedSecrets
};
};
@@ -1416,6 +1585,9 @@ export const secretV2BridgeServiceFactory = ({
getSecrets,
getSecretVersions,
backfillSecretReferences,
moveSecrets
moveSecrets,
getSecretsCount,
getSecretsCountMultiEnv,
getSecretsMultiEnv
};
};

View File

@@ -1,8 +1,9 @@
import { Knex } from "knex";
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 { SecretsOrderBy } from "@app/services/secret/secret-types";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
@@ -21,6 +22,11 @@ export type TGetSecretsDTO = {
includeImports?: boolean;
recursive?: boolean;
tagSlugs?: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
offset?: number;
limit?: number;
search?: string;
} & TProjectPermission;
export type TGetASecretDTO = {

View File

@@ -954,6 +954,120 @@ export const secretServiceFactory = ({
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 ({
projectId,
path,
@@ -965,7 +1079,8 @@ export const secretServiceFactory = ({
includeImports,
expandSecretReferences,
recursive,
tagSlugs = []
tagSlugs = [],
...paramsV2
}: TGetSecretsRawDTO) => {
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) {
@@ -980,7 +1095,8 @@ export const secretServiceFactory = ({
recursive,
actorAuthMethod,
includeImports,
tagSlugs
tagSlugs,
...paramsV2
});
return { secrets, imports };
}
@@ -2656,6 +2772,9 @@ export const secretServiceFactory = ({
getSecretVersions,
backfillSecretReferences,
moveSecrets,
startSecretV2Migration
startSecretV2Migration,
getSecretsCount,
getSecretsCountMultiEnv,
getSecretsRawMultiEnv
};
};

View File

@@ -1,7 +1,7 @@
import { Knex } from "knex";
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 { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
@@ -81,6 +81,8 @@ export type TGetSecretsDTO = {
environment: string;
includeImports?: boolean;
recursive?: boolean;
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetASecretDTO = {
@@ -143,6 +145,10 @@ export type TDeleteBulkSecretDTO = {
}>;
} & TProjectPermission;
export enum SecretsOrderBy {
Name = "name" // "key" for secrets but using name for use across resources
}
export type TGetSecretsRawDTO = {
expandSecretReferences?: boolean;
path: string;
@@ -150,6 +156,11 @@ export type TGetSecretsRawDTO = {
includeImports?: boolean;
recursive?: boolean;
tagSlugs?: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
offset?: number;
limit?: number;
search?: string;
} & TProjectPermission;
export type TGetASecretRawDTO = {

View File

@@ -100,7 +100,7 @@ export default function NavHeader({
onValueChange={(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"
>
{userAvailableEnvs?.map(({ name, slug }) => (

View File

@@ -1,3 +1,4 @@
import { ReactElement } from "react";
import {
faCaretDown,
faCheck,
@@ -23,6 +24,7 @@ export type PaginationProps = {
onChangePerPage: (newRows: number) => void;
className?: string;
perPageList?: number[];
startAdornment?: ReactElement;
};
export const Pagination = ({
@@ -32,7 +34,8 @@ export const Pagination = ({
onChangePage,
onChangePerPage,
perPageList = [10, 20, 50, 100],
className
className,
startAdornment
}: PaginationProps) => {
const prevPageNumber = Math.max(1, page - 1);
const canGoPrev = page > 1;
@@ -46,11 +49,12 @@ export const Pagination = ({
return (
<div
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
)}
>
<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">
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div>

View File

@@ -0,0 +1 @@
export { useGetProjectSecretsDetails } from "./queries";

View File

@@ -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
});
};

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>;
};

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { dynamicSecretKeys } from "./queries";
import {
@@ -22,6 +23,8 @@ export const useCreateDynamicSecret = () => {
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but we currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
}
});
@@ -39,6 +42,8 @@ export const useUpdateDynamicSecret = () => {
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
}
});
@@ -56,6 +61,8 @@ export const useDeleteDynamicSecret = () => {
return data.dynamicSecret;
},
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
// TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries(dashboardKeys.all());
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
}
});

View File

@@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries";
import {
@@ -124,6 +125,12 @@ export const useCreateFolder = () => {
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
@@ -151,6 +158,12 @@ export const useUpdateFolder = () => {
return data;
},
onSuccess: (_, { projectId, environment, path }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path ?? "/"
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
@@ -179,6 +192,12 @@ export const useDeleteFolder = () => {
return data;
},
onSuccess: (_, { path = "/", projectId, environment }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: path
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({ projectId, environment, path })
);
@@ -206,6 +225,12 @@ export const useUpdateFolderBatch = () => {
},
onSuccess: (_, { projectId, folders }) => {
folders.forEach((folder) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: folder.path ?? "/"
})
);
queryClient.invalidateQueries(
folderQueryKeys.getSecretFolders({
projectId,

View File

@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretImportKeys } from "./queries";
import {
@@ -31,6 +32,9 @@ export const useCreateSecretImport = () => {
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
}
});
};
@@ -55,6 +59,9 @@ export const useUpdateSecretImport = () => {
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ environment, path, projectId })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
}
});
};
@@ -93,6 +100,9 @@ export const useDeleteSecretImport = () => {
queryClient.invalidateQueries(
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
);
}
});
};

View File

@@ -1,6 +1,7 @@
import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
import { secretSnapshotKeys } from "../secretSnapshots/queries";
@@ -44,6 +45,9 @@ export const useCreateSecretV3 = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -96,6 +100,9 @@ export const useUpdateSecretV3 = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -139,6 +146,9 @@ export const useDeleteSecretV3 = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -172,6 +182,9 @@ export const useCreateSecretBatch = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -205,6 +218,9 @@ export const useUpdateSecretBatch = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -240,6 +256,9 @@ export const useDeleteSecretBatch = ({
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -295,6 +314,12 @@ export const useMoveSecrets = ({
return data;
},
onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => {
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId,
secretPath: sourceSecretPath
})
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({
workspaceId: projectId,

View File

@@ -0,0 +1 @@
export * from "./secrets-overview";

View File

@@ -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 };
};

View File

@@ -46,12 +46,12 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { data: identityMembershipOrgsData } = useGetIdentityMembershipOrgs({
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 { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
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;

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
@@ -17,29 +18,28 @@ import {
} from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import {
useGetDynamicSecrets,
useGetImportedSecretsSingleEnv,
useGetProjectFolders,
useGetProjectSecrets,
useGetSecretApprovalPolicyOfABoard,
useGetSecretImports,
useGetWorkspaceSnapshotList,
useGetWsSnapshotCount,
useGetWsTags
} 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 { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { FolderListView } from "./components/FolderListView";
import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone";
import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store";
import { Filter, SortDir } from "./SecretMainPage.types";
import { Filter, RowType } from "./SecretMainPage.types";
const LOADER_TEXT = [
"Retrieving your encrypted secrets...",
@@ -55,12 +55,8 @@ export const SecretMainPage = () => {
const { permission } = useProjectPermission();
const [isVisible, setIsVisible] = useState(false);
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC);
const [filter, setFilter] = useState<Filter>({
tags: {},
searchFilter: (router.query.searchFilter as string) || ""
});
const debouncedSearchFilter = useDebounce(filter.searchFilter);
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const paginationOffset = (page - 1) * perPage;
@@ -83,6 +79,18 @@ export const SecretMainPage = () => {
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(() => {
if (
!isWorkspaceLoading &&
@@ -91,43 +99,43 @@ export const SecretMainPage = () => {
) {
router.push(`/project/${workspaceId}/secrets/overview`);
createNotification({
text: "No envronment found with given slug",
text: "No environment found with given slug",
type: "error"
});
}
}, [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 {
data: secretImports,
isLoading: isSecretImportsLoading,
isFetching: isSecretImportsFetching
} = useGetSecretImports({
projectId: workspaceId,
data,
isLoading: isDetailsLoading,
isFetching: isDetailsFetching
} = useGetProjectSecretsDetails({
environment,
path: secretPath,
options: {
enabled: canReadSecret
}
projectId: workspaceId,
secretPath,
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
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
projectId: workspaceId,
@@ -138,13 +146,7 @@ export const SecretMainPage = () => {
}
});
const { data: dynamicSecrets, isLoading: isDynamicSecretLoading } = useGetDynamicSecrets({
projectSlug,
environmentSlug: environment,
path: secretPath
});
// fech tags
// fetch tags
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
@@ -174,12 +176,14 @@ export const SecretMainPage = () => {
isPaused: !canDoReadRollback
});
const isNotEmtpy = Boolean(
secrets?.length || folders?.length || secretImports?.length || dynamicSecrets?.length
const isNotEmpty = Boolean(
secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length
);
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 query: Record<string, string> = { ...router.query, env: slug };
@@ -191,17 +195,31 @@ export const SecretMainPage = () => {
};
const handleTagToggle = useCallback(
(tagId: string) =>
(tagSlug: string) =>
setFilter((state) => {
const isTagPresent = Boolean(state.tags?.[tagId]);
const isTagPresent = Boolean(state.tags?.[tagSlug]);
const newTagFilter = { ...state.tags };
if (isTagPresent) delete newTagFilter[tagId];
else newTagFilter[tagId] = true;
if (isTagPresent) delete newTagFilter[tagSlug];
else newTagFilter[tagSlug] = true;
return { ...state, tags: newTagFilter };
}),
[]
);
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
include: {
...state.include,
[rowType]: !state.include[rowType]
}
};
}),
[]
);
const handleSearchChange = useCallback(
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
[]
@@ -219,126 +237,18 @@ export const SecretMainPage = () => {
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(() => {
// reset page if no longer valid
if (rows.totalRows < paginationOffset) setPage(1);
}, [rows.totalRows]);
if (totalCount < paginationOffset) setPage(1);
}, [totalCount]);
// loading screen when you don't have permission but as folder's is viewable need to wait for that
const loadingOnDenied = !canReadSecret && isFoldersLoading;
if (loadingOnAccess || loadingOnDenied) {
if (isDetailsLoading) {
return <ContentLoader text={LOADER_TEXT} />;
}
return (
<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 />
<div className="relative right-6 -top-2 mb-2 ml-6">
<NavHeader
@@ -372,17 +282,22 @@ export const SecretMainPage = () => {
isVisible={isVisible}
filter={filter}
tags={tags}
onVisiblilityToggle={handleToggleVisibility}
onVisibilityToggle={handleToggleVisibility}
onSearchChange={handleSearchChange}
onToggleTagFilter={handleTagToggle}
snapshotCount={snapshotCount || 0}
isSnapshotCountLoading={isSnapshotCountLoading}
onToggleRowType={handleToggleRowType}
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">
{isNotEmtpy && (
<div className="flex border-b border-mineshaft-600 font-medium">
{isNotEmpty && (
<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
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
<FontAwesomeIcon
icon={sortDir === SortDir.ASC ? faArrowDown : faArrowUp}
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
className="ml-2"
/>
</div>
<div className="flex-grow px-4 py-2">Value</div>
</div>
)}
{canReadSecret && (
{canReadSecret && imports?.length && (
<SecretImportListView
searchTerm={filter.searchFilter}
secretImports={rows.imports}
isFetching={isSecretImportsLoading || isSecretImportsFetching}
searchTerm={debouncedSearchFilter}
secretImports={imports}
isFetching={isDetailsFetching}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
secrets={secrets}
importedSecrets={importedSecrets}
/>
)}
<FolderListView
folders={rows.folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
sortDir={sortDir}
searchTerm={filter.searchFilter}
/>
{canReadSecret && (
{folders?.length && (
<FolderListView
folders={folders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
/>
)}
{canReadSecret && dynamicSecrets?.length && (
<DynamicSecretListView
sortDir={sortDir}
environment={environment}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecrets={rows.dynamicSecrets || []}
dynamicSecrets={dynamicSecrets}
/>
)}
{canReadSecret && (
{canReadSecret && secrets?.length && (
<SecretListView
secrets={rows.secrets}
secrets={secrets}
tags={tags}
isVisible={isVisible}
environment={environment}
@@ -443,18 +356,26 @@ export const SecretMainPage = () => {
/>
)}
{!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>
{!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
environment={environment}
workspaceId={workspaceId}
@@ -467,7 +388,7 @@ export const SecretMainPage = () => {
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isSmaller={isNotEmtpy}
isSmaller={isNotEmpty}
environments={currentWorkspace?.environments}
isProtectedBranch={isProtectedBranch}
/>

View File

@@ -1,15 +1,14 @@
export type Filter = {
tags: Record<string, boolean>;
searchFilter: string;
include: {
[key in RowType]: boolean;
};
};
export enum SortDir {
ASC = "asc",
DESC = "desc"
}
export enum RowType {
Folder = "folder",
Import = "import",
DynamicSecret = "dynamic",
Secret = "Secret"
Secret = "secret"
}

View File

@@ -13,7 +13,9 @@ import {
faFileImport,
faFilter,
faFingerprint,
faFolder,
faFolderPlus,
faKey,
faMagnifyingGlass,
faMinusSquare,
faPlus,
@@ -62,7 +64,7 @@ import {
useSelectedSecretActions,
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter } from "../../SecretMainPage.types";
import { Filter, RowType } from "../../SecretMainPage.types";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
import { FolderForm } from "./FolderForm";
@@ -83,7 +85,8 @@ type Props = {
isSnapshotCountLoading?: boolean;
onSearchChange: (term: string) => void;
onToggleTagFilter: (tagId: string) => void;
onVisiblilityToggle: () => void;
onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void;
};
@@ -100,8 +103,9 @@ export const ActionBar = ({
isSnapshotCountLoading,
onSearchChange,
onToggleTagFilter,
onVisiblilityToggle,
onClickRollbackMode
onVisibilityToggle,
onClickRollbackMode,
onToggleRowType
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -298,7 +302,9 @@ export const ActionBar = ({
ariaLabel="Download"
className={twMerge(
"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} />
@@ -306,6 +312,60 @@ export const ActionBar = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0">
<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>
<DropdownSubMenuTrigger
iconPos="right"
@@ -319,10 +379,10 @@ export const ActionBar = ({
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onToggleTagFilter(id);
onToggleTagFilter(slug);
}}
key={id}
icon={filter?.tags[id] && <FontAwesomeIcon icon={faCheckCircle} />}
icon={filter?.tags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
@@ -346,7 +406,7 @@ export const ActionBar = ({
</IconButton>
</div>
<div>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisiblilityToggle}>
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisibilityToggle}>
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
</IconButton>
</div>

View File

@@ -27,7 +27,6 @@ import {
TDynamicSecret
} from "@app/hooks/api/dynamicSecret/types";
import { SortDir } from "../../SecretMainPage.types";
import { CreateDynamicSecretLease } from "./CreateDynamicSecretLease";
import { DynamicSecretLease } from "./DynamicSecretLease";
import { EditDynamicSecretForm } from "./EditDynamicSecretForm";
@@ -38,19 +37,17 @@ const formatProviderName = (type: DynamicSecretProviders) => {
};
type Props = {
dynamicSecrets: TDynamicSecret[];
dynamicSecrets?: TDynamicSecret[];
environment: string;
projectSlug: string;
secretPath?: string;
sortDir: SortDir;
};
export const DynamicSecretListView = ({
dynamicSecrets = [],
environment,
projectSlug,
secretPath = "/",
sortDir = SortDir.ASC
secretPath = "/"
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"dynamicSecretLeases",
@@ -59,7 +56,6 @@ export const DynamicSecretListView = ({
"deleteDynamicSecret"
] as const);
const deleteDynamicSecret = useDeleteDynamicSecret();
const handleDynamicSecretDelete = async () => {
@@ -90,158 +86,148 @@ export const DynamicSecretListView = ({
return (
<>
{dynamicSecrets
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
)
.map((secret) => {
const isRevocking = secret.status === DynamicSecretStatus.Deleting;
return (
<Modal
key={secret.id}
isOpen={
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
}
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
{dynamicSecrets.map((secret) => {
const isRevoking = secret.status === DynamicSecretStatus.Deleting;
return (
<Modal
key={secret.id}
isOpen={
popUp.dynamicSecretLeases.isOpen && popUp.dynamicSecretLeases.data === secret.id
}
onOpenChange={(state) => handlePopUpToggle("dynamicSecretLeases", state)}
>
<div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter" && !isRevoking)
handlePopUpOpen("dynamicSecretLeases", secret.id);
}}
onClick={() => {
if (!isRevoking) {
handlePopUpOpen("dynamicSecretLeases", secret.id);
}
}}
>
<div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
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 className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFingerprint} />
</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>
);
})}
<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={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
isOpen={popUp.createDynamicSecretLease.isOpen}
onOpenChange={(state) => handlePopUpToggle("createDynamicSecretLease", state)}

View File

@@ -11,7 +11,6 @@ import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { SortDir } from "../../SecretMainPage.types";
import { FolderForm } from "../ActionBar/FolderForm";
type Props = {
@@ -19,17 +18,13 @@ type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
sortDir: SortDir;
searchTerm?: string;
};
export const FolderListView = ({
folders = [],
environment,
workspaceId,
searchTerm,
secretPath = "/",
sortDir = SortDir.ASC
secretPath = "/"
}: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"updateFolder",
@@ -104,84 +99,77 @@ export const FolderListView = ({
return (
<>
{folders
.filter(({ name }) => name.toUpperCase().includes(String(searchTerm?.toUpperCase())))
.sort((a, b) =>
sortDir === SortDir.ASC
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
)
.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>
{folders.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>
))}
<Modal
isOpen={popUp.updateFolder.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}

View File

@@ -14,9 +14,10 @@ import { Button, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/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 { CopySecretsFromBoard } from "./CopySecretsFromBoard";
@@ -190,6 +191,9 @@ export const SecretDropzone = ({
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(secretApprovalRequestKeys.count({ workspaceId }));
handlePopUpClose("overlapKeyWarning");
createNotification({

View File

@@ -291,7 +291,7 @@ export const SecretImportItem = ({
<tr>
<td style={{ padding: "0.25rem 1rem" }}>Key</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>
</thead>
<tbody>
@@ -304,7 +304,7 @@ export const SecretImportItem = ({
)}
{importedSecrets
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
.map(({ key, value, overriden }, index) => (
.map(({ key, value }, index) => (
<tr key={`${id}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key}
@@ -312,9 +312,9 @@ export const SecretImportItem = ({
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<SecretInput value={value} isReadOnly />
</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} />
</td>
</td> */}
</tr>
))}
</tbody>

View File

@@ -90,18 +90,18 @@ type Props = {
secretPath?: string;
secretImports?: TSecretImport[];
isFetching?: boolean;
secrets?: SecretV3RawSanitized[];
// secrets?: SecretV3RawSanitized[];
importedSecrets?: TImportedSecrets;
searchTerm: string;
};
export const SecretImportListView = ({
secretImports = [],
secretImports,
environment,
workspaceId,
secretPath,
importedSecrets,
secrets = [],
// secrets = [],
isFetching,
searchTerm
}: Props) => {
@@ -117,11 +117,11 @@ export const SecretImportListView = ({
useSensor(KeyboardSensor, {})
);
const [items, setItems] = useState(secretImports);
const [items, setItems] = useState(secretImports ?? []);
useEffect(() => {
if (!isFetching) {
setItems(secretImports);
setItems(secretImports ?? []);
}
}, [isFetching, secretImports]);
@@ -170,7 +170,7 @@ export const SecretImportListView = ({
};
const handleOpenReplicationSecrets = (replicationImportId: string) => {
const reservedImport = secretImports.find(
const reservedImport = secretImports?.find(
({ isReserved, importPath, importEnv }) =>
importEnv.slug === environment &&
isReserved &&
@@ -208,8 +208,8 @@ export const SecretImportListView = ({
importedSecrets={computeImportedSecretRows(
item.importEnv.slug,
item.importPath,
importedSecrets,
secrets
importedSecrets
// secrets scott - now that secrets are paginated we are not showing if they are overridden (yet?)
)}
secretPath={secretPath}
environment={environment}

View File

@@ -7,6 +7,7 @@ import { CreateTagModal } from "@app/components/tags/CreateTagModal";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
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 { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
@@ -213,6 +214,12 @@ export const SecretListView = ({
});
if (cb) cb();
}
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({
projectId: workspaceId,
secretPath
})
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
@@ -246,6 +253,9 @@ export const SecretListView = ({
try {
await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
// wrap this in another function and then reuse
queryClient.invalidateQueries(
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
);
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);

View File

@@ -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 Link from "next/link";
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
faArrowDown,
faArrowUp,
faFingerprint,
faFolder,
faFolderBlank,
faFolderPlus,
faKey,
faList,
faMagnifyingGlass,
faPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import NavHeader from "@app/components/navigation/NavHeader";
import { createNotification } from "@app/components/notifications";
@@ -55,22 +59,25 @@ import {
useCreateFolder,
useCreateSecretV3,
useDeleteSecretV3,
useGetDynamicSecretsOfAllEnv,
useGetFoldersByEnv,
useGetImportedSecretsAllEnvs,
useGetProjectSecretsAllEnv,
useUpdateSecretV3
} 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 { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/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 { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
import { SecretV2MigrationSection } from "./components/SecretV2MigrationSection";
import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel";
@@ -82,9 +89,12 @@ export enum EntryType {
enum RowType {
Folder = "folder",
DynamicSecret = "dynamic",
Secret = "Secret"
Secret = "secret"
}
type Filter = {
[key in RowType]: boolean;
};
const INIT_PER_PAGE = 20;
export const SecretOverviewPage = () => {
@@ -96,7 +106,7 @@ export const SecretOverviewPage = () => {
// coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null);
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const { permission } = useProjectPermission();
useEffect(() => {
@@ -106,6 +116,7 @@ export const SecretOverviewPage = () => {
}, [parentTableRef.current]);
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3;
const { currentOrg } = useOrganization();
const workspaceId = currentWorkspace?.id as string;
const projectSlug = currentWorkspace?.slug as string;
@@ -113,6 +124,12 @@ export const SecretOverviewPage = () => {
const debouncedSearchFilter = useDebounce(searchFilter);
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<{
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
@@ -173,61 +190,72 @@ export const SecretOverviewPage = () => {
}, [isWorkspaceLoading, workspaceId, router.isReady]);
const userAvailableEnvs = currentWorkspace?.environments || [];
const [visibleEnvs, setVisibleEnvs] = useState(
userAvailableEnvs?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
const readableEnvs = userAvailableEnvs?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
);
const [visibleEnvs, setVisibleEnvs] = useState(readableEnvs);
useEffect(() => {
setVisibleEnvs(
userAvailableEnvs?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
)
);
setVisibleEnvs(readableEnvs);
}, [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({
projectId: workspaceId,
path: secretPath,
environments: userAvailableEnvs.map(({ slug }) => slug)
});
const { dynamicSecretNames, dynamicSecrets, isDynamicSecretPresentInEnv } =
useGetDynamicSecretsOfAllEnv({
projectSlug,
environmentSlugs: userAvailableEnvs.map(({ slug }) => slug),
path: secretPath
});
const paginationOffset = (page - 1) * perPage;
const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
{
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: 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(() => {
// reset page if no longer valid
if (rows.length < paginationOffset) setPage(1);
}, [rows.length]);
if (totalCount < paginationOffset) setPage(1);
}, [totalCount]);
const isTableLoading =
folders?.some(({ isLoading }) => isLoading) ||
secrets?.some(({ isLoading }) => isLoading) ||
dynamicSecrets?.some(({ isLoading }) => isLoading);
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
return {
...state,
[rowType]: !state[rowType]
};
}),
[]
);
if (isWorkspaceLoading || isTableLoading) {
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img
@@ -524,17 +535,22 @@ export const SecretOverviewPage = () => {
// 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 isTableEmpty =
!(
folders?.every(({ isLoading }) => isLoading) &&
secrets?.every(({ isLoading }) => isLoading) &&
dynamicSecrets?.every(({ isLoading }) => isLoading)
) && rows.length === 0;
const isTableEmpty = totalCount === 0;
const isTableFiltered =
Boolean(Object.values(filter).filter((enabled) => !enabled).length) ||
visibleEnvs.length !== readableEnvs?.length;
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 (
<>
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<SecretV2MigrationSection />
<div className="relative right-5 ml-4">
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
</div>
@@ -591,7 +607,10 @@ export const SecretOverviewPage = () => {
ariaLabel="Environments"
variant="plain"
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">
<FontAwesomeIcon icon={faList} />
@@ -600,37 +619,25 @@ export const SecretOverviewPage = () => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
{userAvailableEnvs
.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: slug,
secretPath
})
)
)
.map((availableEnv) => {
const { id: envId, name } = availableEnv;
{readableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={() => handleEnvSelect(envId)}
key={envId}
icon={
isEnvSelected ? (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
) : (
<FontAwesomeIcon className="text-mineshaft-400" icon={faCircle} />
)
}
iconPos="left"
>
<div className="flex items-center">{name}</div>
</DropdownMenuItem>
);
})}
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleEnvSelect(envId);
}}
key={envId}
disabled={visibleEnvs?.length === 1}
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">{name}</div>
</DropdownMenuItem>
);
})}
{/* <DropdownMenuItem className="px-1.5" asChild>
<Button
size="xs"
@@ -643,6 +650,48 @@ export const SecretOverviewPage = () => {
Create an environment
</Button>
</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>
</DropdownMenu>
)}
@@ -727,9 +776,17 @@ export const SecretOverviewPage = () => {
variant="plain"
className="ml-2"
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>
</div>
</Th>
@@ -766,7 +823,7 @@ export const SecretOverviewPage = () => {
</Tr>
</THead>
<TBody>
{canViewOverviewPage && isTableLoading && (
{canViewOverviewPage && isOverviewLoading && (
<TableSkeleton
columns={visibleEnvs.length + 1}
innerKey="secret-overview-loading"
@@ -808,7 +865,7 @@ export const SecretOverviewPage = () => {
</Td>
</Tr>
)}
{isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && (
{isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState
@@ -833,60 +890,51 @@ export const SecretOverviewPage = () => {
</Td>
</Tr>
)}
{!isTableLoading &&
rows.slice(paginationOffset, paginationOffset + perPage).map((row, index) => {
switch (row.type) {
case RowType.Secret:
if (visibleEnvs?.length === 0) return null;
return (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[row.name]}
onToggleSecretSelect={() =>
toggleSelectedEntry(EntryType.SECRET, row.name)
}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${row.name}-${index + 1}`}
environments={visibleEnvs}
secretKey={row.name}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
);
case RowType.DynamicSecret:
return (
<SecretOverviewDynamicSecretRow
dynamicSecretName={row.name}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={visibleEnvs}
key={`overview-${row.name}-${index + 1}`}
/>
);
case RowType.Folder:
return (
<SecretOverviewFolderRow
folderName={row.name}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[row.name]}
onToggleFolderSelect={() =>
toggleSelectedEntry(EntryType.FOLDER, row.name)
}
environments={visibleEnvs}
key={`overview-${row.name}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
);
default:
return null;
}
})}
{!isOverviewLoading && visibleEnvs.length > 0 && (
<>
{folderNames.map((folderName, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[folderName]}
onToggleFolderSelect={() =>
toggleSelectedEntry(EntryType.FOLDER, folderName)
}
environments={visibleEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
}
/>
))}
{dynamicSecretNames.map((dynamicSecretName, index) => (
<SecretOverviewDynamicSecretRow
dynamicSecretName={dynamicSecretName}
isDynamicSecretInEnv={isDynamicSecretPresentInEnv}
environments={visibleEnvs}
key={`overview-${dynamicSecretName}-${index + 1}`}
/>
))}
{secKeys.map((key, index) => (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[key]}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`}
environments={visibleEnvs}
secretKey={key}
getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth}
/>
))}
</>
)}
</TBody>
<TFoot>
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
@@ -913,10 +961,17 @@ export const SecretOverviewPage = () => {
</Tr>
</TFoot>
</Table>
{!isTableLoading && rows.length > INIT_PER_PAGE && (
{!isOverviewLoading && totalCount > INIT_PER_PAGE && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
/>
}
className="border-t border-solid border-t-mineshaft-600"
count={rows.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { SecretTableResourceCount } from "./SecretTableResourceCount";

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
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 { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -78,7 +78,7 @@ export const SecretV2MigrationSection = () => {
const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin);
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 && (
<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" />
@@ -88,18 +88,30 @@ export const SecretV2MigrationSection = () => {
</div>
</div>
)}
<p className="mb-2 text-lg font-semibold">Action Required</p>
<p className="mb-4 leading-7 text-gray-400">
Infisical secrets engine is now 10x faster and allows you to encrypt secrets with your own
KMS. Upgrade your project to receive these improvements.
<div className="mb-4 flex items-start gap-2">
<FontAwesomeIcon icon={faWarning} size="xl" className="mt-1 text-primary" />
<p className="text-xl font-semibold">
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>
<Button
onClick={() => handlePopUpOpen("migrationInfo")}
isDisabled={!isAdmin || isUpgrading}
color="mineshaft"
isLoading={migrateProjectToV3.isLoading}
className="w-full "
>
{ isAdmin ? "Upgrade Project" : "Upgrade requires admin privilege"}
{isAdmin ? "Upgrade Project" : "Upgrade requires admin privilege"}
</Button>
{didProjectUpgradeFailed && (
<p className="mt-2 text-sm leading-7 text-red-400">