This commit is contained in:
Scott Wilson
2025-09-17 13:36:46 -07:00
parent 2974938f70
commit 8f8e8d3f88
32 changed files with 1565 additions and 517 deletions

View File

@@ -480,7 +480,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[] = [
@@ -3502,6 +3506,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;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -3818,4 +3850,7 @@ export type Event =
| ProjectDeleteEvent
| SecretReminderCreateEvent
| SecretReminderGetEvent
| SecretReminderDeleteEvent;
| SecretReminderDeleteEvent
| DashboardListSecretsEvent
| DashboardGetSecretValueEvent
| DashboardGetSecretVersionValueEvent;

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

@@ -2333,7 +2333,8 @@ export const secretV2BridgeServiceFactory = ({
actorAuthMethod,
limit = 20,
offset = 0,
secretId
secretId,
secretVersions: secretVersionsFilter
}: TGetSecretVersionsDTO) => {
const secret = await secretDAL.findById(secretId);
@@ -2370,6 +2371,7 @@ export const secretV2BridgeServiceFactory = ({
const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({
secretId,
projectId: folder.projectId,
secretVersions: secretVersionsFilter,
findOpt: {
offset,
limit,

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

@@ -279,11 +279,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

@@ -222,7 +222,15 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.UPDATE_ORG]: "Update Organization",
[EventType.CREATE_PROJECT]: "Create Project",
[EventType.UPDATE_PROJECT]: "Update Project",
[EventType.DELETE_PROJECT]: "Delete 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"
};
export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = {

View File

@@ -216,5 +216,13 @@ export enum EventType {
CREATE_PROJECT = "create-project",
UPDATE_PROJECT = "update-project",
DELETE_PROJECT = "delete-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"
}

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.resetQueries({
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.resetQueries({
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

@@ -21,7 +21,9 @@ import {
TGetProjectSecretsKey,
TGetSecretAccessListDTO,
TGetSecretReferenceTreeDTO,
TSecretReferenceTraceNode
TGetSecretVersionValue,
TSecretReferenceTraceNode,
TSecretVersionValue
} from "./types";
export const secretKeys = {
@@ -34,6 +36,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,7 +71,10 @@ 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 = {
@@ -89,14 +96,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 +118,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
sec.idOverride = personalSecret.id;
sec.valueOverride = personalSecret.value;
sec.overrideAction = "modified";
sec.isEmpty = personalSecret.isEmpty;
}
});
@@ -238,7 +248,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 +269,32 @@ 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}`,
{
params: {
secretId,
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

@@ -10,6 +10,7 @@ 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";
@@ -27,12 +28,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 +68,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,12 +102,49 @@ 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,
@@ -95,7 +153,7 @@ export const SecretEditRow = ({
formState: { isDirty, isSubmitting }
} = useForm({
values: {
value: defaultValue || null
value: (secretValueData?.valueOverride ?? secretValueData?.value) || null
}
});
@@ -113,6 +171,25 @@ export const SecretEditRow = ({
};
const handleCopySecretToClipboard = async () => {
if (!isSecretValueFetched) {
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 +298,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 +324,11 @@ export const SecretEditRow = ({
isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined}
canEditButNotView={secretValueHidden && !isOverride}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => {
field.onBlur();
setIsFieldFocused.off();
}}
/>
)}
/>

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

@@ -183,17 +183,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 +260,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),
@@ -601,7 +589,9 @@ const Page = () => {
return secrets;
}
const mergedSecrets = [...(secrets || [])];
const mergedSecrets = [...(secrets || [])] as (SecretV3RawSanitized & {
originalKey?: string;
})[];
pendingChanges.secrets.forEach((change) => {
switch (change.type) {
@@ -652,7 +642,8 @@ const Page = () => {
updatedAt: new Date().toISOString(),
__v: 0
})) || []
: mergedSecrets[updateIndex].tags
: mergedSecrets[updateIndex].tags,
originalKey: mergedSecrets[updateIndex].key
};
}
break;

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;
@@ -317,18 +322,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,71 @@
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>
{/* <td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
</td> */}
</tr>
);
};

View File

@@ -1,28 +1,22 @@
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";
@@ -60,9 +54,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 +68,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 +76,7 @@ type Props = {
secretPath: string;
onToggle: (isOpen: boolean) => void;
onClose: () => void;
secret: SecretV3RawSanitized;
secret: SecretV3RawSanitized & { originalKey?: string };
onDeleteSecret: () => void;
onSaveSecret: (
orgSec: SecretV3RawSanitized,
@@ -92,7 +91,7 @@ type Props = {
export const SecretDetailSidebar = ({
isOpen,
onToggle,
secret,
secret: originalSecret,
onDeleteSecret,
onSaveSecret,
tags,
@@ -101,16 +100,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 +192,6 @@ export const SecretDetailSidebar = ({
"secretReferenceTree"
] as const);
const { permission } = useProjectPermission();
const { currentProject } = useProject();
const tagFields = useFieldArray({
control,
name: "tags"
@@ -139,7 +209,6 @@ export const SecretDetailSidebar = ({
{}
);
const selectTagSlugs = selectedTags.map((i) => i.slug);
const navigate = useNavigate();
const cannotEditSecret = permission.cannot(
ProjectPermissionSecretActions.Edit,
@@ -205,7 +274,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 +296,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 +355,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 +399,13 @@ export const SecretDetailSidebar = ({
>
<div className="flex items-start gap-x-2">
<InfisicalSecretInput
isReadOnly={isReadOnly || !isAllowed || secret?.isRotatedSecret}
isReadOnly={
isReadOnly ||
!isAllowed ||
secret?.isRotatedSecret ||
isLoadingSecretValue ||
isErrorFetchingSecretValue
}
environment={environment}
secretPath={secretPath}
key="secret-value"
@@ -364,6 +413,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 +432,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 +704,20 @@ 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
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">

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,41 @@ 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 && !isPending;
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,12 +178,43 @@ export const SecretItem = memo(
);
const getDefaultValue = () => {
if (isLoadingSecretValue) return HIDDEN_SECRET_VALUE;
if (secret.secretValueHidden && !isPending) {
return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
}
return secret.valueOverride || secret.value || "";
if (isErrorFetchingSecretValue) return "Error loading secret value...";
return secret.value || "";
};
const getOverrideDefaultValue = () => {
if (isLoadingSecretValue) return HIDDEN_SECRET_VALUE;
if (secret.secretValueHidden && !isPending) {
return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
}
if (isErrorFetchingSecretValue) return "Error loading secret value...";
return secret.valueOverride || "";
};
const formMethods = useForm<TFormSchema>({
defaultValues: {
...secret,
valueOverride: getOverrideDefaultValue(),
value: getDefaultValue()
},
values: {
...secret,
valueOverride: getOverrideDefaultValue(),
value: getDefaultValue()
},
resolver: zodResolver(formSchema)
});
const {
handleSubmit,
control,
@@ -155,17 +225,7 @@ export const SecretItem = memo(
getValues,
trigger,
formState: { isDirty, isSubmitting, errors }
} = useForm<TFormSchema>({
defaultValues: {
...secret,
value: getDefaultValue()
},
values: {
...secret,
value: getDefaultValue()
},
resolver: zodResolver(formSchema)
});
} = formMethods;
const secretName = watch("key");
const overrideAction = watch("overrideAction");
@@ -255,7 +315,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 +385,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();
};
@@ -428,6 +515,11 @@ export const SecretItem = memo(
isVisible={isVisible}
isReadOnly={isReadOnly}
{...field}
onFocus={() => setIsFieldFocused.on()}
onBlur={() => {
setIsFieldFocused.off();
field.onBlur();
}}
containerClassName="py-1.5 rounded-md transition-all"
/>
)}
@@ -446,6 +538,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
}
@@ -682,7 +781,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

@@ -616,19 +616,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,286 @@
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;
}
export const SecretVersionItem = ({
secretVersion: { createdAt, version, actor, secretValueHidden },
secret,
currentVersion,
onRevert
}: 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);
}
};
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;
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);
}}
onKeyDown={async (e) => {
if (secretValueHidden) return;
if (e.key === "Enter" || e.key === " ") {
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);
}
}}
>
<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 && (
<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>
);
};