Merge pull request #4545 from Infisical/ENG-3501

[WIP] feat(dashboard): Only fetch secret value on relevant actions
This commit is contained in:
Scott Wilson
2025-09-18 17:54:08 -07:00
committed by GitHub
38 changed files with 1698 additions and 588 deletions

View File

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

View File

@@ -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<ReturnType<typeof server.services.secretImport.getImportsMultiEnv>> | undefined;
let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined;
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined;
let secrets:
| (Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>>[number] & { isEmpty: boolean })[]
| undefined;
let dynamicSecrets:
| Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnvs>>
| 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<ReturnType<typeof server.services.folder.getFolders>> | undefined;
let secrets:
| (Awaited<ReturnType<typeof server.services.secret.getSecretsRaw>>["secrets"][number] & {
isEmpty: boolean;
reminder: Awaited<ReturnType<typeof server.services.reminder.getRemindersForDashboard>>[string] | null;
})[]
| undefined;
let dynamicSecrets: Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnv>> | undefined;
let secretRotations:
| Awaited<ReturnType<typeof server.services.secretRotationV2.getDashboardSecretRotations>>
| (Awaited<ReturnType<typeof server.services.secretRotationV2.getDashboardSecretRotations>>[number] & {
secrets: (NonNullable<
Awaited<
ReturnType<typeof server.services.secretRotationV2.getDashboardSecretRotations>
>[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 };
}
});
};

View File

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

View File

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

View File

@@ -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 ({

View File

@@ -159,6 +159,7 @@ export type TGetSecretVersionsDTO = Omit<TProjectPermission, "projectId"> & {
limit?: number;
offset?: number;
secretId: string;
secretVersions?: string[];
};
export type TSecretReference = { environment: string; secretPath: string; secretKey: string };

View File

@@ -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") {

View File

@@ -331,6 +331,7 @@ export type TGetSecretVersionsDTO = Omit<TProjectPermission, "projectId"> & {
limit?: number;
offset?: number;
secretId: string;
secretVersions?: string[];
};
export type TSecretReference = { environment: string; secretPath: string };

View File

@@ -130,6 +130,14 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop
);
}
if (tree?.children?.length === 0) {
return (
<div className="flex items-center justify-center py-4">
<span className="text-mineshaft-400">This secret does not contain references</span>
</div>
);
}
return (
<div>
<FormControl label="Expanded value">

View File

@@ -55,6 +55,8 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
secretPath?: string;
environment?: string;
containerClassName?: string;
isLoadingValue?: boolean;
isErrorLoadingValue?: boolean;
};
type ReferenceItem = {
@@ -279,11 +281,16 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
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}

View File

@@ -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 <span className="ph-no-capture text-red/75">Error loading secret value.</span>;
if (isImport && !content) return "IMPORTED";
if (content === "") return "EMPTY";
if (!content) return "EMPTY";
@@ -48,6 +57,8 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
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<HTMLTextAreaElement, Props>(
isReadOnly,
onFocus,
canEditButNotView,
isLoadingValue,
isErrorLoadingValue,
...props
},
ref
@@ -83,7 +96,9 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
{syntaxHighlight(
value,
isVisible || (isSecretFocused && !valueAlwaysHidden),
isImport
isImport,
isLoadingValue,
isErrorLoadingValue
)}
</span>
</code>
@@ -114,7 +129,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
}}
value={value || ""}
{...props}
readOnly={isReadOnly}
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
/>
</div>
</div>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<ReturnType<typeof fetchProjectSecretsOverview>>) => {
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<ReturnType<typeof fetchProjectSecretsDetails>>) => ({
...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<DashboardSecretValue>("/api/v1/dashboard/secret-value", {
params
});
return data;
};
export const useGetSecretValue = (
params: TGetSecretValueDTO,
options?: Omit<
UseQueryOptions<
DashboardSecretValue,
unknown,
DashboardSecretValue,
ReturnType<typeof dashboardKeys.getSecretValue>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: dashboardKeys.getSecretValue(params),
queryFn: async () => fetchSecretValue(params),
staleTime: 1000 * 60,
...options
});
};

View File

@@ -112,7 +112,6 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
TGetDashboardProjectSecretsOverviewDTO,
"environments"
> & {
viewSecretValue: boolean;
environment: string;
includeImports?: boolean;
tags: Record<string, boolean>;
@@ -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;
};

View File

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

View File

