diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index a6111f6ae..36c936cb0 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -488,7 +488,11 @@ export enum EventType { CREATE_SECRET_REMINDER = "create-secret-reminder", GET_SECRET_REMINDER = "get-secret-reminder", - DELETE_SECRET_REMINDER = "delete-secret-reminder" + DELETE_SECRET_REMINDER = "delete-secret-reminder", + + DASHBOARD_LIST_SECRETS = "dashboard-list-secrets", + DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", + DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value" } export const filterableSecretEvents: EventType[] = [ @@ -3510,6 +3514,34 @@ interface ProjectDeleteEvent { }; } +interface DashboardListSecretsEvent { + type: EventType.DASHBOARD_LIST_SECRETS; + metadata: { + environment: string; + secretPath: string; + numberOfSecrets: number; + secretIds: string[]; + }; +} + +interface DashboardGetSecretValueEvent { + type: EventType.DASHBOARD_GET_SECRET_VALUE; + metadata: { + secretId: string; + secretKey: string; + environment: string; + secretPath: string; + }; +} + +interface DashboardGetSecretVersionValueEvent { + type: EventType.DASHBOARD_GET_SECRET_VERSION_VALUE; + metadata: { + secretId: string; + version: string; + }; +} + interface ProjectRoleCreateEvent { type: EventType.CREATE_PROJECT_ROLE; metadata: { @@ -3889,6 +3921,9 @@ export type Event = | SecretReminderCreateEvent | SecretReminderGetEvent | SecretReminderDeleteEvent + | DashboardListSecretsEvent + | DashboardGetSecretValueEvent + | DashboardGetSecretVersionValueEvent | ProjectRoleCreateEvent | ProjectRoleUpdateEvent | ProjectRoleDeleteEvent diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index c394ee8e9..4e9d160bf 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -1,16 +1,16 @@ import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; -import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas"; +import { SecretFoldersSchema, SecretImportsSchema, SecretType, UsersSchema } from "@app/db/schemas"; import { RemindersSchema } from "@app/db/schemas/reminders"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { DASHBOARD } from "@app/lib/api-docs"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { OrderByDirection } from "@app/lib/types"; -import { secretsLimit } from "@app/server/config/rateLimiter"; +import { readLimit, 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"; @@ -111,6 +111,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { SecretRotationV2Schema, z.object({ secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ secretValueHidden: z.boolean(), secretPath: z.string().optional(), @@ -124,7 +125,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .array() .optional(), secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ + isEmpty: z.boolean(), secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), @@ -219,7 +222,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let imports: Awaited> | undefined; let folders: Awaited> | undefined; - let secrets: Awaited> | undefined; + let secrets: + | (Awaited>[number] & { isEmpty: boolean })[] + | undefined; let dynamicSecrets: | Awaited> | undefined; @@ -426,43 +431,51 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { - secrets = await server.services.secret.getSecretsRawMultiEnv({ - viewSecretValue: true, - 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, - isInternal: true - }); + secrets = ( + await server.services.secret.getSecretsRawMultiEnv({ + viewSecretValue: true, + 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, + isInternal: true + }) + ).map((secret) => ({ ...secret, isEmpty: !secret.secretValue })); } } if (secrets?.length || secretRotations?.length) { for await (const environment of environments) { - const secretCountFromEnv = - (secrets?.filter((secret) => secret.environment === environment).length ?? 0) + - (secretRotations - ?.filter((rotation) => rotation.environment.slug === environment) - .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); + const secretIds = [ + ...new Set( + [ + ...(secrets?.filter((secret) => secret.environment === environment) ?? []), + ...(secretRotations + ?.filter((rotation) => rotation.environment.slug === environment) + .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))) ?? []) + ].map((secret) => secret.id) + ) + ]; - if (secretCountFromEnv) { + if (secretIds) { await server.services.auditLog.createAuditLog({ projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secretCountFromEnv + numberOfSecrets: secretIds.length, + secretIds } } }); @@ -473,7 +486,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), organizationId: req.permission.orgId, properties: { - numberOfSecrets: secretCountFromEnv, + numberOfSecrets: secretIds.length, projectId, environment, secretPath, @@ -584,7 +597,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .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(), - viewSecretValue: booleanSchema.default(true), includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), @@ -606,7 +618,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { SecretRotationV2Schema, z.object({ secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ + isEmpty: z.boolean(), secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), @@ -619,7 +633,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .array() .optional(), secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ + isEmpty: z.boolean(), secretReminderRecipients: z .object({ user: UsersSchema.pick({ id: true, email: true, username: true }), @@ -715,12 +731,21 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let folders: Awaited> | undefined; let secrets: | (Awaited>["secrets"][number] & { + isEmpty: boolean; reminder: Awaited>[string] | null; })[] | undefined; let dynamicSecrets: Awaited> | undefined; let secretRotations: - | Awaited> + | (Awaited>[number] & { + secrets: (NonNullable< + Awaited< + ReturnType + >[number]["secrets"][number] & { + isEmpty: boolean; + } + > | null)[]; + })[] | undefined; let totalImportCount: number | undefined; @@ -822,19 +847,31 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { - secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( - { - projectId, - search, - orderBy, - orderDirection, - environments: [environment], - secretPath, - limit: remainingLimit, - offset: adjustedOffset - }, - req.permission - ); + secretRotations = ( + await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ) + ).map((rotation) => ({ + ...rotation, + secrets: rotation.secrets.map((secret) => + secret + ? { + ...secret, + isEmpty: !secret.secretValue + } + : secret + ) + })); await server.services.auditLog.createAuditLog({ projectId, @@ -919,7 +956,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { await server.services.secret.getSecretsRaw({ actorId: req.permission.id, actor: req.permission.type, - viewSecretValue: req.query.viewSecretValue, + viewSecretValue: true, throwOnMissingReadValuePermission: false, actorOrgId: req.permission.orgId, environment, @@ -943,6 +980,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secrets = rawSecrets.map((secret) => ({ ...secret, + isEmpty: !secret.secretValue, reminder: reminders[secret.id] ?? null })); } @@ -977,19 +1015,25 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { })); if (secrets?.length || secretRotations?.length) { - const secretCount = - (secrets?.length ?? 0) + - (secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); + const secretIds = [ + ...new Set( + [ + ...(secrets ?? []), + ...(secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))) ?? []) + ].map((secret) => secret.id) + ) + ]; await server.services.auditLog.createAuditLog({ projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secretCount + numberOfSecrets: secretIds.length, + secretIds } } }); @@ -1000,7 +1044,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { distinctId: getTelemetryDistinctId(req), organizationId: req.permission.orgId, properties: { - numberOfSecrets: secretCount, + numberOfSecrets: secretIds.length, projectId, environment, secretPath, @@ -1060,6 +1104,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .array() .optional(), secrets: secretRawSchema + .omit({ secretValue: true }) .extend({ secretValueHidden: z.boolean(), secretPath: z.string().optional(), @@ -1145,18 +1190,20 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); for await (const environment of environments) { - const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length; + const envSecrets = secrets.filter((secret) => secret.environment === environment); + const secretCountForEnv = envSecrets.length; if (secretCountForEnv) { await server.services.auditLog.createAuditLog({ projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secretCountForEnv + numberOfSecrets: secretCountForEnv, + secretIds: envSecrets.map((secret) => secret.id) } } }); @@ -1259,6 +1306,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ + // TODO(scott): omit secretValue here, but requires refactor of uploading env/copy from board secrets: secretRawSchema .extend({ secretPath: z.string().optional(), @@ -1310,6 +1358,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ + // TODO(scott): omit secretValue here, but requires refactor of uploading env/copy from board secrets: secretRawSchema .extend({ secretValueHidden: z.boolean(), @@ -1345,11 +1394,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { projectId, ...req.auditLogInfo, event: { - type: EventType.GET_SECRETS, + type: EventType.DASHBOARD_LIST_SECRETS, metadata: { environment, secretPath, - numberOfSecrets: secrets.length + numberOfSecrets: secrets.length, + secretIds: secrets.map((secret) => secret.id) } } }); @@ -1373,4 +1423,251 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { return { secrets }; } }); + + server.route({ + method: "GET", + url: "/secret-value", + config: { + rateLimit: secretsLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + secretKey: z.string().trim(), + isOverride: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .optional() + }), + response: { + 200: z.object({ + valueOverride: z.string().optional(), + value: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secretPath, projectId, environment, secretKey, isOverride } = req.query; + + const { secrets } = await server.services.secret.getSecretsRaw({ + actorId: req.permission.id, + actor: req.permission.type, + viewSecretValue: true, + throwOnMissingReadValuePermission: false, + actorOrgId: req.permission.orgId, + environment, + actorAuthMethod: req.permission.authMethod, + projectId, + path: secretPath, + search: secretKey, + includeTagsInSearch: true, + includeMetadataInSearch: true + }); + + if (isOverride) { + const personalSecret = secrets.find((secret) => secret.type === SecretType.Personal); + + if (!personalSecret) + throw new BadRequestError({ + message: `Could not find personal secret with key "${secretKey}" at secret path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + if (personalSecret) + return { + valueOverride: personalSecret.secretValue + }; + } + + const sharedSecret = secrets.find((secret) => secret.type === SecretType.Shared); + + if (!sharedSecret) + throw new BadRequestError({ + message: `Could not find secret with key "${secretKey}" at secret path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + // only audit if not personal + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.DASHBOARD_GET_SECRET_VALUE, + metadata: { + environment: req.query.environment, + secretPath: req.query.secretPath, + secretKey, + secretId: sharedSecret.id + } + } + }); + + return { value: sharedSecret.secretValue }; + } + }); + + server.route({ + url: "/secret-imports", + method: "GET", + config: { + rateLimit: secretsLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim(), + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash) + }), + response: { + 200: z.object({ + secrets: z + .object({ + secretPath: z.string(), + environment: z.string(), + environmentInfo: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }), + folderId: z.string().optional(), + secrets: secretRawSchema.omit({ secretValue: true }).extend({ isEmpty: z.boolean() }).array() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const importedSecrets = await server.services.secretImport.getRawSecretsFromImports({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.query.projectId, + ...req.auditLogInfo, + event: { + type: EventType.DASHBOARD_LIST_SECRETS, + metadata: { + environment: req.query.environment, + secretPath: req.query.path, + numberOfSecrets: importedSecrets.length, + secretIds: importedSecrets.map((secret) => secret.id) + } + } + }); + + return { + secrets: importedSecrets.map((importData) => ({ + ...importData, + secrets: importData.secrets.map((secret) => ({ + ...secret, + isEmpty: !secret.secretValue + })) + })) + }; + } + }); + + server.route({ + method: "GET", + url: "/secret-versions/:secretId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + secretId: z.string() + }), + querystring: z.object({ + offset: z.coerce.number(), + limit: z.coerce.number() + }), + response: { + 200: z.object({ + secretVersions: secretRawSchema + .omit({ secretValue: true }) + .extend({ + secretValueHidden: z.boolean() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretVersions = await server.services.secret.getSecretVersions({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + limit: req.query.limit, + offset: req.query.offset, + secretId: req.params.secretId + }); + + return { secretVersions }; + } + }); + + server.route({ + method: "GET", + url: "/secret-versions/:secretId/value/:version", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + secretId: z.string(), + version: z.string() + }), + + response: { + 200: z.object({ + value: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { version, secretId } = req.params; + + const [secretVersion] = await server.services.secret.getSecretVersions({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretId, + secretVersions: [version] + }); + + if (!secretVersion) + throw new NotFoundError({ + message: `Could not find secret version "${version}" for secret with ID "${secretId}` + }); + + await server.services.auditLog.createAuditLog({ + projectId: secretVersion.workspace, + ...req.auditLogInfo, + event: { + type: EventType.DASHBOARD_GET_SECRET_VERSION_VALUE, + metadata: { + secretId, + version + } + } + }); + + return { value: secretVersion.secretValue }; + } + }); }; diff --git a/backend/src/server/routes/v4/secret-router.ts b/backend/src/server/routes/v4/secret-router.ts index 2c9f05570..e9cff7949 100644 --- a/backend/src/server/routes/v4/secret-router.ts +++ b/backend/src/server/routes/v4/secret-router.ts @@ -1262,7 +1262,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { handler: async (req) => { const { secretName } = req.params; const { secretPath, environment, projectId } = req.query; - const { tree, value } = await server.services.secret.getSecretReferenceTree({ + const { tree, value, secret } = await server.services.secret.getSecretReferenceTree({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, @@ -1273,6 +1273,21 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { environment }); + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET, + metadata: { + environment, + secretPath, + secretId: secret.id, + secretKey: secretName, + secretVersion: secret.version + } + } + }); + return { tree, value }; } }); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 93afb3b55..d8220e4f5 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -446,9 +446,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { } }) .where((bd) => { - void bd - .whereNull(`${TableName.SecretV2}.userId`) - .orWhere({ [`${TableName.SecretV2}.userId` as "userId"]: userId || null }); + void bd.whereNull(`${TableName.SecretV2}.userId`); + // scott: removing this as we don't need to count overrides + // and there is currently a bug when you move secrets that doesn't move the override so this can skew count + // .orWhere({ [`${TableName.SecretV2}.userId` as "userId"]: userId || null }); }) .countDistinct(`${TableName.SecretV2}.key`); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 9db535fbe..2a588f9a1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -478,15 +478,16 @@ export const secretV2BridgeServiceFactory = ({ secret = sharedSecretToModify; } - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: inputSecret.secretName, - secretTags: secret.tags.map((el) => el.slug) - }) - ); + if (secret.type !== SecretType.Personal) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: inputSecret.secretName, + secretTags: secret.tags.map((el) => el.slug) + }) + ); // validate tags // fetch all tags and if not same count throw error meaning one was invalid tags @@ -497,17 +498,18 @@ export const secretV2BridgeServiceFactory = ({ const tagsToCheck = inputSecret.tagIds ? newTags : secret.tags; // now check with new ids - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretActions.Edit, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: inputSecret.secretName, - ...(tagsToCheck.length && { - secretTags: tagsToCheck.map((el) => el.slug) + if (secret.type !== SecretType.Personal) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: inputSecret.secretName, + ...(tagsToCheck.length && { + secretTags: tagsToCheck.map((el) => el.slug) + }) }) - }) - ); + ); if (inputSecret.newSecretName) { const doesNewNameSecretExist = await secretDAL.findOne({ @@ -706,15 +708,17 @@ export const secretV2BridgeServiceFactory = ({ }) }); if (!secretToDelete) throw new NotFoundError({ message: "Secret not found" }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretActions.Delete, - subject(ProjectPermissionSub.Secrets, { - environment, - secretPath, - secretName: secretToDelete.key, - secretTags: secretToDelete.tags?.map((el) => el.slug) - }) - ); + + if (secretToDelete.type !== SecretType.Personal) + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: secretToDelete.key, + secretTags: secretToDelete.tags?.map((el) => el.slug) + }) + ); try { const deletedSecret = await secretDAL.transaction(async (tx) => { @@ -2333,7 +2337,8 @@ export const secretV2BridgeServiceFactory = ({ actorAuthMethod, limit = 20, offset = 0, - secretId + secretId, + secretVersions: secretVersionsFilter }: TGetSecretVersionsDTO) => { const secret = await secretDAL.findById(secretId); @@ -2370,6 +2375,7 @@ export const secretV2BridgeServiceFactory = ({ const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({ secretId, projectId: folder.projectId, + secretVersions: secretVersionsFilter, findOpt: { offset, limit, @@ -2939,7 +2945,7 @@ export const secretV2BridgeServiceFactory = ({ secretKey: secretName }); - return { tree: stackTrace, value: expandedValue }; + return { tree: stackTrace, value: expandedValue, secret }; }; const getAccessibleSecrets = async ({ diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index fc7d468ff..5e2ffc1a0 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -159,6 +159,7 @@ export type TGetSecretVersionsDTO = Omit & { limit?: number; offset?: number; secretId: string; + secretVersions?: string[]; }; export type TSecretReference = { environment: string; secretPath: string; secretKey: string }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 8758a992e..3a5ccc667 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -2568,7 +2568,8 @@ export const secretServiceFactory = ({ actorAuthMethod, limit = 20, offset = 0, - secretId + secretId, + secretVersions: filterSecretVersions }: TGetSecretVersionsDTO) => { const secretVersionV2 = await secretV2BridgeService .getSecretVersions({ @@ -2578,7 +2579,8 @@ export const secretServiceFactory = ({ actorAuthMethod, limit, offset, - secretId + secretId, + secretVersions: filterSecretVersions }) .catch((err) => { if ((err as Error).message === "BadRequest: Failed to find secret") { diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 07f46bf1e..d8c778d7e 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -331,6 +331,7 @@ export type TGetSecretVersionsDTO = Omit & { limit?: number; offset?: number; secretId: string; + secretVersions?: string[]; }; export type TSecretReference = { environment: string; secretPath: string }; diff --git a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx index ada741145..d4f09ea8f 100644 --- a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx +++ b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx @@ -130,6 +130,14 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop ); } + if (tree?.children?.length === 0) { + return ( +
+ This secret does not contain references +
+ ); + } + return (
diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index ec09a93eb..347be1b4f 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -55,6 +55,8 @@ type Props = Omit, "onChange" | "val secretPath?: string; environment?: string; containerClassName?: string; + isLoadingValue?: boolean; + isErrorLoadingValue?: boolean; }; type ReferenceItem = { @@ -279,11 +281,16 @@ export const InfisicalSecretInput = forwardRef( ref={handleRef} onKeyDown={handleKeyDown} value={value} - onFocus={() => setIsFocused.on()} + onFocus={(evt) => { + if (props.onFocus) props.onFocus(evt); + setIsFocused.on(); + }} onBlur={(evt) => { // should not on blur when its mouse down selecting a item from suggestion if (!(evt.relatedTarget?.getAttribute("aria-label") === "suggestion-item")) setIsFocused.off(); + + if (props.onBlur) props.onBlur(evt); }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index c320bb0af..60ab5848f 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -7,7 +7,16 @@ import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPa const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; -const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => { +const syntaxHighlight = ( + content?: string | null, + isVisible?: boolean, + isImport?: boolean, + isLoadingValue?: boolean, + isErrorLoadingValue?: boolean +) => { + if (isLoadingValue) return HIDDEN_SECRET_VALUE; + if (isErrorLoadingValue) + return Error loading secret value.; if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; @@ -48,6 +57,8 @@ type Props = TextareaHTMLAttributes & { isDisabled?: boolean; containerClassName?: string; canEditButNotView?: boolean; + isLoadingValue?: boolean; + isErrorLoadingValue?: boolean; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; @@ -65,6 +76,8 @@ export const SecretInput = forwardRef( isReadOnly, onFocus, canEditButNotView, + isLoadingValue, + isErrorLoadingValue, ...props }, ref @@ -83,7 +96,9 @@ export const SecretInput = forwardRef( {syntaxHighlight( value, isVisible || (isSecretFocused && !valueAlwaysHidden), - isImport + isImport, + isLoadingValue, + isErrorLoadingValue )} @@ -114,7 +129,7 @@ export const SecretInput = forwardRef( }} value={value || ""} {...props} - readOnly={isReadOnly} + readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue} />
diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 15993023e..df045fa53 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -224,6 +224,14 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_PROJECT]: "Update Project", [EventType.DELETE_PROJECT]: "Delete Project", + [EventType.CREATE_SECRET_REMINDER]: "Create Secret Reminder", + [EventType.GET_SECRET_REMINDER]: "Get Secret Reminder", + [EventType.DELETE_SECRET_REMINDER]: "Delete Secret Reminder", + + [EventType.DASHBOARD_LIST_SECRETS]: "Dashboard List Secrets", + [EventType.DASHBOARD_GET_SECRET_VALUE]: "Dashboard Get Secret Value", + [EventType.DASHBOARD_GET_SECRET_VERSION_VALUE]: "Dashboard Get Secret Version Value", + [EventType.CREATE_PROJECT_ROLE]: "Create Project Role", [EventType.UPDATE_PROJECT_ROLE]: "Update Project Role", [EventType.DELETE_PROJECT_ROLE]: "Delete Project Role", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 77baaa990..52ccc3cdc 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -218,6 +218,14 @@ export enum EventType { UPDATE_PROJECT = "update-project", DELETE_PROJECT = "delete-project", + CREATE_SECRET_REMINDER = "create-secret-reminder", + GET_SECRET_REMINDER = "get-secret-reminder", + DELETE_SECRET_REMINDER = "delete-secret-reminder", + + DASHBOARD_LIST_SECRETS = "dashboard-list-secrets", + DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", + DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value", + CREATE_PROJECT_ROLE = "create-project-role", UPDATE_PROJECT_ROLE = "update-project-role", DELETE_PROJECT_ROLE = "delete-project-role", diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index fde3a5ee3..3866c6337 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query"; import { AxiosError } from "axios"; import { apiRequest } from "@app/config/request"; @@ -10,13 +10,15 @@ import { DashboardProjectSecretsOverview, DashboardProjectSecretsOverviewResponse, DashboardSecretsOrderBy, + DashboardSecretValue, TDashboardProjectSecretsQuickSearch, TDashboardProjectSecretsQuickSearchResponse, TGetAccessibleSecretsDTO, TGetDashboardProjectSecretsByKeys, TGetDashboardProjectSecretsDetailsDTO, TGetDashboardProjectSecretsOverviewDTO, - TGetDashboardProjectSecretsQuickSearchDTO + TGetDashboardProjectSecretsQuickSearchDTO, + TGetSecretValueDTO } from "@app/hooks/api/dashboard/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; @@ -73,6 +75,15 @@ export const dashboardKeys = { ...dashboardKeys.all(), "accessible-secrets", { projectId, secretPath, environment, filterByAction } + ] as const, + getSecretValuesRoot: () => [...dashboardKeys.all(), "secrets-values"] as const, + getSecretValue: ({ environment, secretPath, secretKey, isOverride }: TGetSecretValueDTO) => + [ + ...dashboardKeys.getSecretValuesRoot(), + environment, + secretPath, + secretKey, + isOverride ] as const }; @@ -174,6 +185,8 @@ export const useGetProjectSecretsOverview = ( "queryKey" | "queryFn" > ) => { + const queryClient = useQueryClient(); + return useQuery({ ...options, // wait for all values to be available @@ -193,8 +206,8 @@ export const useGetProjectSecretsOverview = ( includeSecretRotations, environments }), - queryFn: () => - fetchProjectSecretsOverview({ + queryFn: async () => { + const resp = fetchProjectSecretsOverview({ secretPath, search, limit, @@ -208,7 +221,14 @@ export const useGetProjectSecretsOverview = ( includeDynamicSecrets, includeSecretRotations, environments - }), + }); + + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getSecretValuesRoot() + }); + + return resp; + }, select: useCallback((data: Awaited>) => { const { secrets, secretRotations, ...select } = data; const uniqueSecrets = secrets ? unique(secrets, (i) => i.secretKey) : []; @@ -254,7 +274,6 @@ export const useGetProjectSecretsDetails = ( search = "", includeSecrets, includeFolders, - viewSecretValue, includeImports, includeDynamicSecrets, includeSecretRotations, @@ -270,6 +289,8 @@ export const useGetProjectSecretsDetails = ( "queryKey" | "queryFn" > ) => { + const queryClient = useQueryClient(); + return useQuery({ ...options, // wait for all values to be available @@ -286,7 +307,6 @@ export const useGetProjectSecretsDetails = ( limit, orderBy, orderDirection, - viewSecretValue, offset, projectId, environment, @@ -297,14 +317,13 @@ export const useGetProjectSecretsDetails = ( includeSecretRotations, tags }), - queryFn: () => - fetchProjectSecretsDetails({ + queryFn: async () => { + const resp = await fetchProjectSecretsDetails({ secretPath, search, limit, orderBy, orderDirection, - viewSecretValue, offset, projectId, environment, @@ -314,7 +333,14 @@ export const useGetProjectSecretsDetails = ( includeDynamicSecrets, includeSecretRotations, tags - }), + }); + + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getSecretValuesRoot() + }); + + return resp; + }, select: useCallback( (data: Awaited>) => ({ ...data, @@ -471,3 +497,31 @@ export const useGetAccessibleSecrets = ({ fetchAccessibleSecrets({ projectId, secretPath, environment, filterByAction, recursive }) }); }; + +export const fetchSecretValue = async (params: TGetSecretValueDTO) => { + const { data } = await apiRequest.get("/api/v1/dashboard/secret-value", { + params + }); + + return data; +}; + +export const useGetSecretValue = ( + params: TGetSecretValueDTO, + options?: Omit< + UseQueryOptions< + DashboardSecretValue, + unknown, + DashboardSecretValue, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: dashboardKeys.getSecretValue(params), + queryFn: async () => fetchSecretValue(params), + staleTime: 1000 * 60, + ...options + }); +}; diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index fbd5107cd..5a7a519b7 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -112,7 +112,6 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit< TGetDashboardProjectSecretsOverviewDTO, "environments" > & { - viewSecretValue: boolean; environment: string; includeImports?: boolean; tags: Record; @@ -156,3 +155,21 @@ export type TGetAccessibleSecretsDTO = { | ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue; }; + +export type TGetSecretValueDTO = { + projectId: string; + secretKey: string; + environment: string; + secretPath: string; + isOverride?: boolean; +}; + +export type DashboardSecretValue = + | { + value: string; + valueOverride: undefined; + } + | { + value: undefined; + valueOverride: string; + }; diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 71716cb7c..5d80c3649 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -67,7 +67,7 @@ export const useGetSecretImports = ({ const fetchImportedSecrets = async (projectId: string, environment: string, directory?: string) => { const { data } = await apiRequest.get<{ secrets: TImportedSecrets[] }>( - "/api/v2/secret-imports/secrets", + "/api/v1/dashboard/secret-imports", { params: { projectId, @@ -132,13 +132,13 @@ export const useGetImportedSecretsSingleEnv = ({ id: encSecret.id, env: encSecret.environment, key: encSecret.secretKey, - value: encSecret.secretValue, secretValueHidden: encSecret.secretValueHidden, tags: encSecret.tags, comment: encSecret.secretComment, createdAt: encSecret.createdAt, updatedAt: encSecret.updatedAt, - version: encSecret.version + version: encSecret.version, + isEmpty: encSecret.isEmpty }; }) })); @@ -172,7 +172,6 @@ export const useGetImportedSecretsAllEnvs = ({ id: encSecret.id, env: encSecret.environment, key: encSecret.secretKey, - value: encSecret.secretValue, secretValueHidden: encSecret.secretValueHidden, tags: encSecret.tags, comment: encSecret.secretComment, @@ -233,7 +232,9 @@ export const useGetImportedSecretsAllEnvs = ({ return { secret: secret?.secrets.find((s) => s.key === secretName), - environmentInfo: secret?.environmentInfo + environmentInfo: secret?.environmentInfo, + secretPath: secret?.secretPath, + environment: secret?.environment }; } return undefined; diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 23690b33d..d17641398 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -28,7 +28,7 @@ export type TImportedSecrets = { environmentInfo: ProjectEnv; secretPath: string; folderId: string; - secrets: SecretV3Raw[]; + secrets: Omit[]; }; export type TGetSecretImports = { diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index df3a78c35..95d0b9cdf 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -6,6 +6,7 @@ import axios from "axios"; import { createNotification } from "@app/components/notifications"; import { apiRequest } from "@app/config/request"; import { useToggle } from "@app/hooks/useToggle"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; import { ERROR_NOT_ALLOWED_READ_SECRETS } from "./constants"; import { @@ -21,7 +22,9 @@ import { TGetProjectSecretsKey, TGetSecretAccessListDTO, TGetSecretReferenceTreeDTO, - TSecretReferenceTraceNode + TGetSecretVersionValue, + TSecretReferenceTraceNode, + TSecretVersionValue } from "./types"; export const secretKeys = { @@ -34,6 +37,8 @@ export const secretKeys = { }: TGetProjectSecretsKey) => [{ projectId, environment, secretPath, viewSecretValue }, "secrets"] as const, getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const, + getSecretVersionValue: (secretId: string, version: number) => + ["secret-versions", secretId, version] as const, getSecretAccessList: ({ projectId, environment, @@ -67,14 +72,17 @@ export const fetchProjectSecrets = async ({ }; export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { - const personalSecrets: Record = {}; + const personalSecrets: Record< + string, + { id: string; value?: string; env: string; isEmpty?: boolean } + > = {}; const secrets: SecretV3RawSanitized[] = []; rawSecrets.forEach((el) => { const decryptedSecret: SecretV3RawSanitized = { id: el.id, env: el.environment, key: el.secretKey, - value: el.secretValue, + value: el.secretValueHidden ? HIDDEN_SECRET_VALUE : el.secretValue, secretValueHidden: el.secretValueHidden, tags: el.tags || [], comment: el.secretComment || "", @@ -89,14 +97,16 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { secretMetadata: el.secretMetadata, isRotatedSecret: el.isRotatedSecret, rotationId: el.rotationId, - reminder: el.reminder + reminder: el.reminder, + isEmpty: el.isEmpty }; if (el.type === SecretType.Personal) { personalSecrets[decryptedSecret.key] = { id: el.id, value: el.secretValue, - env: el.environment + env: el.environment, + isEmpty: el.isEmpty }; } else { secrets.push(decryptedSecret); @@ -109,6 +119,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { sec.idOverride = personalSecret.id; sec.valueOverride = personalSecret.value; sec.overrideAction = "modified"; + sec.isEmpty = personalSecret.isEmpty; + sec.secretValueHidden = false; } }); @@ -238,7 +250,7 @@ export const useGetProjectSecretsAllEnv = ({ const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => { const { data } = await apiRequest.get<{ secretVersions: SecretVersions[] }>( - `/api/v1/secret/${secretId}/secret-versions`, + `/api/v1/dashboard/secret-versions/${secretId}`, { params: { limit, @@ -259,6 +271,26 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) => }, []) }); +export const fetchSecretVersionValue = async (secretId: string, version: number) => { + const { data } = await apiRequest.get( + `/api/v1/dashboard/secret-versions/${secretId}/value/${version}` + ); + return data.value; +}; + +export const useGetSecretVersionValue = ( + dto: TGetSecretVersionValue, + options?: Omit< + UseQueryOptions>, + "queryKey" | "queryFn" + > +) => + useQuery({ + queryKey: secretKeys.getSecretVersionValue(dto.secretId, dto.version), + queryFn: () => fetchSecretVersionValue(dto.secretId, dto.version), + ...options + }); + export const useGetSecretAccessList = (dto: TGetSecretAccessListDTO) => useQuery({ enabled: Boolean(dto.secretKey), diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 327d3659d..68a3852c0 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -47,6 +47,7 @@ export type SecretV3RawSanitized = { isPending?: boolean; pendingAction?: PendingAction; reminder?: Reminder; + isEmpty?: boolean; }; export type SecretV3Raw = { @@ -73,6 +74,7 @@ export type SecretV3Raw = { rotationId?: string; secretReminderRecipients?: SecretReminderRecipient[]; reminder?: Reminder; + isEmpty?: boolean; }; export type SecretV3RawResponse = { @@ -137,6 +139,15 @@ export type GetSecretVersionsDTO = { offset: number; }; +export type TGetSecretVersionValue = { + secretId: string; + version: number; +}; + +export type TSecretVersionValue = { + value: string; +}; + export type TGetSecretAccessListDTO = { projectId: string; environment: string; diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index d884f3b6a..e48cfb78c 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -349,9 +349,7 @@ export const OverviewPage = () => { getSecretRotationStatusesByName } = useSecretRotationOverview(secretRotations); - const { secKeys, getEnvSecretKeyCount } = useSecretOverview( - secrets?.concat(secretImportsShaped) || [] - ); + const { secKeys, getEnvSecretKeyCount } = useSecretOverview(secrets || []); const getSecretByKey = useCallback( (env: string, key: string) => { diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewRotationSecretRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewRotationSecretRow.tsx new file mode 100644 index 000000000..f8efdf17e --- /dev/null +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewRotationSecretRow.tsx @@ -0,0 +1,77 @@ +import { twMerge } from "tailwind-merge"; + +import { SecretInput, Tooltip } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; +import { useProject } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useGetSecretValue } from "@app/hooks/api/dashboard/queries"; +import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; + +interface SecretOverviewRotationSecretRowProps { + secret: SecretV3RawSanitized | null; + isSecretVisible: boolean; + environment: string; + secretPath: string; +} + +export const SecretOverviewRotationSecretRow = ({ + secret, + isSecretVisible, + environment, + secretPath +}: SecretOverviewRotationSecretRowProps) => { + const [isFieldFocused, setIsFieldFocused] = useToggle(); + + const { currentProject } = useProject(); + + const canFetchValue = Boolean(secret); + + const { data: secretValueData, isError } = useGetSecretValue( + { + secretKey: secret?.key ?? "", + environment, + secretPath, + projectId: currentProject.id + }, + { + enabled: canFetchValue && (isSecretVisible || isFieldFocused) + } + ); + + const secretValue = isError + ? "Error loading secret value..." + : (secretValueData?.valueOverride ?? secretValueData?.value ?? HIDDEN_SECRET_VALUE); + + return ( + + + + {secret?.key ?? "********"} + + + {/* eslint-disable-next-line no-nested-ternary */} + {!secret ? ( +
********
+ ) : secret.secretValueHidden ? ( + + ) : ( + setIsFieldFocused.on()} + onBlur={() => setIsFieldFocused.off()} + /> + )} + + +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx index 17f328fe2..46a5e7328 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewSecretRotationRow/SecretOverviewSecretRotationRow.tsx @@ -16,8 +16,6 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge"; import { Badge, IconButton, TableContainer, Tag, Td, Tooltip, Tr } from "@app/components/v2"; -import { Blur } from "@app/components/v2/Blur"; -import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionSecretRotationActions, ProjectPermissionSub @@ -27,6 +25,8 @@ import { useToggle } from "@app/hooks"; import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils"; +import { SecretOverviewRotationSecretRow } from "./SecretOverviewRotationSecretRow"; + type Props = { secretRotationName: string; environments: { name: string; slug: string }[]; @@ -256,52 +256,13 @@ export const SecretOverviewSecretRotationRow = ({ {secrets.map((secret, index) => { return ( - - - - - {secret?.key ?? "********"} - - - - {/* eslint-disable-next-line no-nested-ternary */} - {!secret ? ( -
********
- ) : secret.secretValueHidden ? ( - - ) : ( - {}} - /> - )} - - -
+ ); })} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index 4b07e6242..a6ed5fc58 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; import { @@ -10,14 +10,12 @@ import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { - hasSecretReference, - SecretReferenceTree -} from "@app/components/secrets/SecretReferenceDetails"; +import { SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; import { DeleteActionModal, IconButton, @@ -27,12 +25,23 @@ import { Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProject, + useProjectPermission +} from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { usePopUp, useToggle } from "@app/hooks"; -import { SecretType } from "@app/hooks/api/types"; +import { + dashboardKeys, + fetchSecretValue, + useGetSecretValue +} from "@app/hooks/api/dashboard/queries"; +import { ProjectEnv, SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { CollapsibleSecretImports } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; type Props = { defaultValue?: string | null; @@ -56,6 +65,15 @@ type Props = { ) => Promise; onSecretDelete: (env: string, key: string, secretId?: string) => Promise; isRotatedSecret?: boolean; + isEmpty?: boolean; + importedSecret?: + | { + secretPath: string; + secret?: SecretV3RawSanitized; + environmentInfo?: ProjectEnv; + environment: string; + } + | undefined; importedBy?: { environment: { name: string; slug: string }; folders: { @@ -81,24 +99,68 @@ export const SecretEditRow = ({ isVisible, secretId, isRotatedSecret, - importedBy + importedBy, + importedSecret, + isEmpty }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "editSecret" ] as const); + const queryClient = useQueryClient(); + + const { currentProject } = useProject(); + + const [isFieldFocused, setIsFieldFocused] = useToggle(); + + const fetchSecretValueParams = importedSecret + ? { + environment: importedSecret.environment, + secretPath: importedSecret.secretPath, + secretKey: importedSecret.secret?.key ?? "", + projectId: currentProject.id + } + : { + environment, + secretPath, + secretKey: secretName, + projectId: currentProject.id, + isOverride + }; + + // scott: only fetch value if secret exists, has non-empty value and user has permission + const canFetchValue = Boolean(importedSecret ?? secretId) && !isEmpty && !secretValueHidden; + + const { + data: secretValueData, + isPending: isPendingSecretValueData, + isError: isErrorFetchingSecretValue + } = useGetSecretValue(fetchSecretValueParams, { + enabled: canFetchValue && (isVisible || isFieldFocused) + }); + + const isFetchingSecretValue = canFetchValue && isPendingSecretValueData; + const isSecretValueFetched = Boolean(secretValueData); + const { handleSubmit, control, reset, getValues, + setValue, formState: { isDirty, isSubmitting } } = useForm({ - values: { - value: defaultValue || null + defaultValues: { + value: secretValueData?.valueOverride ?? secretValueData?.value ?? (defaultValue || null) } }); + useEffect(() => { + if (secretValueData && !isDirty) { + setValue("value", secretValueData.valueOverride ?? secretValueData.value); + } + }, [secretValueData]); + const { permission } = useProjectPermission(); const [isDeleting, setIsDeleting] = useToggle(); @@ -113,6 +175,25 @@ export const SecretEditRow = ({ }; const handleCopySecretToClipboard = async () => { + if (!isSecretValueFetched && !isDirty) { + try { + const data = await fetchSecretValue(fetchSecretValueParams); + + queryClient.setQueryData(dashboardKeys.getSecretValue(fetchSecretValueParams), data); + + await window.navigator.clipboard.writeText(data.valueOverride ?? data.value); + createNotification({ type: "success", text: "Copied secret to clipboard" }); + return; + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to fetch secret value." + }); + return; + } + } + const { value } = getValues(); if (value) { try { @@ -221,14 +302,25 @@ export const SecretEditRow = ({ )}
( setIsFieldFocused.on()} + onBlur={() => { + field.onBlur(); + setIsFieldFocused.off(); + }} /> )} /> @@ -309,18 +406,12 @@ export const SecretEditRow = ({
- + diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 0260135f8..cb2307880 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -50,7 +50,14 @@ type Props = { getImportedSecretByKey: ( env: string, secretName: string - ) => { secret?: SecretV3RawSanitized; environmentInfo?: ProjectEnv } | undefined; + ) => + | { + secret?: SecretV3RawSanitized; + secretPath: string; + environment: string; + environmentInfo?: ProjectEnv; + } + | undefined; scrollOffset: number; importedBy?: { environment: { name: string; slug: string }; @@ -140,7 +147,7 @@ export const SecretOverviewTableRow = ({ const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey); const isSecretPresent = Boolean(secret); - const isSecretEmpty = secret?.value === ""; + const isSecretEmpty = secret?.isEmpty; return ( )} - {secret?.valueOverride && ( + {secret?.idOverride && ( @@ -266,11 +273,13 @@ export const SecretOverviewTableRow = ({ secretPath={secretPath} isVisible={isSecretVisible} secretName={secretKey} + isEmpty={secret?.isEmpty} secretValueHidden={secret?.secretValueHidden || false} defaultValue={getDefaultValue(secret, importedSecret)} secretId={secret?.id} - isOverride={Boolean(secret?.valueOverride)} + isOverride={Boolean(secret?.idOverride)} isImportedSecret={isImportedSecret} + importedSecret={importedSecret} isCreatable={isCreatable} onSecretDelete={onSecretDelete} onSecretCreate={onSecretCreate} diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx index 8c97b4f5c..b90edef1d 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretSearchInput/components/QuickSearchSecretItem.tsx @@ -25,8 +25,10 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { useProject } from "@app/context"; import { reverseTruncate } from "@app/helpers/reverseTruncate"; import { useTimedReset } from "@app/hooks"; +import { fetchSecretValue } from "@app/hooks/api/dashboard/queries"; import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; import { ProjectEnv } from "@app/hooks/api/projects/types"; @@ -53,6 +55,8 @@ export const QuickSearchSecretItem = ({ initialState: false }); + const { currentProject } = useProject(); + const [groupSecret] = secretGroup; const handleNavigate = () => { @@ -67,14 +71,29 @@ export const QuickSearchSecretItem = ({ onClose(); }; - const handleCopy = (value: string, env: string) => { - navigator.clipboard.writeText(value); - createNotification({ - type: "info", - title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`, - text: "" - }); - setIsUrlCopied(true); + const handleCopy = async (env: string) => { + try { + const data = await fetchSecretValue({ + environment: groupSecret.env, + secretPath: groupSecret.path!, + secretKey: groupSecret.key, + projectId: currentProject.id + }); + + navigator.clipboard.writeText(data.valueOverride ?? data.value!); + createNotification({ + type: "info", + title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`, + text: "" + }); + setIsUrlCopied(true); + } catch (error) { + console.error(error); + createNotification({ + type: "error", + text: "Error fetching secret value" + }); + } }; const secretGroupTags = secretGroup.flatMap((secret) => secret.tags); @@ -146,7 +165,7 @@ export const QuickSearchSecretItem = ({ e.stopPropagation(); const el = envSlugMap.get(groupSecret.env)?.name; if (el) { - handleCopy(groupSecret.value!, el); + handleCopy(el); } }} > @@ -173,7 +192,7 @@ export const QuickSearchSecretItem = ({ e.stopPropagation(); const el = envSlugMap.get(secret.env)?.name; if (el) { - handleCopy(secret.value!, el); + handleCopy(el); } }} key={secret.id} @@ -214,7 +233,7 @@ export const QuickSearchSecretItem = ({ e.stopPropagation(); const el = envSlugMap.get(secret.env)?.name; if (el) { - handleCopy(secret.value!, el); + handleCopy(el); } }} key={secret.id} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 72232058d..9b757e963 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -47,10 +47,12 @@ import { useGetWsTags } from "@app/hooks/api"; import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectVersion } from "@app/hooks/api/projects/types"; +import { queryClient } from "@app/hooks/api/reactQuery"; import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { useCreateCommit } from "@app/hooks/api/secrets/mutations"; import { SecretV3RawSanitized } from "@app/hooks/api/types"; @@ -152,6 +154,10 @@ const Page = () => { } }, [isBatchMode, projectId, environment, secretPath, loadPendingChanges]); + useEffect(() => { + if (isVisible) setIsVisible(false); + }, [environment]); + const canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, @@ -183,17 +189,6 @@ const Page = () => { }) ); - const canReadSecretValue = hasSecretReadValueOrDescribePermission( - permission, - ProjectPermissionSecretActions.ReadValue, - { - environment, - secretPath, - secretName: "*", - secretTags: ["*"] - } - ); - const canReadSecretImports = permission.can( ProjectPermissionActions.Read, subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) @@ -271,7 +266,6 @@ const Page = () => { orderDirection, includeImports: canReadSecretImports && (isResourceTypeFiltered ? filter.include.import : true), includeFolders: isResourceTypeFiltered ? filter.include.folder : true, - viewSecretValue: canReadSecretValue, includeDynamicSecrets: canReadDynamicSecret && (isResourceTypeFiltered ? filter.include.dynamic : true), includeSecrets: canReadSecret && (isResourceTypeFiltered ? filter.include.secret : true), @@ -347,6 +341,24 @@ const Page = () => { pendingChanges: changes, message }); + + if (!isProtectedBranch) { + pendingChanges.secrets.forEach((secret) => { + if (secret.type === "update" && secret.secretValue !== undefined) { + queryClient.setQueryData( + dashboardKeys.getSecretValue({ + projectId, + environment, + secretPath, + secretKey: secret.newSecretName ?? secret.secretKey, + isOverride: false + }), + { value: secret.secretValue } + ); + } + }); + } + createNotification({ text: isProtectedBranch ? "Requested changes have been sent for review" @@ -604,7 +616,9 @@ const Page = () => { return secrets; } - const mergedSecrets = [...(secrets || [])]; + const mergedSecrets = [...(secrets || [])] as (SecretV3RawSanitized & { + originalKey?: string; + })[]; pendingChanges.secrets.forEach((change) => { switch (change.type) { @@ -655,7 +669,8 @@ const Page = () => { updatedAt: new Date().toISOString(), __v: 0 })) || [] - : mergedSecrets[updateIndex].tags + : mergedSecrets[updateIndex].tags, + originalKey: mergedSecrets[updateIndex].key }; } break; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx index 4cccc6480..06fecf169 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretMainPage.store.tsx @@ -54,6 +54,7 @@ export interface PendingSecretDelete extends BasePendingChange { type: PendingAction.Delete; secretKey: string; secretValue: string; + secretValueHidden: boolean; } // Folder-related change types @@ -408,7 +409,7 @@ const createBatchModeStore: StateCreator const mergedUpdate: PendingSecretUpdate = { ...existingUpdate, secretKey: existingUpdate.secretKey, - originalValue: existingUpdate.originalValue, + originalValue: change.originalValue, originalComment: existingUpdate.originalComment, originalSkipMultilineEncoding: existingUpdate.originalSkipMultilineEncoding, originalTags: existingUpdate.originalTags, diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx index f81cd7b2d..dbccd93e0 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/CommitForm/CommitForm.tsx @@ -13,6 +13,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2"; import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { SecretVersionDiffView } from "@app/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView"; +import { HIDDEN_SECRET_VALUE_API_MASK } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; import { PendingChange, @@ -105,7 +106,12 @@ const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => { version: 1, // placeholder, not used secretKey: change.newSecretName ? existingSecret.key : undefined, secretValue: - change.secretValue !== undefined ? (existingSecret.value ?? "") : undefined, + // eslint-disable-next-line no-nested-ternary + change.secretValue !== undefined + ? change.existingSecret.secretValueHidden + ? HIDDEN_SECRET_VALUE_API_MASK + : (change.originalValue ?? "") + : undefined, tags: change.tags ? (existingSecret.tags?.map((tag) => tag.slug) ?? []) : undefined, secretMetadata: change.secretMetadata ? existingSecret.secretMetadata : undefined, skipMultilineEncoding: @@ -130,7 +136,7 @@ const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => { } if (change.type === PendingAction.Delete) { - const { secretKey, secretValue } = change; + const { secretKey, secretValue, secretValueHidden } = change; return ( { { version: 1, // placeholder, not used secretKey, - secretValue + // eslint-disable-next-line no-nested-ternary + secretValue: secretValue + ? secretValueHidden + ? HIDDEN_SECRET_VALUE_API_MASK + : secretValue + : undefined } ] }} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx index c9a2c1b62..379708fdc 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/EnvironmentTabs/EnvironmentTabs.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { faArrowRightArrowLeft, faEllipsisH, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faEllipsisH, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -199,14 +199,15 @@ export const EnvironmentTabs = ({ secretPath }: Props) => { )} - {currentProject.environments.length > 1 && ( + {/* scott: removing until we have time to update for fetching secret value */} + {/* {currentProject.environments.length > 1 && (
Compare Environments
- )} + )} */} void; environment: string; @@ -35,7 +37,10 @@ type Props = { importedSecrets: { key: string; value?: string; - overriden: { env: string; secretPath: string }; + overridden: { env: string; secretPath: string }; + environment: string; + secretPath?: string; + isEmpty?: boolean; }[]; searchTerm: string; onExpandReplicateSecrets: (id: string) => void; @@ -299,7 +304,6 @@ export const SecretImportItem = ({ Key Value - {/* Override */} @@ -317,18 +321,11 @@ export const SecretImportItem = ({ )} - {filteredImportedSecrets.map(({ key, value }, index) => ( - - - {key} - - - - - {/* - - */} - + {filteredImportedSecrets.map((secret, index) => ( + ))} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx index d99295286..7fbbc9436 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportListView.tsx @@ -64,18 +64,24 @@ export const computeImportedSecretRows = ( const importedSecretEntries: { key: string; value?: string; - overriden: { + environment: string; + secretPath?: string; + overridden: { env: string; secretPath: string; }; + isEmpty?: boolean; }[] = []; - importedSec.secrets.forEach(({ key, value }) => { + importedSec.secrets.forEach(({ key, value, env, path, isEmpty }) => { if (!importedEntry[key]) { importedSecretEntries.push({ key, value, - overriden: overridenSec?.[key] + environment: env, + secretPath: path, + overridden: overridenSec?.[key], + isEmpty }); importedEntry[key] = true; } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportSecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportSecretRow.tsx new file mode 100644 index 000000000..fd8c8a440 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretImportListView/SecretImportSecretRow.tsx @@ -0,0 +1,68 @@ +import { SecretInput } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useGetSecretValue } from "@app/hooks/api/dashboard/queries"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; + +type SecretImportSecretRowProps = { + secret: { + key: string; + value?: string; + environment: string; + secretPath?: string; + isEmpty?: boolean; + overridden: { env: string; secretPath: string }; + }; +}; + +export const SecretImportSecretRow = ({ + secret: { key, environment, secretPath = "/", isEmpty } +}: SecretImportSecretRowProps) => { + const [isFieldFocused, setIsFieldFocused] = useToggle(); + + const { currentProject } = useProject(); + + const canFetchSecretValue = !isEmpty; + + const { + data: secretValue, + isPending: isPendingSecretValue, + isError: isErrorFetchingSecretValue + } = useGetSecretValue( + { + environment, + secretPath, + secretKey: key, + projectId: currentProject.id + }, + { + enabled: isFieldFocused && canFetchSecretValue + } + ); + + const isLoadingSecretValue = canFetchSecretValue && isPendingSecretValue; + + const getValue = () => { + if (isLoadingSecretValue) return HIDDEN_SECRET_VALUE; + + if (isErrorFetchingSecretValue) return "Error loading secret value"; + + return secretValue?.value || ""; + }; + + return ( + + + {key} + + + setIsFieldFocused.on()} + onBlur={() => setIsFieldFocused.off()} + isReadOnly + /> + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index f00e1b72b..67404f878 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -1,36 +1,27 @@ import { useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; -import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons"; +import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons"; import { - faArrowRotateRight, faCheckCircle, faCopy, - faDesktop, - faEyeSlash, faPlus, faProjectDiagram, faSearch, - faServer, faShare, faTag, faTrash, - faTriangleExclamation, - faUser + faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Link, useNavigate } from "@tanstack/react-router"; -import { format } from "date-fns"; -import { twMerge } from "tailwind-merge"; +import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { - hasSecretReference, - SecretReferenceTree -} from "@app/components/secrets/SecretReferenceDetails"; +import { SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; import { Button, Drawer, @@ -60,9 +51,13 @@ import { } from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getProjectBaseURL } from "@app/helpers/project"; -import { usePopUp } from "@app/hooks"; +import { usePopUp, useToggle } from "@app/hooks"; import { useGetSecretVersion } from "@app/hooks/api"; -import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { + dashboardKeys, + fetchSecretValue, + useGetSecretValue +} from "@app/hooks/api/dashboard/queries"; import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; @@ -70,6 +65,7 @@ import { camelCaseToSpaces } from "@app/lib/fn/string"; import { HIDDEN_SECRET_VALUE } from "./SecretItem"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; +import { SecretVersionItem } from "./SecretVersionItem"; type Props = { isOpen?: boolean; @@ -77,7 +73,7 @@ type Props = { secretPath: string; onToggle: (isOpen: boolean) => void; onClose: () => void; - secret: SecretV3RawSanitized; + secret: SecretV3RawSanitized & { originalKey?: string }; onDeleteSecret: () => void; onSaveSecret: ( orgSec: SecretV3RawSanitized, @@ -92,7 +88,7 @@ type Props = { export const SecretDetailSidebar = ({ isOpen, onToggle, - secret, + secret: originalSecret, onDeleteSecret, onSaveSecret, tags, @@ -101,16 +97,90 @@ export const SecretDetailSidebar = ({ secretPath, handleSecretShare }: Props) => { + const { currentProject } = useProject(); + const [isFieldFocused, setIsFieldFocused] = useToggle(); + const queryClient = useQueryClient(); + + const canFetchSecretValue = + Boolean(originalSecret) && !originalSecret.secretValueHidden && !originalSecret.isEmpty; + + const fetchSecretValueParams = { + environment, + secretPath, + secretKey: originalSecret?.originalKey || originalSecret?.key, + projectId: currentProject.id, + isOverride: Boolean(originalSecret?.idOverride) + }; + + const { + data: secretValueData, + isPending: isPendingSecretValue, + isError: isErrorFetchingSecretValue + } = useGetSecretValue(fetchSecretValueParams, { + enabled: canFetchSecretValue && isFieldFocused + }); + + const isLoadingSecretValue = canFetchSecretValue && isPendingSecretValue; + const hasFetchedSecretValue = !canFetchSecretValue || Boolean(secretValueData); + + const secret = { + ...originalSecret, + value: originalSecret?.value ?? secretValueData?.value, + valueOverride: originalSecret?.valueOverride ?? secretValueData?.valueOverride + }; + + const { permission } = useProjectPermission(); + + const canEditSecretValue = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: secret.key, + secretTags: ["*"] + }) + ); + + const getDefaultValue = () => { + if (isLoadingSecretValue) return HIDDEN_SECRET_VALUE; + + if (secret.secretValueHidden) { + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; + } + + if (isErrorFetchingSecretValue) return "Error loading secret value..."; + + return secret.value || ""; + }; + + const getOverrideDefaultValue = () => { + if (isLoadingSecretValue) return HIDDEN_SECRET_VALUE; + + if (secret.secretValueHidden) { + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; + } + + if (isErrorFetchingSecretValue) return "Error loading secret value..."; + + return secret.valueOverride || ""; + }; + const { control, watch, handleSubmit, setValue, reset, + getValues, + getFieldState, formState: { isDirty } } = useForm({ resolver: zodResolver(formSchema), - values: secret, + values: { + ...secret, + valueOverride: getOverrideDefaultValue(), + value: getDefaultValue() + }, disabled: !secret }); @@ -119,9 +189,6 @@ export const SecretDetailSidebar = ({ "secretReferenceTree" ] as const); - const { permission } = useProjectPermission(); - const { currentProject } = useProject(); - const tagFields = useFieldArray({ control, name: "tags" @@ -139,7 +206,6 @@ export const SecretDetailSidebar = ({ {} ); const selectTagSlugs = selectedTags.map((i) => i.slug); - const navigate = useNavigate(); const cannotEditSecret = permission.cannot( ProjectPermissionSecretActions.Edit, @@ -205,7 +271,16 @@ export const SecretDetailSidebar = ({ }; const handleFormSubmit = async (data: TFormSchema) => { - await onSaveSecret(secret, { ...secret, ...data }, () => reset()); + await onSaveSecret( + secret, + { + ...secret, + ...data, + value: getFieldState("value").isDirty ? data.value : undefined, + valueOverride: getFieldState("valueOverride").isDirty ? data.valueOverride : undefined + }, + () => reset() + ); }; useEffect(() => { @@ -218,58 +293,22 @@ export const SecretDetailSidebar = ({ ); }, [secret?.secretReminderRecipients]); - const getModifiedByIcon = (userType: string | undefined | null) => { - switch (userType) { - case ActorType.USER: - return faUser; - case ActorType.IDENTITY: - return faDesktop; - default: - return faServer; - } - }; + const fetchValue = async () => { + if (secretValueData) return secretValueData.valueOverride ?? secretValueData.value; - const getModifiedByName = ( - userType: string | undefined | null, - userName: string | null | undefined - ) => { - switch (userType) { - case ActorType.PLATFORM: - return "System-generated"; - case ActorType.IDENTITY: - return userName || "Deleted Identity"; - case ActorType.USER: - return userName || "Deleted User"; - default: - return "Unknown"; - } - }; + try { + const data = await fetchSecretValue(fetchSecretValueParams); - const getLinkToModifyHistoryEntity = ( - actorId: string, - actorType: string, - membershipId: string | null = "" - ) => { - switch (actorType) { - case ActorType.USER: - return `/projects/secret-management/${currentProject.id}/members/${membershipId}`; - case ActorType.IDENTITY: - return `/projects/secret-management/${currentProject.id}/identities/${actorId}`; - default: - return null; - } - }; + queryClient.setQueryData(dashboardKeys.getSecretValue(fetchSecretValueParams), data); - const onModifyHistoryClick = ( - actorId: string | undefined | null, - actorType: string | undefined | null, - membershipId: string | undefined | null - ) => { - if (actorType && actorId && actorType !== ActorType.PLATFORM) { - const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType, membershipId); - if (redirectLink) { - navigate({ to: redirectLink }); - } + return data?.valueOverride ?? data.value; + } catch (error) { + console.error(error); + createNotification({ + type: "error", + text: "Error fetching secret value" + }); + throw error; } }; @@ -313,6 +352,7 @@ export const SecretDetailSidebar = ({ title={`Secret – ${secret?.key}`} className="thin-scrollbar h-full" cardBodyClassName="pb-0" + onOpenAutoFocus={(e) => e.preventDefault()} >
setIsFieldFocused.on()} + onBlur={() => { + setIsFieldFocused.off(); + field.onBlur(); + }} /> } - onClick={() => { - const value = secret?.valueOverride ?? secret?.value; - if (value) { - handleSecretShare(value); + onClick={async () => { + let value: string | undefined; + + if (hasFetchedSecretValue) { + const values = getValues(["value", "valueOverride"]); + value = secret.idOverride ? values[1] : values[0]; + } else { + value = await fetchValue(); } + + handleSecretShare(value ?? ""); }} > Share @@ -644,179 +702,21 @@ export const SecretDetailSidebar = ({
Version History
- {secretVersion?.map( - ({ createdAt, secretValue, secretValueHidden, version, id, actor }) => ( -
-
-
-
-
- v{version} -
-
-
{format(new Date(createdAt), "Pp")}
-
-
-
-
-
-
- {actor && ( -
-
- Modified by: - - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
- onModifyHistoryClick( - actor.actorId, - actor.actorType, - actor.membershipId - ) - } - className="cursor-pointer" - > - -
-
-
-
- )} -
-
- Value: -
-
-
- - -
- - {secretValueHidden - ? HIDDEN_SECRET_VALUE - : secretValue?.replace(/./g, "*")} - - -
-
-
-
-
- {!secret?.isRotatedSecret && ( -
- - setValue("value", secretValue, { shouldDirty: true })} - > - - - -
- )} -
- ) - )} + setTimeout(() => { + setValue("value", versionValue, { shouldDirty: true }); + }, 5); + }} + /> + ))}
@@ -942,26 +842,15 @@ export const SecretDetailSidebar = ({ )}
- } + onClick={() => handlePopUpOpen("secretReferenceTree", secretKey)} > -
- -
-
+ Secret Reference Tree + & { tags?: { id: string }[] }, @@ -91,7 +98,7 @@ type Props = { export const SecretItem = memo( ({ - secret, + secret: originalSecret, onSaveSecret, onDeleteSecret, onDetailViewSecret, @@ -114,9 +121,43 @@ export const SecretItem = memo( ] as const); const { currentProject } = useProject(); const { permission } = useProjectPermission(); - const { isRotatedSecret } = secret; const { removePendingChange } = useBatchModeActions(); + const [isFieldFocused, setIsFieldFocused] = useToggle(); + const queryClient = useQueryClient(); + + const canFetchSecretValue = + !originalSecret.secretValueHidden && + !originalSecret.isEmpty && + pendingAction !== PendingAction.Create; + + const fetchSecretValueParams = { + environment, + secretPath, + secretKey: originalSecret.originalKey || originalSecret.key, + projectId: currentProject.id, + isOverride: Boolean(originalSecret.idOverride) + }; + + const { + data: secretValueData, + isPending: isPendingSecretValueData, + isError: isErrorFetchingSecretValue + } = useGetSecretValue(fetchSecretValueParams, { + enabled: canFetchSecretValue && (isVisible || isFieldFocused) + }); + + const isLoadingSecretValue = canFetchSecretValue && isPendingSecretValueData; + const hasFetchedSecretValue = !canFetchSecretValue || Boolean(secretValueData); + + const secret = { + ...originalSecret, + value: originalSecret.value ?? secretValueData?.value, + valueOverride: originalSecret.valueOverride ?? secretValueData?.valueOverride + }; + + const { isRotatedSecret } = secret; + const autoSaveTimeoutRef = useRef(); const isAutoSavingRef = useRef(false); @@ -139,10 +180,27 @@ export const SecretItem = memo( ); const getDefaultValue = () => { + if (isLoadingSecretValue) return undefined; + if (secret.secretValueHidden && !isPending) { return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; } - return secret.valueOverride || secret.value || ""; + + if (isErrorFetchingSecretValue) return undefined; + + return secret.value || ""; + }; + + const getOverrideDefaultValue = () => { + if (isLoadingSecretValue) return undefined; + + if (secret.secretValueHidden && !isPending) { + return canEditSecretValue ? HIDDEN_SECRET_VALUE : ""; + } + + if (isErrorFetchingSecretValue) return undefined; + + return secret.valueOverride || ""; }; const { @@ -154,14 +212,17 @@ export const SecretItem = memo( reset, getValues, trigger, - formState: { isDirty, isSubmitting, errors } + formState: { isDirty, isSubmitting, errors }, + getFieldState } = useForm({ defaultValues: { ...secret, + valueOverride: getOverrideDefaultValue(), value: getDefaultValue() }, values: { ...secret, + valueOverride: getOverrideDefaultValue(), value: getDefaultValue() }, resolver: zodResolver(formSchema) @@ -255,7 +316,10 @@ export const SecretItem = memo( ); const isReadOnlySecret = - isReadOnly || isRotatedSecret || (isPending && pendingAction === PendingAction.Delete); + isReadOnly || + isRotatedSecret || + (isPending && pendingAction === PendingAction.Delete) || + isLoadingSecretValue; const { secretValueHidden } = secret; @@ -322,12 +386,36 @@ export const SecretItem = memo( } }; - const copyTokenToClipboard = () => { - const [overrideValue, value] = getValues(["value", "valueOverride"]); - if (isOverridden) { - navigator.clipboard.writeText(value as string); + const fetchValue = async () => { + if (secretValueData) return secretValueData; + + try { + const data = await fetchSecretValue(fetchSecretValueParams); + + queryClient.setQueryData(dashboardKeys.getSecretValue(fetchSecretValueParams), data); + + return data; + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to fetch secret value" + }); + throw e; + } + }; + + const copyTokenToClipboard = async () => { + if (hasFetchedSecretValue) { + const [overrideValue, value] = getValues(["value", "valueOverride"]); + if (isOverridden) { + navigator.clipboard.writeText(value as string); + } else { + navigator.clipboard.writeText(overrideValue as string); + } } else { - navigator.clipboard.writeText(overrideValue as string); + const data = await fetchValue(); + navigator.clipboard.writeText((data.valueOverride ?? data.value) as string); } setIsSecValueCopied.on(); }; @@ -410,7 +498,7 @@ export const SecretItem = memo( tabIndex={0} role="button" > - {secretValueHidden && !isOverridden && !isPending && ( + {secretValueHidden && !getFieldState("value").isDirty && ( @@ -424,10 +512,18 @@ export const SecretItem = memo( control={control} render={({ field }) => ( { + if (secret.idOverride) setIsFieldFocused.on(); + }} + onBlur={() => { + setIsFieldFocused.off(); + field.onBlur(); + }} containerClassName="py-1.5 rounded-md transition-all" /> )} @@ -439,6 +535,8 @@ export const SecretItem = memo( control={control} render={({ field }) => ( { + setIsFieldFocused.on(); + }} + onBlur={() => { + setIsFieldFocused.off(); + field.onBlur(); + }} defaultValue={ secretValueHidden && !isPending ? HIDDEN_SECRET_VALUE : undefined } @@ -613,7 +718,7 @@ export const SecretItem = memo( onShareSecret(secret)} + onClick={async () => { + if (hasFetchedSecretValue) { + onShareSecret(secret); + return; + } + + const data = await fetchValue(); + + onShareSecret({ + ...secret, + ...data + }); + }} > { - const { key, id: secretId, value } = popUp.deleteSecret?.data as SecretV3RawSanitized; + const { + key, + id: secretId, + value, + secretValueHidden + } = popUp.deleteSecret?.data as SecretV3RawSanitized; try { if (isBatchMode) { const deleteChange: PendingSecretDelete = { @@ -475,7 +480,8 @@ export const SecretListView = ({ secretKey: key, secretValue: value || "", timestamp: Date.now(), - resourceType: "secret" + resourceType: "secret", + secretValueHidden }; addPendingChange(deleteChange, { @@ -616,19 +622,21 @@ export const SecretListView = ({ } /> - handlePopUpToggle("secretDetail", isOpen)} - secret={popUp.secretDetail.data as SecretV3RawSanitized} - onDeleteSecret={() => handlePopUpOpen("deleteSecret", popUp.secretDetail.data)} - onClose={() => handlePopUpClose("secretDetail")} - onSaveSecret={handleSaveSecret} - tags={wsTags} - onCreateTag={() => handlePopUpOpen("createTag")} - handleSecretShare={(value: string) => handlePopUpOpen("createSharedSecret", { value })} - /> + {popUp.secretDetail.data && ( + handlePopUpToggle("secretDetail", isOpen)} + secret={popUp.secretDetail.data as SecretV3RawSanitized} + onDeleteSecret={() => handlePopUpOpen("deleteSecret", popUp.secretDetail.data)} + onClose={() => handlePopUpClose("secretDetail")} + onSaveSecret={handleSaveSecret} + tags={wsTags} + onCreateTag={() => handlePopUpOpen("createTag")} + handleSecretShare={(value: string) => handlePopUpOpen("createSharedSecret", { value })} + /> + )} handlePopUpToggle("createTag", isOpen)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx new file mode 100644 index 000000000..808e549ee --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretVersionItem.tsx @@ -0,0 +1,277 @@ +import { useState } from "react"; +import { faEye } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowRotateRight, + faDesktop, + faEyeSlash, + faServer, + faUser +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { fetchSecretVersionValue } from "@app/hooks/api/secrets/queries"; +import { SecretV3RawSanitized, SecretVersions } from "@app/hooks/api/secrets/types"; + +interface SecretVersionItemProps { + secretVersion: SecretVersions; + secret: SecretV3RawSanitized; + currentVersion: number; + onRevert: (secretValue: string) => void; + canReadValue: boolean; +} + +export const SecretVersionItem = ({ + secretVersion: { createdAt, version, actor, secretValueHidden }, + secret, + currentVersion, + onRevert, + canReadValue +}: SecretVersionItemProps) => { + const { currentProject } = useProject(); + + const navigate = useNavigate(); + + const getModifiedByIcon = (userType: string | undefined | null) => { + switch (userType) { + case ActorType.USER: + return faUser; + case ActorType.IDENTITY: + return faDesktop; + default: + return faServer; + } + }; + + const getModifiedByName = ( + userType: string | undefined | null, + userName: string | null | undefined + ) => { + switch (userType) { + case ActorType.PLATFORM: + return "System-generated"; + case ActorType.IDENTITY: + return userName || "Deleted Identity"; + case ActorType.USER: + return userName || "Deleted User"; + default: + return "Unknown"; + } + }; + + const getLinkToModifyHistoryEntity = ( + actorId: string, + actorType: string, + membershipId: string | null = "" + ) => { + switch (actorType) { + case ActorType.USER: + return `/projects/secret-management/${currentProject.id}/members/${membershipId}`; + case ActorType.IDENTITY: + return `/projects/secret-management/${currentProject.id}/identities/${actorId}`; + default: + return null; + } + }; + + const onModifyHistoryClick = ( + actorId: string | undefined | null, + actorType: string | undefined | null, + membershipId: string | undefined | null + ) => { + if (actorType && actorId && actorType !== ActorType.PLATFORM) { + const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType, membershipId); + if (redirectLink) { + navigate({ to: redirectLink }); + } + } + }; + + const [secretValue, setSecretValue] = useState(null); + const [isFetchingValue, setIsFetchingValue] = useState(false); + const handleGetSecretValue = async () => { + if (secretValue) return secretValue; + try { + setIsFetchingValue(true); + const value = await fetchSecretVersionValue(secret.id, version); + setSecretValue(value); + return value; + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to fetch secret version value" + }); + throw e; + } finally { + setIsFetchingValue(false); + } + }; + + const handleCopyValue = async ( + e: React.MouseEvent | React.KeyboardEvent + ) => { + const value = await handleGetSecretValue(); + navigator.clipboard.writeText(value || ""); + const target = e.currentTarget; + target.style.borderBottom = "1px dashed"; + target.style.paddingBottom = "-1px"; + + // Create and insert popup + const popup = document.createElement("div"); + popup.className = + "w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50"; + popup.textContent = "Copied!"; + target.parentElement?.appendChild(popup); + + // Remove popup and border after delay + setTimeout(() => { + popup.remove(); + target.style.borderBottom = "none"; + }, 3000); + }; + + return ( +
+
+
+
+
+ v{version} +
+
+
{format(new Date(createdAt), "Pp")}
+
+
+
+
+
+
+ {actor && ( +
+
+ Modified by: + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ onModifyHistoryClick(actor.actorId, actor.actorType, actor.membershipId) + } + className="cursor-pointer" + > + +
+
+
+
+ )} +
+
+ Value: +
+
+
+ + +
+ + **** + + +
+
+
+
+
+ {!secret?.isRotatedSecret && canReadValue && ( +
+ + { + if (secretValue) { + onRevert(secretValue); + return; + } + + const value = await handleGetSecretValue(); + + onRevert(value); + }} + > + + + +
+ )} +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx index 77e6eddab..a2c54a11f 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationItem.tsx @@ -15,13 +15,13 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { SecretRotationV2StatusBadge } from "@app/components/secret-rotations-v2/SecretRotationV2StatusBadge"; import { IconButton, Modal, ModalContent, TableContainer, Tag, Tooltip } from "@app/components/v2"; -import { Blur } from "@app/components/v2/Blur"; -import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types"; import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { SecretRotationSecretRow } from "./SecretRotationSecretRow"; + type Props = { secretRotation: TSecretRotationV2; onEdit: () => void; @@ -209,43 +209,13 @@ export const SecretRotationItem = ({ {secrets.map((secret, index) => { return ( - - - - - {secret?.key ?? "********"} - - - - {/* eslint-disable-next-line no-nested-ternary */} - {!secret ? ( -
********
- ) : secret.secretValueHidden ? ( - - ) : ( - {}} - /> - )} - - -
+ ); })} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationSecretRow.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationSecretRow.tsx new file mode 100644 index 000000000..e8b7788af --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView/SecretRotationSecretRow.tsx @@ -0,0 +1,76 @@ +import { twMerge } from "tailwind-merge"; + +import { SecretInput, Tooltip } from "@app/components/v2"; +import { Blur } from "@app/components/v2/Blur"; +import { useProject } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useGetSecretValue } from "@app/hooks/api/dashboard/queries"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; + +interface SecretRotationSecretRowProps { + secret: TSecretRotationV2["secrets"][number]; + secretPath: string; + environment: string; +} + +export const SecretRotationSecretRow = ({ + secret, + environment, + secretPath +}: SecretRotationSecretRowProps) => { + const [isFieldFocused, setIsFieldFocused] = useToggle(); + + const { currentProject } = useProject(); + + const { data: secretValue, isPending: isLoadingSecretValue } = useGetSecretValue( + { + environment, + secretPath, + secretKey: secret?.key ?? "", + projectId: currentProject.id + }, + { + enabled: isFieldFocused && Boolean(secret) + } + ); + + const getValue = () => { + if (isLoadingSecretValue) return HIDDEN_SECRET_VALUE; + + if (!secretValue) return "Error loading secret value"; + + return secretValue.value || ""; + }; + + return ( + + + + {secret?.key ?? "********"} + + + {/* eslint-disable-next-line no-nested-ternary */} + {!secret ? ( +
********
+ ) : secret.secretValueHidden ? ( + + ) : ( + setIsFieldFocused.on()} + onBlur={() => setIsFieldFocused.off()} + /> + )} + + +
+ ); +};