Merge remote-tracking branch 'origin/main' into feat/pki-ENG-3666

This commit is contained in:
Carlos Monastyrski
2025-09-22 15:52:41 -03:00
128 changed files with 5873 additions and 2385 deletions
@@ -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) => {
@@ -731,6 +729,9 @@ export const OverviewPage = () => {
userAvailableEnvs.forEach((env) => {
secrets?.forEach((secret) => {
// bulk actions don't apply to rotation secrets (move/delete)
if (secret.isRotatedSecret) return;
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[EntryType.SECRET][secret.key];
} else {
@@ -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>
);
};
@@ -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>
@@ -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>
@@ -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}
@@ -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}
@@ -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"
@@ -570,6 +582,9 @@ const Page = () => {
const newChecks = { ...selectedSecrets };
secrets?.forEach((secret) => {
// bulk actions don't apply to rotation secrets (move/delete)
if (secret.isRotatedSecret) return;
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[secret.id];
} else {
@@ -601,7 +616,9 @@ const Page = () => {
return secrets;
}
const mergedSecrets = [...(secrets || [])];
const mergedSecrets = [...(secrets || [])] as (SecretV3RawSanitized & {
originalKey?: string;
})[];
pendingChanges.secrets.forEach((change) => {
switch (change.type) {
@@ -652,7 +669,8 @@ const Page = () => {
updatedAt: new Date().toISOString(),
__v: 0
})) || []
: mergedSecrets[updateIndex].tags
: mergedSecrets[updateIndex].tags,
originalKey: mergedSecrets[updateIndex].key
};
}
break;
@@ -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,
@@ -18,6 +18,7 @@ import { useCreateDynamicSecret } from "@app/hooks/api";
import { useGetServerConfig } from "@app/hooks/api/admin";
import {
DynamicSecretAwsIamAuth,
DynamicSecretAwsIamCredentialType,
DynamicSecretProviders
} from "@app/hooks/api/dynamicSecret/types";
import { ProjectEnv } from "@app/hooks/api/types";
@@ -28,6 +29,9 @@ const formSchema = z.object({
provider: z.discriminatedUnion("method", [
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AccessKey),
credentialType: z
.nativeEnum(DynamicSecretAwsIamCredentialType)
.default(DynamicSecretAwsIamCredentialType.IamUser),
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
@@ -47,6 +51,9 @@ const formSchema = z.object({
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
credentialType: z
.nativeEnum(DynamicSecretAwsIamCredentialType)
.default(DynamicSecretAwsIamCredentialType.IamUser),
roleArn: z.string().trim().min(1),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
@@ -65,6 +72,9 @@ const formSchema = z.object({
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.IRSA),
credentialType: z
.nativeEnum(DynamicSecretAwsIamCredentialType)
.default(DynamicSecretAwsIamCredentialType.IamUser),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
@@ -137,13 +147,15 @@ export const AwsIamInputForm = ({
environment: isSingleEnvironmentMode ? environments[0] : undefined,
usernameTemplate: "{{randomUsername}}",
provider: {
method: DynamicSecretAwsIamAuth.AssumeRole
method: DynamicSecretAwsIamAuth.AssumeRole,
credentialType: DynamicSecretAwsIamCredentialType.IamUser
}
}
});
const createDynamicSecret = useCreateDynamicSecret();
const method = watch("provider.method");
const credentialType = watch("provider.credentialType");
const handleCreateDynamicSecret = async ({
name,
@@ -264,6 +276,39 @@ export const AwsIamInputForm = ({
</FormControl>
)}
/>
<Controller
name="provider.credentialType"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Credential Type"
>
<>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
<SelectItem value={DynamicSecretAwsIamCredentialType.IamUser}>
IAM User
</SelectItem>
<SelectItem value={DynamicSecretAwsIamCredentialType.TemporaryCredentials}>
Temporary Credentials
</SelectItem>
</Select>
<div className="mt-1 text-xs text-mineshaft-300">
{value === DynamicSecretAwsIamCredentialType.IamUser
? "Creates temporary IAM users with access keys"
: "Uses STS to generate temporary credentials from your connection. Duration is controlled by the Default TTL setting above."}
</div>
</>
</FormControl>
)}
/>
{method === DynamicSecretAwsIamAuth.AccessKey && (
<div className="flex items-center space-x-2">
<Controller
@@ -318,22 +363,24 @@ export const AwsIamInputForm = ({
</div>
)}
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.awsPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<Controller
control={control}
name="provider.awsPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="provider.region"
@@ -341,7 +388,11 @@ export const AwsIamInputForm = ({
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="flex-grow"
className={
credentialType === DynamicSecretAwsIamCredentialType.TemporaryCredentials
? "w-full"
: "flex-grow"
}
isError={Boolean(error?.message)}
errorText={error?.message}
>
@@ -350,97 +401,105 @@ export const AwsIamInputForm = ({
)}
/>
</div>
<Controller
control={control}
name="provider.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyArns"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input
{...field}
placeholder="arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="usernameTemplate"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
placeholder="{{randomUsername}}"
/>
</FormControl>
)}
/>
<MetadataForm control={control} name="provider.tags" title="Tags" isValueRequired />
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<>
<Controller
control={control}
name="provider.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyArns"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input
{...field}
placeholder="arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</>
)}
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<Controller
control={control}
name="usernameTemplate"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
placeholder="{{randomUsername}}"
/>
</FormControl>
)}
/>
)}
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<MetadataForm control={control} name="provider.tags" title="Tags" isValueRequired />
)}
{!isSingleEnvironmentMode && (
<Controller
control={control}
@@ -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
}
]
}}
@@ -154,20 +154,22 @@ const renderOutputForm = (
}
if (provider === DynamicSecretProviders.AwsIam) {
const { USERNAME, ACCESS_KEY, SECRET_ACCESS_KEY } = data as {
const { USERNAME, ACCESS_KEY, SECRET_ACCESS_KEY, SESSION_TOKEN } = data as {
ACCESS_KEY: string;
SECRET_ACCESS_KEY: string;
USERNAME: string;
USERNAME?: string;
SESSION_TOKEN?: string;
};
return (
<div>
<OutputDisplay label="AWS Username" value={USERNAME} />
{USERNAME && <OutputDisplay label="AWS IAM Username" value={USERNAME} />}
<OutputDisplay label="AWS IAM Access Key" value={ACCESS_KEY} />
<OutputDisplay
label="AWS IAM Secret Key"
value={SECRET_ACCESS_KEY}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
<OutputDisplay label="AWS IAM Secret Key" value={SECRET_ACCESS_KEY} />
{SESSION_TOKEN && <OutputDisplay label="AWS IAM Session Token" value={SESSION_TOKEN} />}
<div className="mt-2 text-xs text-mineshaft-300">
Important: Copy these credentials now. You will not be able to see them again after you
close the modal.
</div>
</div>
);
}
@@ -7,7 +7,11 @@ import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Select, SelectItem, TextArea } from "@app/components/v2";
import { useGetServerConfig, useUpdateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretAwsIamAuth, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import {
DynamicSecretAwsIamAuth,
DynamicSecretAwsIamCredentialType,
TDynamicSecret
} from "@app/hooks/api/dynamicSecret/types";
import { slugSchema } from "@app/lib/schemas";
import { MetadataForm } from "../MetadataForm";
@@ -16,6 +20,9 @@ const formSchema = z.object({
inputs: z.discriminatedUnion("method", [
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AccessKey),
credentialType: z
.nativeEnum(DynamicSecretAwsIamCredentialType)
.default(DynamicSecretAwsIamCredentialType.IamUser),
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
@@ -30,6 +37,9 @@ const formSchema = z.object({
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.AssumeRole),
credentialType: z
.nativeEnum(DynamicSecretAwsIamCredentialType)
.default(DynamicSecretAwsIamCredentialType.IamUser),
roleArn: z.string().trim().min(1),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
@@ -43,6 +53,9 @@ const formSchema = z.object({
}),
z.object({
method: z.literal(DynamicSecretAwsIamAuth.IRSA),
credentialType: z
.nativeEnum(DynamicSecretAwsIamCredentialType)
.default(DynamicSecretAwsIamCredentialType.IamUser),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
@@ -78,6 +91,7 @@ const formSchema = z.object({
newName: slugSchema().optional(),
usernameTemplate: z.string().trim().nullable().optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
@@ -115,6 +129,7 @@ export const EditDynamicSecretAwsIamForm = ({
}
});
const method = watch("inputs.method");
const credentialType = watch("inputs.credentialType");
const updateDynamicSecret = useUpdateDynamicSecret();
@@ -235,6 +250,39 @@ export const EditDynamicSecretAwsIamForm = ({
</FormControl>
)}
/>
<Controller
name="inputs.credentialType"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Credential Type"
>
<>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
<SelectItem value={DynamicSecretAwsIamCredentialType.IamUser}>
IAM User
</SelectItem>
<SelectItem value={DynamicSecretAwsIamCredentialType.TemporaryCredentials}>
Temporary Credentials
</SelectItem>
</Select>
<div className="mt-1 text-xs text-mineshaft-300">
{value === DynamicSecretAwsIamCredentialType.IamUser
? "Creates temporary IAM users with access keys"
: "Uses STS to generate temporary credentials from your connection. Duration is controlled by the Default TTL setting above."}
</div>
</>
</FormControl>
)}
/>
{method === DynamicSecretAwsIamAuth.AccessKey && (
<div className="flex items-center space-x-2">
<Controller
@@ -289,21 +337,24 @@ export const EditDynamicSecretAwsIamForm = ({
</div>
)}
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.awsPath"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<Controller
control={control}
name="inputs.awsPath"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Path"
className="flex-grow"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="inputs.region"
@@ -311,7 +362,11 @@ export const EditDynamicSecretAwsIamForm = ({
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="flex-grow"
className={
credentialType === DynamicSecretAwsIamCredentialType.TemporaryCredentials
? "w-full"
: "flex-grow"
}
isError={Boolean(error?.message)}
errorText={error?.message}
>
@@ -320,93 +375,101 @@ export const EditDynamicSecretAwsIamForm = ({
)}
/>
</div>
<Controller
control={control}
name="inputs.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.policyArns"
defaultValue="datacenter1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="usernameTemplate"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<MetadataForm control={control} name="inputs.tags" title="Tags" isValueRequired />
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<>
<Controller
control={control}
name="inputs.userGroups"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Groups"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given groups."
>
<Input {...field} placeholder="group1,group2" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.permissionBoundaryPolicyArn"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM User Permission Boundary ARN"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="ARN to be attached to the generated user for AWS Permission Boundary."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.policyArns"
defaultValue="datacenter1"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Policy ARNs"
isError={Boolean(error?.message)}
isOptional
errorText={error?.message}
helperText="Generated users will get attached to given policy arns."
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.policyDocument"
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS IAM Policy Document"
isOptional
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Generated users will have the inline policy."
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</>
)}
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<Controller
control={control}
name="usernameTemplate"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
)}
{credentialType !== DynamicSecretAwsIamCredentialType.TemporaryCredentials && (
<MetadataForm control={control} name="inputs.tags" title="Tags" isValueRequired />
)}
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
@@ -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
@@ -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>
@@ -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;
}
@@ -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>
);
};
@@ -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"
@@ -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
@@ -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)}
@@ -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>
);
};
@@ -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>
@@ -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>
);
};