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