@@ -28,7 +28,7 @@ export type TImportedSecrets = {
environmentInfo: ProjectEnv;
secretPath: string;
folderId: string;
secrets: SecretV3Raw[];
secrets: Omit<SecretV3Raw, "secretValue">[];
};
export type TGetSecretImports = {

View File

@@ -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<string, { id: string; value?: string; env: string }> = {};
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<TSecretVersionValue>(
`/api/v1/dashboard/secret-versions/${secretId}/value/${version}`
);
return data.value;
};
export const useGetSecretVersionValue = (
dto: TGetSecretVersionValue,
options?: Omit<
UseQueryOptions<string, unknown, string, ReturnType<typeof secretKeys.getSecretVersionValue>>,
"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),

View File

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

View File

@@ -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) => {

View File

@@ -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 (
<Tooltip
className="max-w-sm"
content={secret ? undefined : "You do not have permission to view this secret."}
>
<tr className="!last:border-b-0 h-full hover:bg-mineshaft-700">
<td className="flex h-full items-center" style={{ padding: "0.5rem 1rem" }}>
<span className={twMerge(!secret && "blur")}>{secret?.key ?? "********"}</span>
</td>
<td className="col-span-2 h-full w-full" style={{ padding: "0.5rem 1rem" }}>
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<SecretInput
isReadOnly
value={secretValue}
isVisible={isSecretVisible}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => setIsFieldFocused.off()}
/>
)}
</td>
</tr>
</Tooltip>
);
};

View File

@@ -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 = ({
<tbody className="border-t-2 border-mineshaft-600">
{secrets.map((secret, index) => {
return (
<Tooltip
className="max-w-sm"
content={
secret
? undefined
: "You do not have permission to view this secret."
}
>
<tr
// eslint-disable-next-line react/no-array-index-key
key={`rotation-secret-${secretRotation.id}-${index}`}
className="!last:border-b-0 h-full hover:bg-mineshaft-700"
>
<td
className="flex h-full items-center"
style={{ padding: "0.5rem 1rem" }}
>
<span className={twMerge(!secret && "blur")}>
{secret?.key ?? "********"}
</span>
</td>
<td
className="col-span-2 h-full w-full"
style={{ padding: "0.5rem 1rem" }}
>
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<InfisicalSecretInput
isReadOnly
value={secret.value}
isVisible={isSecretVisible}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
onChange={() => {}}
/>
)}
</td>
</tr>
</Tooltip>
<SecretOverviewRotationSecretRow
key={`rotation-secret-${secretRotation.id}-${index + 1}`}
secret={secret}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
isSecretVisible={isSecretVisible}
/>
);
})}
</tbody>

View File

@@ -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<void>;
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
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 = ({
)}
<div className="flex-grow border-r border-r-mineshaft-600 pl-1 pr-2">
<Controller
disabled={isImportedSecret && !defaultValue}
control={control}
name="value"
render={({ field }) => (
<InfisicalSecretInput
{...field}
isReadOnly={isImportedSecret || (isRotatedSecret && !isOverride)}
value={field.value as string}
isReadOnly={
isImportedSecret ||
(isRotatedSecret && !isOverride) ||
isFetchingSecretValue ||
isErrorFetchingSecretValue
}
value={
// eslint-disable-next-line no-nested-ternary
isFetchingSecretValue
? HIDDEN_SECRET_VALUE
: isErrorFetchingSecretValue
? "Error fetching secret value..."
: (field.value as string)
}
key="secret-input"
isVisible={isVisible && !secretValueHidden}
secretPath={secretPath}
@@ -236,6 +328,11 @@ export const SecretEditRow = ({
isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined}
canEditButNotView={secretValueHidden && !isOverride}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => {
field.onBlur();
setIsFieldFocused.off();
}}
/>
)}
/>
@@ -309,18 +406,12 @@ export const SecretEditRow = ({
<Modal>
<ModalTrigger asChild>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip
content={
hasSecretReference(defaultValue || "")
? "Secret Reference Tree"
: "Secret does not contain references"
}
>
<Tooltip content="Secret Reference Tree">
<IconButton
variant="plain"
ariaLabel="reference-tree"
className="h-full"
isDisabled={!hasSecretReference(defaultValue || "") || !canReadSecretValue}
isDisabled={!canReadSecretValue || !secretId || isEmpty}
>
<FontAwesomeIcon icon={faProjectDiagram} />
</IconButton>

View File

@@ -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 (
<Td
key={`sec-overview-${slug}-${i + 1}-value`}
@@ -254,7 +261,7 @@ export const SecretOverviewTableRow = ({
<FontAwesomeIcon icon={faRotate} />
</Tooltip>
)}
{secret?.valueOverride && (
{secret?.idOverride && (
<Tooltip content="Personal Override">
<FontAwesomeIcon icon={faCodeBranch} />
</Tooltip>
@@ -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}

View File

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

View File

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

View File

@@ -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<CombinedState, [], [], BatchModeState>
const mergedUpdate: PendingSecretUpdate = {
...existingUpdate,
secretKey: existingUpdate.secretKey,
originalValue: existingUpdate.originalValue,
originalValue: change.originalValue,
originalComment: existingUpdate.originalComment,
originalSkipMultilineEncoding: existingUpdate.originalSkipMultilineEncoding,
originalTags: existingUpdate.originalTags,

View File

@@ -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 (
<SecretVersionDiffView
onDiscard={onDiscard}
@@ -143,7 +149,12 @@ const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => {
{
version: 1, // placeholder, not used
secretKey,
secretValue
// eslint-disable-next-line no-nested-ternary
secretValue: secretValue
? secretValueHidden
? HIDDEN_SECRET_VALUE_API_MASK
: secretValue
: undefined
}
]
}}

View File

@@ -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) => {
</Tooltip>
</Tab>
)}
{currentProject.environments.length > 1 && (
{/* scott: removing until we have time to update for fetching secret value */}
{/* {currentProject.environments.length > 1 && (
<Tab className="ml-auto" value={COMPARE_ENVIRONMENT_TAB}>
<div className="flex items-center gap-x-2 whitespace-nowrap">
<FontAwesomeIcon icon={faArrowRightArrowLeft} />
Compare Environments
</div>
</Tab>
)}
)} */}
</TabList>
</Tabs>
<Modal

View File

@@ -20,12 +20,14 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2";
import { EmptyState, IconButton, TableContainer, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { useToggle } from "@app/hooks";
import { useResyncSecretReplication } from "@app/hooks/api";
import { TSecretImport } from "@app/hooks/api/types";
import { SecretImportSecretRow } from "./SecretImportSecretRow";
type Props = {
onDelete: () => 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 = ({
<tr>
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
{/* <td style={{ padding: "0.25rem 1rem" }}>Override</td> */}
</tr>
</thead>
<tbody>
@@ -317,18 +321,11 @@ export const SecretImportItem = ({
</td>
</tr>
)}
{filteredImportedSecrets.map(({ key, value }, index) => (
<tr key={`${id}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key}
</td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<SecretInput value={value} isReadOnly />
</td>
{/* <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
</td> */}
</tr>
{filteredImportedSecrets.map((secret, index) => (
<SecretImportSecretRow
secret={secret}
key={`${id}-${secret.key}-${index + 1}`}
/>
))}
</tbody>
</table>

View File

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

View File

@@ -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 (
<tr>
<td className="h-10 w-1/2" style={{ padding: "0.25rem 1rem" }}>
{key}
</td>
<td className="h-10 w-1/2" style={{ padding: "0.25rem 1rem" }}>
<SecretInput
value={getValue()}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => setIsFieldFocused.off()}
isReadOnly
/>
</td>
</tr>
);
};

View File

@@ -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<TFormSchema>({
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()}
>
<form
onSubmit={handleSubmit(handleFormSubmit)}
@@ -356,7 +396,14 @@ export const SecretDetailSidebar = ({
>
<div className="flex items-start gap-x-2">
<InfisicalSecretInput
isReadOnly={isReadOnly || !isAllowed || secret?.isRotatedSecret}
isReadOnly={
isReadOnly ||
!isAllowed ||
secret?.isRotatedSecret ||
isLoadingSecretValue ||
isErrorFetchingSecretValue
}
canEditButNotView={secret.secretValueHidden && canEditSecretValue}
environment={environment}
secretPath={secretPath}
key="secret-value"
@@ -364,6 +411,11 @@ export const SecretDetailSidebar = ({
containerClassName="text-bunker-300 w-full hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
{...field}
autoFocus={false}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => {
setIsFieldFocused.off();
field.onBlur();
}}
/>
<Tooltip
content={
@@ -378,11 +430,17 @@ export const SecretDetailSidebar = ({
className="px-2 py-[0.43rem] font-normal"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faShare} />}
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 = ({
<div className="dark flex max-h-[24rem] flex-1 cursor-default flex-col text-sm text-bunker-300">
<div className="mb-0.5 text-mineshaft-400">Version History</div>
<div className="thin-scrollbar flex flex-1 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 dark:[color-scheme:dark]">
{secretVersion?.map(
({ createdAt, secretValue, secretValueHidden, version, id, actor }) => (
<div className="flex flex-row" key={id}>
<div className="flex w-full flex-col space-y-1">
<div className="flex items-center">
<div className="w-10">
<div className="w-fit rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 text-sm text-mineshaft-300">
v{version}
</div>
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="flex w-full cursor-default">
<div className="relative w-10">
<div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" />
</div>
<div className="flex w-full cursor-default flex-col">
{actor && (
<div className="flex flex-row">
<div className="flex w-fit flex-row text-sm">
Modified by:
<Tooltip content={getModifiedByName(actor.actorType, actor.name)}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
onClick={() =>
onModifyHistoryClick(
actor.actorId,
actor.actorType,
actor.membershipId
)
}
className="cursor-pointer"
>
<FontAwesomeIcon
icon={getModifiedByIcon(actor.actorType)}
className="ml-2"
/>
</div>
</Tooltip>
</div>
</div>
)}
<div className="flex flex-row">
<div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70">
Value:
</div>
<div className="group break-all pl-1 font-mono">
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline">
<button
type="button"
className="select-none text-left"
onClick={(e) => {
if (secretValueHidden) return;
{secretVersion?.map((version) => (
<SecretVersionItem
canReadValue={!cannotReadSecretValue}
secretVersion={version}
secret={secret}
currentVersion={secretVersion.length}
onRevert={async (versionValue) => {
await fetchValue();
navigator.clipboard.writeText(secretValue || "");
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);
}}
onKeyDown={(e) => {
if (secretValueHidden) return;
if (e.key === "Enter" || e.key === " ") {
navigator.clipboard.writeText(secretValue || "");
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);
}
}}
>
<span
className={twMerge(
secretValueHidden && "text-xs text-bunker-300 opacity-40"
)}
>
{secretValueHidden ? "Hidden" : secretValue}
</span>
</button>
<button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.currentTarget
.closest(".group")
?.classList.remove("show-value");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.currentTarget
.closest(".group")
?.classList.remove("show-value");
}
}}
>
<FontAwesomeIcon icon={faEyeSlash} />
</button>
</div>
<span className="group-[.show-value]:hidden">
{secretValueHidden
? HIDDEN_SECRET_VALUE
: secretValue?.replace(/./g, "*")}
<button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.currentTarget
.closest(".group")
?.classList.add("show-value");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.currentTarget
.closest(".group")
?.classList.add("show-value");
}
}}
>
<FontAwesomeIcon icon={faEye} />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
{!secret?.isRotatedSecret && (
<div
className={`flex items-center justify-center ${version === secretVersion.length ? "hidden" : ""}`}
>
<Tooltip content="Restore Secret Value">
<IconButton
ariaLabel="Restore"
variant="outline_bg"
size="sm"
className="h-8 w-8 rounded-md"
onClick={() => setValue("value", secretValue, { shouldDirty: true })}
>
<FontAwesomeIcon icon={faArrowRotateRight} />
</IconButton>
</Tooltip>
</div>
)}
</div>
)
)}
setTimeout(() => {
setValue("value", versionValue, { shouldDirty: true });
}, 5);
}}
/>
))}
</div>
</div>
<div className="dark flex flex-col text-sm text-bunker-300">
@@ -942,26 +842,15 @@ export const SecretDetailSidebar = ({
)}
</div>
<div className="mt-auto flex items-center space-x-2 pb-4">
<Tooltip
content={
hasSecretReference(secret?.value)
? undefined
: "Secret does not contain any references."
}
className="z-[100] text-center"
<Button
className="flex-1"
variant="outline_bg"
isDisabled={cannotReadSecretValue || secret.isEmpty}
leftIcon={<FontAwesomeIcon icon={faProjectDiagram} />}
onClick={() => handlePopUpOpen("secretReferenceTree", secretKey)}
>
<div className="flex-1">
<Button
className="w-full"
variant="outline_bg"
isDisabled={cannotReadSecretValue || !hasSecretReference(secret?.value)}
leftIcon={<FontAwesomeIcon icon={faProjectDiagram} />}
onClick={() => handlePopUpOpen("secretReferenceTree", secretKey)}
>
Secret Reference Tree
</Button>
</div>
</Tooltip>
Secret Reference Tree
</Button>
<Tooltip content="Copy Secret ID" className="z-[100]">
<IconButton
variant="outline_bg"

View File

@@ -27,8 +27,8 @@ import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useProject
useProject,
useProjectPermission
} from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
@@ -47,6 +47,13 @@ import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { format } from "date-fns";
import { CreateReminderForm } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm";
import {
dashboardKeys,
fetchSecretValue,
useGetSecretValue
} from "@app/hooks/api/dashboard/queries";
import { createNotification } from "@app/components/notifications";
import { useQueryClient } from "@tanstack/react-query";
import {
FontAwesomeSpriteName,
formSchema,
@@ -56,11 +63,11 @@ import {
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { useBatchModeActions } from "../../SecretMainPage.store";
export const HIDDEN_SECRET_VALUE = "*****************************";
export const HIDDEN_SECRET_VALUE = "*************************";
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
type Props = {
secret: SecretV3RawSanitized;
secret: SecretV3RawSanitized & { originalKey?: string };
onSaveSecret: (
orgSec: SecretV3RawSanitized,
modSec: Omit<SecretV3RawSanitized, "tags"> & { 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<NodeJS.Timeout>();
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<TFormSchema>({
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 && (
<Tooltip
content={`You do not have access to view the current value${canEditSecretValue && !isRotatedSecret ? ", but you can set a new one" : "."}`}
>
@@ -424,10 +512,18 @@ export const SecretItem = memo(
control={control}
render={({ field }) => (
<SecretInput
isLoadingValue={isLoadingSecretValue && Boolean(secret.idOverride)}
isErrorLoadingValue={isErrorFetchingSecretValue}
key="value-overriden"
isVisible={isVisible}
isReadOnly={isReadOnly}
{...field}
onFocus={() => {
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 }) => (
<InfisicalSecretInput
isLoadingValue={isLoadingSecretValue}
isErrorLoadingValue={isErrorFetchingSecretValue}
isReadOnly={isReadOnlySecret}
key="secret-value"
isVisible={isVisible && (!secretValueHidden || isPending)}
@@ -446,6 +544,13 @@ export const SecretItem = memo(
environment={environment}
secretPath={secretPath}
{...field}
onFocus={() => {
setIsFieldFocused.on();
}}
onBlur={() => {
setIsFieldFocused.off();
field.onBlur();
}}
defaultValue={
secretValueHidden && !isPending ? HIDDEN_SECRET_VALUE : undefined
}
@@ -613,7 +718,7 @@ export const SecretItem = memo(
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
@@ -682,7 +787,19 @@ export const SecretItem = memo(
variant="plain"
size="md"
ariaLabel="share-secret"
onClick={() => onShareSecret(secret)}
onClick={async () => {
if (hasFetchedSecretValue) {
onShareSecret(secret);
return;
}
const data = await fetchValue();
onShareSecret({
...secret,
...data
});
}}
>
<Tooltip content="Share Secret">
<FontAwesomeSymbol

View File

@@ -466,7 +466,12 @@ export const SecretListView = ({
);
const handleSecretDelete = useCallback(async () => {
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 = ({
</>
}
/>
<SecretDetailSidebar
environment={environment}
secretPath={secretPath}
isOpen={popUp.secretDetail.isOpen}
onToggle={(isOpen) => 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 && (
<SecretDetailSidebar
environment={environment}
secretPath={secretPath}
isOpen={popUp.secretDetail.isOpen}
onToggle={(isOpen) => 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 })}
/>
)}
<CreateTagModal
isOpen={popUp.createTag.isOpen}
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}

View File

@@ -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<string | null>(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<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>
) => {
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 (
<div className="flex flex-row">
<div className="flex w-full flex-col space-y-1">
<div className="flex items-center">
<div className="w-10">
<div className="w-fit rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 text-sm text-mineshaft-300">
v{version}
</div>
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="flex w-full cursor-default">
<div className="relative w-10">
<div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" />
</div>
<div className="flex w-full cursor-default flex-col">
{actor && (
<div className="flex flex-row">
<div className="flex w-fit flex-row text-sm">
Modified by:
<Tooltip content={getModifiedByName(actor.actorType, actor.name)}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
onClick={() =>
onModifyHistoryClick(actor.actorId, actor.actorType, actor.membershipId)
}
className="cursor-pointer"
>
<FontAwesomeIcon icon={getModifiedByIcon(actor.actorType)} className="ml-2" />
</div>
</Tooltip>
</div>
</div>
)}
<div className="flex flex-row">
<div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70">
Value:
</div>
<div className="group break-all pl-1 font-mono">
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline">
<button
type="button"
className="select-none text-left"
onClick={async (e) => {
if (secretValueHidden) return;
await handleCopyValue(e);
}}
onKeyDown={async (e) => {
if (secretValueHidden) return;
if (e.key === "Enter" || e.key === " ") {
await handleCopyValue(e);
}
}}
>
<span
className={twMerge(secretValueHidden && "text-xs text-bunker-300 opacity-40")}
>
{/* eslint-disable-next-line no-nested-ternary */}
{secretValueHidden
? "Hidden"
: isFetchingValue
? "****"
: ((secretValue || <span className="text-mineshaft-400">EMPTY</span>) ??
"Error fetching secret value...")}
</span>
</button>
<button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.currentTarget.closest(".group")?.classList.remove("show-value");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.currentTarget.closest(".group")?.classList.remove("show-value");
}
}}
>
<FontAwesomeIcon icon={faEyeSlash} />
</button>
</div>
<span className="group-[.show-value]:hidden">
****
<button
type="button"
className="ml-1 cursor-pointer"
onClick={async (e) => {
e.currentTarget.closest(".group")?.classList.add("show-value");
await handleGetSecretValue();
}}
onKeyDown={async (e) => {
if (e.key === "Enter" || e.key === " ") {
e.currentTarget.closest(".group")?.classList.add("show-value");
await handleGetSecretValue();
}
}}
>
<FontAwesomeIcon icon={faEye} />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
{!secret?.isRotatedSecret && canReadValue && (
<div
className={`flex items-center justify-center ${version === currentVersion ? "hidden" : ""}`}
>
<Tooltip content="Restore Secret Value">
<IconButton
ariaLabel="Restore"
variant="outline_bg"
size="sm"
className="h-8 w-8 rounded-md"
onClick={async () => {
if (secretValue) {
onRevert(secretValue);
return;
}
const value = await handleGetSecretValue();
onRevert(value);
}}
>
<FontAwesomeIcon icon={faArrowRotateRight} />
</IconButton>
</Tooltip>
</div>
)}
</div>
);
};

View File

@@ -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 = ({
<tbody>
{secrets.map((secret, index) => {
return (
<Tooltip
className="max-w-sm"
content={
secret ? undefined : "You do not have permission to view this secret."
}
>
<tr
// eslint-disable-next-line react/no-array-index-key
key={`rotation-secret-${secretRotation.id}-${index}`}
className="h-full last:!border-b-0 hover:bg-mineshaft-700"
>
<td className="flex h-full items-center" style={{ padding: "0.5rem 1rem" }}>
<span className={twMerge(!secret && "blur")}>
{secret?.key ?? "********"}
</span>
</td>
<td className="col-span-2 h-full w-full" style={{ padding: "0.5rem 1rem" }}>
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<InfisicalSecretInput
isReadOnly
value={secret.value}
secretPath={secretRotation.folder.path}
environment={secretRotation.environment.slug}
onChange={() => {}}
/>
)}
</td>
</tr>
</Tooltip>
<SecretRotationSecretRow
// eslint-disable-next-line react/no-array-index-key
key={`rotation-secret-${secretRotation.id}-${index}`}
secret={secret}
environment={secretRotation.environment.slug}
secretPath={secretRotation.folder.path}
/>
);
})}
</tbody>

View File

@@ -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 (
<Tooltip
className="max-w-sm"
content={secret ? undefined : "You do not have permission to view this secret."}
>
<tr className="h-full last:!border-b-0 hover:bg-mineshaft-700">
<td className="flex h-full items-center" style={{ padding: "0.5rem 1rem" }}>
<span className={twMerge(!secret && "blur")}>{secret?.key ?? "********"}</span>
</td>
<td className="col-span-2 h-full w-full" style={{ padding: "0.5rem 1rem" }}>
{/* eslint-disable-next-line no-nested-ternary */}
{!secret ? (
<div className="h-full pl-4 blur">********</div>
) : secret.secretValueHidden ? (
<Blur
className="py-0"
tooltipText="You do not have permission to read the value of this secret."
/>
) : (
<SecretInput
isReadOnly
value={getValue()}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => setIsFieldFocused.off()}
/>
)}
</td>
</tr>
</Tooltip>
);
};