Merge remote-tracking branch 'origin/main' into feat/add-render-app-connection-and-secret-sync

This commit is contained in:
Sheen Capadngan
2025-06-19 03:14:23 +08:00
281 changed files with 6700 additions and 1520 deletions
@@ -7,6 +7,7 @@ import React, {
useMemo,
useState
} from "react";
import { FormProvider, useForm } from "react-hook-form";
import { ViewMode } from "../types";
@@ -23,8 +24,11 @@ interface AccessTreeProviderProps {
children: ReactNode;
}
export type AccessTreeForm = { metadata: { key: string; value: string }[] };
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
const [secretName, setSecretName] = useState("");
const formMethods = useForm<AccessTreeForm>({ defaultValues: { metadata: [] } });
const [viewMode, setViewMode] = useState(ViewMode.Docked);
const value = useMemo(
@@ -37,7 +41,11 @@ export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children
[secretName, setSecretName, viewMode, setViewMode]
);
return <AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>;
return (
<FormProvider {...formMethods}>
<AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>
</FormProvider>
);
};
export const useAccessTreeContext = (): AccessTreeContextProps => {
@@ -1,10 +1,12 @@
import { Dispatch, SetStateAction, useState } from "react";
import { useFormContext } from "react-hook-form";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Panel } from "@xyflow/react";
import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { MetadataForm } from "@app/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm";
import { ViewMode } from "../types";
@@ -32,6 +34,7 @@ export const PermissionSimulation = ({
setSecretName
}: TProps) => {
const [expand, setExpand] = useState(false);
const { control } = useFormContext();
const handlePermissionSimulation = () => {
setExpand(true);
@@ -139,6 +142,11 @@ export const PermissionSimulation = ({
/>
</div>
)}
{subject === ProjectPermissionSub.DynamicSecrets && (
<div>
<MetadataForm control={control} />
</div>
)}
</>
)}
</div>
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { MongoAbility, MongoQuery } from "@casl/ability";
import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react";
@@ -7,7 +8,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { useAccessTreeContext } from "../components";
import { AccessTreeForm, useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types";
import {
createBaseEdge,
@@ -36,6 +37,8 @@ export const useAccessTree = (
) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const { control } = useFormContext<AccessTreeForm>();
const metadata = useWatch({ control, name: "metadata" });
const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]);
const [subject, setSubject] = useState(ProjectPermissionSub.Secrets);
@@ -168,7 +171,8 @@ export const useAccessTree = (
environment,
subject,
secretName,
actionRuleMap
actionRuleMap,
metadata
})
);
@@ -266,7 +270,8 @@ export const useAccessTree = (
subject,
secretName,
setNodes,
setEdges
setEdges,
metadata
]);
return {
@@ -17,6 +17,27 @@ type Props = {
access: PermissionAccess;
} & Pick<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">;
type ConditionDisplayProps = {
_key: string;
operator: string;
value: string | string[];
};
const ConditionDisplay = ({ _key: key, value, operator }: ConditionDisplayProps) => {
return (
<li>
<span className="font-medium capitalize text-mineshaft-100">{camelCaseToSpaces(key)}</span>{" "}
<span className="text-mineshaft-200">
{formatedConditionsOperatorNames[operator as PermissionConditionOperators]}
</span>{" "}
<span className="rounded bg-mineshaft-600 p-0.5 font-mono">
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
);
};
export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => {
let component: ReactElement;
@@ -56,43 +77,58 @@ export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subjec
{actionRuleMap.map((ruleMap, index) => {
const rule = ruleMap[action];
if (
!rule ||
!rule.conditions ||
(!rule.conditions.secretName && !rule.conditions.secretTags)
)
return null;
if (!rule || !rule.conditions) return null;
return (
<li key={`${action}_${index + 1}`}>
<span className={`italic ${rule.inverted ? "text-red" : "text-green"} `}>
{rule.inverted ? "Forbids" : "Allows"}
</span>
<span> when:</span>
{Object.entries(rule.conditions).map(([key, condition]) => (
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
{Object.entries(condition as object).map(([operator, value]) => (
<li key={`${action}_${index + 1}_${key}_${operator}`}>
<span className="font-medium capitalize text-mineshaft-100">
{camelCaseToSpaces(key)}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
if (
rule.conditions.secretName ||
rule.conditions.secretTags ||
rule.conditions.metadata
) {
return (
<li key={`${action}_${index + 1}`}>
<span className="italic">{rule.inverted ? "Forbids" : "Allows"}</span>
<span> when:</span>
{Object.entries(rule.conditions).map(([key, condition]) => {
if (key.match(/secretPath|environment/)) {
return null;
}
return (
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
{Object.entries(condition as object).map(([operator, value]) => {
if (operator === "$elemMatch") {
return Object.entries(value as object).map(
([nestedKey, nestedCondition]) =>
Object.entries(nestedCondition as object).map(
([nestedOperator, nestedValue]) => (
<ConditionDisplay
_key={`${key} ${nestedKey}`}
operator={nestedOperator}
value={nestedValue}
key={`${action}_${index + 1}_${key}_${operator}_${nestedKey}_${nestedOperator}`}
/>
)
)
);
}
</span>{" "}
<span className={rule.inverted ? "text-red" : "text-green"}>
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
))}
</ul>
))}
</li>
);
return (
<ConditionDisplay
_key={key}
operator={operator}
value={value}
key={`${action}_${index + 1}_${key}_${operator}`}
/>
);
})}
</ul>
);
})}
</li>
);
}
return null;
})}
</ul>
</>
@@ -1,5 +1,5 @@
import { Dispatch, SetStateAction } from "react";
import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons";
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react";
@@ -12,15 +12,15 @@ import { createRoleNode } from "../utils";
const getSubjectIcon = (subject: ProjectPermissionSub) => {
switch (subject) {
case ProjectPermissionSub.Secrets:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-bunker-300" />;
case ProjectPermissionSub.SecretFolders:
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.DynamicSecrets:
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faFingerprint} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretImports:
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-green-700" />;
default:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />;
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-bunker-300" />;
}
};
@@ -33,6 +33,12 @@ const ACTION_MAP: Record<string, string[] | undefined> = {
]
};
const SUBJECT_HEIGHT_MAP: Record<string, number> = {
[ProjectPermissionSub.DynamicSecrets]: 130,
[ProjectPermissionSub.Secrets]: 85,
default: 64
};
const evaluateCondition = (
value: string,
operator: PermissionConditionOperators,
@@ -52,13 +58,113 @@ const evaluateCondition = (
}
};
const doesConditionMatch = (
conditions: Record<string, any> | undefined,
value: string
): boolean => {
if (!conditions) return true;
return Object.entries(conditions).every(([operator, comparisonValue]) =>
evaluateCondition(value, operator as PermissionConditionOperators, comparisonValue)
);
};
const doBaseConditionsApply = (
ruleConditions: any,
environment: string,
folderPath: string
): boolean => {
return (
doesConditionMatch(ruleConditions?.environment, environment) &&
doesConditionMatch(ruleConditions?.secretPath, folderPath)
);
};
const shouldShowConditionalAccess = (
actionRuleMap: TActionRuleMap,
action: string,
environment: string,
folderPath: string,
conditionalFields: string[]
): boolean => {
return actionRuleMap.some((rule) => {
const ruleConditions = rule[action]?.conditions;
if (!ruleConditions) return false;
// Check if any of the conditional fields are present
const hasConditionalField = conditionalFields.some((field) => ruleConditions[field]);
if (!hasConditionalField) return false;
// Check if base conditions (environment and secretPath) apply
return doBaseConditionsApply(ruleConditions, environment, folderPath);
});
};
const determineAccessLevel = (
hasPermission: boolean,
subject: ProjectPermissionSub,
action: string,
actionRuleMap: TActionRuleMap,
environment: string,
folderPath: string,
secretName: string,
metadata: Array<{ key: string; value: string }>
): PermissionAccess => {
if (!hasPermission) {
return PermissionAccess.None;
}
if (subject === ProjectPermissionSub.Secrets) {
if (
!secretName &&
shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, [
"secretName",
"secretTags"
])
) {
return PermissionAccess.Partial;
}
} else if (subject === ProjectPermissionSub.DynamicSecrets) {
if (
!metadata.length &&
shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, ["metadata"])
) {
return PermissionAccess.Partial;
}
}
return PermissionAccess.Full;
};
const checkPermission = (
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>,
subject: ProjectPermissionSub,
action: string,
subjectFields: any
): boolean => {
if (
subject === ProjectPermissionSub.Secrets &&
(action === ProjectPermissionSecretActions.ReadValue ||
action === ProjectPermissionSecretActions.DescribeSecret)
) {
return hasSecretReadValueOrDescribePermission(permissions, action, subjectFields);
}
return permissions.can(
// @ts-expect-error we are not specifying which so can't resolve if valid
action,
abilitySubject(subject, subjectFields)
);
};
export const createFolderNode = ({
folder,
permissions,
environment,
subject,
secretName,
actionRuleMap
actionRuleMap,
metadata
}: {
folder: TSecretFolderWithPath;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
@@ -66,6 +172,7 @@ export const createFolderNode = ({
subject: ProjectPermissionSub;
secretName: string;
actionRuleMap: TActionRuleMap;
metadata: Array<{ key: string; value: string }>;
}) => {
const actions = Object.fromEntries(
Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => {
@@ -73,74 +180,26 @@ export const createFolderNode = ({
// wrapped in try because while editing certain conditions, if their values are empty it throws an error
try {
let hasPermission: boolean;
const subjectFields = {
secretPath: folder.path,
environment,
secretName: secretName || "*",
secretTags: ["*"]
secretTags: ["*"],
metadata: metadata.length ? metadata : ["*"]
};
if (
subject === ProjectPermissionSub.Secrets &&
(action === ProjectPermissionSecretActions.ReadValue ||
action === ProjectPermissionSecretActions.DescribeSecret)
) {
hasPermission = hasSecretReadValueOrDescribePermission(
permissions,
action,
subjectFields
);
} else {
hasPermission = permissions.can(
// @ts-expect-error we are not specifying which so can't resolve if valid
action,
abilitySubject(subject, subjectFields)
);
}
const hasPermission = checkPermission(permissions, subject, action, subjectFields);
if (hasPermission) {
// we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access
if (
!secretName &&
actionRuleMap.some((el) => {
// we only show conditional if secretName/secretTags are present - environment and path can be directly determined
if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags)
return false;
// make sure condition applies to env
if (el[action]?.conditions?.environment) {
if (
!Object.entries(el[action]?.conditions?.environment).every(([operator, value]) =>
evaluateCondition(environment, operator as PermissionConditionOperators, value)
)
) {
return false;
}
}
// and applies to path
if (el[action]?.conditions?.secretPath) {
if (
!Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) =>
evaluateCondition(folder.path, operator as PermissionConditionOperators, value)
)
) {
return false;
}
}
return true;
})
) {
access = PermissionAccess.Partial;
} else {
access = PermissionAccess.Full;
}
} else {
access = PermissionAccess.None;
}
access = determineAccessLevel(
hasPermission,
subject,
action,
actionRuleMap,
environment,
folder.path,
secretName,
metadata
);
} catch (e) {
console.error(e);
access = PermissionAccess.None;
@@ -150,18 +209,7 @@ export const createFolderNode = ({
})
);
let height: number;
switch (subject) {
case ProjectPermissionSub.DynamicSecrets:
height = 130;
break;
case ProjectPermissionSub.Secrets:
height = 85;
break;
default:
height = 64;
}
const height = SUBJECT_HEIGHT_MAP[subject] ?? SUBJECT_HEIGHT_MAP.default;
return {
type: PermissionNode.Folder,
@@ -0,0 +1,51 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { TFlyioApp, useFlyioConnectionListApps } from "@app/hooks/api/appConnections/flyio";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const FlyioSyncFields = () => {
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.Flyio }
>();
const connectionId = useWatch({ name: "connection.id", control });
const { data: apps, isLoading: isAppsLoading } = useFlyioConnectionListApps(connectionId, {
enabled: Boolean(connectionId)
});
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.appId", "");
}}
/>
<Controller
name="destinationConfig.appId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="App">
<FilterableSelect
menuPlacement="top"
isLoading={isAppsLoading && Boolean(connectionId)}
isDisabled={!connectionId}
value={apps?.find((v) => v.id === value) ?? null}
onChange={(option) => onChange((option as SingleValue<TFlyioApp>)?.id ?? null)}
options={apps}
placeholder="Select an app..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
</>
);
};
@@ -11,6 +11,7 @@ import { AzureDevOpsSyncFields } from "./AzureDevOpsSyncFields";
import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields";
import { CamundaSyncFields } from "./CamundaSyncFields";
import { DatabricksSyncFields } from "./DatabricksSyncFields";
import { FlyioSyncFields } from "./FlyioSyncFields";
import { GcpSyncFields } from "./GcpSyncFields";
import { GitHubSyncFields } from "./GitHubSyncFields";
import { HCVaultSyncFields } from "./HCVaultSyncFields";
@@ -64,6 +65,8 @@ export const SecretSyncDestinationFields = () => {
return <OnePassSyncFields />;
case SecretSync.Render:
return <RenderSyncFields />;
case SecretSync.Flyio:
return <FlyioSyncFields />;
default:
throw new Error(`Unhandled Destination Config Field: ${destination}`);
}
@@ -6,6 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2";
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
import {
TVercelConnectionApp,
useVercelConnectionListOrganizations
@@ -174,17 +175,36 @@ export const VercelSyncFields = () => {
errorText={error?.message}
label="Vercel Preview Branch (Optional)"
>
<FilterableSelect
menuPlacement="top"
<CreatableSelect
className="w-full"
placeholder="Select a branch..."
isLoading={isProjectsLoading && Boolean(connectionId) && Boolean(currentApp)}
isDisabled={!connectionId || !currentApp}
options={previewBranchOptions}
menuPlacement="top"
value={previewBranchOptions.find((branch) => branch.id === value) ?? null}
onChange={(option) => onChange((option as SingleValue<{ id: string }>)?.id || "")}
options={previewBranchOptions}
placeholder="Select a branch..."
onCreateOption={(option) => {
onChange(option);
if (!option || option.trim() === "") return;
previewBranchOptions.push({ id: option, name: option });
}}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getNewOptionData={(inputValue, _optionLabel) => {
return {
id: inputValue,
name: `${inputValue} - press Enter`
};
}}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option?.id || ""}
isClearable
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isValidNewOption={(inputValue, _value, _options, _accessors) => {
return (
inputValue.trim().length > 0 &&
previewBranchOptions.filter((branch) => branch.id === inputValue).length === 0
);
}}
/>
</FormControl>
)}
@@ -53,6 +53,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.OnePass:
case SecretSync.OCIVault:
case SecretSync.Render:
case SecretSync.Flyio:
AdditionalSyncOptionsFieldsComponent = null;
break;
default:
@@ -0,0 +1,12 @@
import { useFormContext } from "react-hook-form";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { GenericFieldLabel } from "@app/components/v2";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const FlyioSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.Flyio }>();
const appId = watch("destinationConfig.appId");
return <GenericFieldLabel label="App ID">{appId}</GenericFieldLabel>;
};
@@ -20,6 +20,7 @@ import { AzureDevOpsSyncReviewFields } from "./AzureDevOpsSyncReviewFields";
import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields";
import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields";
import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields";
@@ -108,6 +109,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.Render:
DestinationFieldsComponent = <RenderSyncReviewFields />;
break;
case SecretSync.Flyio:
DestinationFieldsComponent = <FlyioSyncReviewFields />;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
@@ -1,17 +1,45 @@
import { useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { subject } from "@casl/ability";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { useWorkspace } from "@app/context";
import { useProjectPermission, useWorkspace } from "@app/context";
import {
ProjectPermissionSecretSyncActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { TSecretSyncForm } from "./schemas";
export const SecretSyncSourceFields = () => {
const { control, watch } = useFormContext<TSecretSyncForm>();
const { control, watch, setError, clearErrors } = useFormContext<TSecretSyncForm>();
const { permission } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const selectedEnvironment = watch("environment");
const selectedSecretPath = watch("secretPath");
useEffect(() => {
const hasAccessToSource =
selectedEnvironment &&
permission.can(
ProjectPermissionSecretSyncActions.Create,
subject(ProjectPermissionSub.SecretSyncs, {
environment: selectedEnvironment.slug,
secretPath: selectedSecretPath
})
);
if (!hasAccessToSource) {
setError("secretPath", {
message: "You do not have permission to create secret syncs in this environment or path."
});
} else {
clearErrors("secretPath");
}
}, [selectedEnvironment, selectedSecretPath]);
return (
<>
@@ -0,0 +1,13 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const FlyioSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.Flyio),
destinationConfig: z.object({
appId: z.string().trim().min(1, "App ID required")
})
})
);
@@ -8,6 +8,7 @@ import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destinatio
import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema";
import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema";
import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema";
import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema";
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema";
import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema";
@@ -37,7 +38,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
TeamCitySyncDestinationSchema,
OCIVaultSyncDestinationSchema,
OnePassSyncDestinationSchema,
RenderSyncDestinationSchema
RenderSyncDestinationSchema,
FlyioSyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema;
@@ -70,7 +70,15 @@ export const Pagination = ({
key={`pagination-per-page-options-${perPageOption}`}
icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />}
iconPos="right"
onClick={() => onChangePerPage(perPageOption)}
onClick={() => {
const totalPages = Math.ceil(count / perPageOption);
if (page > totalPages) {
onChangePage(totalPages);
}
onChangePerPage(perPageOption);
}}
>
{perPageOption} rows per page
</DropdownMenuItem>
@@ -48,6 +48,7 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?
type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
value?: string | null;
isVisible?: boolean;
valueAlwaysHidden?: boolean;
isImport?: boolean;
isReadOnly?: boolean;
isDisabled?: boolean;
@@ -63,6 +64,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
value,
isVisible,
isImport,
valueAlwaysHidden,
containerClassName,
onBlur,
isDisabled,
@@ -84,7 +86,11 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
<pre aria-hidden className="m-0">
<code className={`inline-block w-full ${commonClassName}`}>
<span style={{ whiteSpace: "break-spaces" }}>
{syntaxHighlight(value, isVisible || isSecretFocused, isImport)}
{syntaxHighlight(
value,
isVisible || (isSecretFocused && !valueAlwaysHidden),
isImport
)}
</span>
</code>
</pre>
@@ -137,7 +137,7 @@ export const SecretPathInput = ({
maxHeight: "var(--radix-select-content-available-height)"
}}
>
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
<div className="thin-scrollbar max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
{suggestions.map((suggestion, i) => (
<div
tabIndex={0}
@@ -163,7 +163,7 @@ export type IdentityManagementSubjectFields = {
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
[PermissionConditionOperators.$EQ]: "equal to",
[PermissionConditionOperators.$IN]: "contains",
[PermissionConditionOperators.$IN]: "in",
[PermissionConditionOperators.$ALL]: "contains all",
[PermissionConditionOperators.$NEQ]: "not equal to",
[PermissionConditionOperators.$GLOB]: "matches glob pattern",
@@ -263,6 +263,11 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
export type SecretSyncSubjectFields = {
environment: string;
secretPath: string;
};
export type SecretRotationSubjectFields = {
environment: string;
secretPath: string;
@@ -303,6 +308,13 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.DynamicSecrets> & DynamicSecretSubjectFields)
)
]
| [
ProjectPermissionSecretSyncActions,
(
| ProjectPermissionSub.SecretSyncs
| (ForcedSubject<ProjectPermissionSub.SecretSyncs> & SecretSyncSubjectFields)
)
]
| [
ProjectPermissionActions,
(
@@ -365,7 +377,6 @@ export type ProjectPermissionSet =
]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
+4 -1
View File
@@ -19,6 +19,7 @@ import {
AzureKeyVaultConnectionMethod,
CamundaConnectionMethod,
DatabricksConnectionMethod,
FlyioConnectionMethod,
GcpConnectionMethod,
GitHubConnectionMethod,
GitHubRadarConnectionMethod,
@@ -80,7 +81,8 @@ export const APP_CONNECTION_MAP: Record<
[AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" },
[AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true },
[AppConnection.OnePass]: { name: "1Password", image: "1Password.png" },
[AppConnection.Render]: { name: "Render", image: "Render.png" }
[AppConnection.Render]: { name: "Render", image: "Render.png" },
[AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }
};
export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => {
@@ -119,6 +121,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case TeamCityConnectionMethod.AccessToken:
case AzureDevOpsConnectionMethod.AccessToken:
case WindmillConnectionMethod.AccessToken:
case FlyioConnectionMethod.AccessToken:
return { name: "Access Token", icon: faKey };
case Auth0ConnectionMethod.ClientCredentials:
return { name: "Client Credentials", icon: faServer };
+6 -1
View File
@@ -65,6 +65,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
[SecretSync.Render]: {
name: "Render",
image: "Render.png"
},
[SecretSync.Flyio]: {
name: "Fly.io",
image: "Flyio.svg"
}
};
@@ -86,7 +90,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.TeamCity]: AppConnection.TeamCity,
[SecretSync.OCIVault]: AppConnection.OCI,
[SecretSync.OnePass]: AppConnection.OnePass,
[SecretSync.Render]: AppConnection.Render
[SecretSync.Render]: AppConnection.Render,
[SecretSync.Flyio]: AppConnection.Flyio
};
export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record<
@@ -23,5 +23,6 @@ export enum AppConnection {
TeamCity = "teamcity",
OCI = "oci",
OnePass = "1password",
Render = "render"
Render = "render",
Flyio = "flyio"
}
@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";
@@ -0,0 +1,36 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries";
import { TFlyioApp } from "./types";
const flyioConnectionKeys = {
all: [...appConnectionKeys.all, "flyio"] as const,
listApps: (connectionId: string) => [...flyioConnectionKeys.all, "apps", connectionId] as const
};
export const useFlyioConnectionListApps = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TFlyioApp[],
unknown,
TFlyioApp[],
ReturnType<typeof flyioConnectionKeys.listApps>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: flyioConnectionKeys.listApps(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TFlyioApp[]>(
`/api/v1/app-connections/flyio/${connectionId}/apps`
);
return data;
},
...options
});
};
@@ -0,0 +1,4 @@
export type TFlyioApp = {
id: string;
name: string;
};
@@ -114,6 +114,10 @@ export type TRenderConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Render;
};
export type TFlyioConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Flyio;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -137,7 +141,8 @@ export type TAppConnectionOption =
| TTeamCityConnectionOption
| TOCIConnectionOption
| TOnePassConnectionOption
| TRenderConnectionOption;
| TRenderConnectionOption
| TFlyioConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -165,4 +170,5 @@ export type TAppConnectionOptionMap = {
[AppConnection.OCI]: TOCIConnectionOption;
[AppConnection.OnePass]: TOnePassConnectionOption;
[AppConnection.Render]: TRenderConnectionOption;
[AppConnection.Flyio]: TFlyioConnectionOption;
};
@@ -0,0 +1,13 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum FlyioConnectionMethod {
AccessToken = "access-token"
}
export type TFlyioConnection = TRootAppConnection & { app: AppConnection.Flyio } & {
method: FlyioConnectionMethod.AccessToken;
credentials: {
accessToken: string;
};
};
@@ -9,6 +9,7 @@ import { TAzureDevOpsConnection } from "./azure-devops-connection";
import { TAzureKeyVaultConnection } from "./azure-key-vault-connection";
import { TCamundaConnection } from "./camunda-connection";
import { TDatabricksConnection } from "./databricks-connection";
import { TFlyioConnection } from "./flyio-connection";
import { TGcpConnection } from "./gcp-connection";
import { TGitHubConnection } from "./github-connection";
import { TGitHubRadarConnection } from "./github-radar-connection";
@@ -35,6 +36,7 @@ export * from "./azure-devops-connection";
export * from "./azure-key-vault-connection";
export * from "./camunda-connection";
export * from "./databricks-connection";
export * from "./flyio-connection";
export * from "./gcp-connection";
export * from "./github-connection";
export * from "./github-radar-connection";
@@ -77,7 +79,8 @@ export type TAppConnection =
| TTeamCityConnection
| TOCIConnection
| TOnePassConnection
| TRenderConnection;
| TRenderConnection
| TFlyioConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id">;
@@ -130,4 +133,5 @@ export type TAppConnectionMap = {
[AppConnection.OCI]: TOCIConnection;
[AppConnection.OnePass]: TOnePassConnection;
[AppConnection.Render]: TRenderConnection;
[AppConnection.Flyio]: TFlyioConnection;
};
@@ -242,6 +242,7 @@ interface CreateIdentityEvent {
metadata: {
identityId: string;
name: string;
hasDeleteProtection: boolean;
};
}
@@ -250,6 +251,7 @@ interface UpdateIdentityEvent {
metadata: {
identityId: string;
name?: string;
hasDeleteProtection?: boolean;
};
}
@@ -85,12 +85,13 @@ export const useCreateIdentity = () => {
export const useUpdateIdentity = () => {
const queryClient = useQueryClient();
return useMutation<Identity, object, UpdateIdentityDTO>({
mutationFn: async ({ identityId, name, role, metadata }) => {
mutationFn: async ({ identityId, name, role, hasDeleteProtection, metadata }) => {
const {
data: { identity }
} = await apiRequest.patch(`/api/v1/identities/${identityId}`, {
name,
role,
hasDeleteProtection,
metadata
});
@@ -14,6 +14,7 @@ export type IdentityTrustedIp = {
export type Identity = {
id: string;
name: string;
hasDeleteProtection: boolean;
authMethods: IdentityAuthMethod[];
createdAt: string;
updatedAt: string;
@@ -83,6 +84,7 @@ export type CreateIdentityDTO = {
name: string;
organizationId: string;
role?: string;
hasDeleteProtection: boolean;
metadata?: { key: string; value: string }[];
};
@@ -90,6 +92,7 @@ export type UpdateIdentityDTO = {
identityId: string;
name?: string;
role?: string;
hasDeleteProtection?: boolean;
organizationId: string;
metadata?: { key: string; value: string }[];
};
@@ -32,6 +32,7 @@ export type TSecretApprovalSecChange = {
version: number;
secretKey: string;
secretValue?: string;
secretValueHidden?: boolean;
secretComment?: string;
isRotatedSecret?: boolean;
tags?: string[];
+2 -1
View File
@@ -16,7 +16,8 @@ export enum SecretSync {
TeamCity = "teamcity",
OCIVault = "oci-vault",
OnePass = "1password",
Render = "render"
Render = "render",
Flyio = "flyio"
}
export enum SecretSyncStatus {
@@ -0,0 +1,15 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TFlyioSync = TRootSecretSync & {
destination: SecretSync.Flyio;
destinationConfig: {
appId: string;
};
connection: {
app: AppConnection.Flyio;
name: string;
id: string;
};
};
@@ -10,6 +10,7 @@ import { TAzureDevOpsSync } from "./azure-devops-sync";
import { TAzureKeyVaultSync } from "./azure-key-vault-sync";
import { TCamundaSync } from "./camunda-sync";
import { TDatabricksSync } from "./databricks-sync";
import { TFlyioSync } from "./flyio-sync";
import { TGcpSync } from "./gcp-sync";
import { TGitHubSync } from "./github-sync";
import { THCVaultSync } from "./hc-vault-sync";
@@ -45,7 +46,8 @@ export type TSecretSync =
| TTeamCitySync
| TOCIVaultSync
| TOnePassSync
| TRenderSync;
| TRenderSync
| TFlyioSync;
export type TListSecretSyncs = { secretSyncs: TSecretSync[] };
@@ -15,7 +15,8 @@ import {
IconButton,
Input,
Modal,
ModalContent
ModalContent,
Switch
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
@@ -27,6 +28,7 @@ const schema = z
.object({
name: z.string().min(1, "Required"),
role: z.object({ slug: z.string(), name: z.string() }),
hasDeleteProtection: z.boolean(),
metadata: z
.object({
key: z.string().trim().min(1),
@@ -64,7 +66,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: ""
name: "",
hasDeleteProtection: false
}
});
@@ -78,6 +81,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
identityId: string;
name: string;
role: string;
hasDeleteProtection: boolean;
metadata?: { key: string; value: string }[];
customRole: {
name: string;
@@ -91,22 +95,25 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset({
name: identity.name,
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
hasDeleteProtection: identity.hasDeleteProtection,
metadata: identity.metadata
});
} else {
reset({
name: "",
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole),
hasDeleteProtection: false
});
}
}, [popUp?.identity?.data, roles]);
const onFormSubmit = async ({ name, role, metadata }: FormData) => {
const onFormSubmit = async ({ name, role, metadata, hasDeleteProtection }: FormData) => {
try {
const identity = popUp?.identity?.data as {
identityId: string;
name: string;
role: string;
hasDeleteProtection: boolean;
};
if (identity) {
@@ -116,6 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
identityId: identity.identityId,
name,
role: role.slug || undefined,
hasDeleteProtection,
organizationId: orgId,
metadata
});
@@ -127,6 +135,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { id: createdId } = await createMutateAsync({
name,
role: role.slug || undefined,
hasDeleteProtection,
organizationId: orgId,
metadata
});
@@ -215,6 +224,24 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
name="hasDeleteProtection"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Switch
className="ml-0 mr-2 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
containerClassName="flex-row-reverse w-fit"
id="delete-protection-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>Delete Protection {value ? "Enabled" : "Disabled"}</p>
</Switch>
</FormControl>
)}
/>
<div>
<FormLabel label="Metadata" />
</div>
@@ -329,7 +329,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent align="start" className="mt-3 p-1">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
@@ -18,6 +18,7 @@ import { AzureDevOpsConnectionForm } from "./AzureDevOpsConnectionForm";
import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm";
import { CamundaConnectionForm } from "./CamundaConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { FlyioConnectionForm } from "./FlyioConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { GitHubRadarConnectionForm } from "./GitHubRadarConnectionForm";
@@ -122,6 +123,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <OnePassConnectionForm onSubmit={onSubmit} />;
case AppConnection.Render:
return <RenderConnectionForm onSubmit={onSubmit} />;
case AppConnection.Flyio:
return <FlyioConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -208,6 +211,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <OnePassConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Render:
return <RenderConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Flyio:
return <FlyioConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,136 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { FlyioConnectionMethod, TFlyioConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TFlyioConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.Flyio)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(FlyioConnectionMethod.AccessToken),
credentials: z.object({
accessToken: z
.string()
.trim()
.min(1, "Access Token required")
.startsWith("FlyV1", "Token must start with 'FlyV1'")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const FlyioConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.Flyio,
method: FlyioConnectionMethod.AccessToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.Flyio].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(FlyioConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.accessToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Fly.io"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -47,13 +47,19 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
<DropdownMenuTrigger asChild>
<Button
size="xs"
rightIcon={<FontAwesomeIcon className="ml-1" icon={faChevronDown} />}
rightIcon={
<FontAwesomeIcon
className="ml-1 transition-transform duration-200 group-data-[state=open]:rotate-180"
icon={faChevronDown}
/>
}
colorSchema="secondary"
className="group select-none"
>
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[120px]" align="end">
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
@@ -68,6 +74,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
handlePopUpOpen("identity", {
identityId,
name: data.identity.name,
hasDeleteProtection: data.identity.hasDeleteProtection,
role: data.role,
customRole: data.customRole
});
@@ -131,6 +138,12 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen }: Props) =
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.identity.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Delete Protection</p>
<p className="text-sm text-mineshaft-300">
{data.identity.hasDeleteProtection ? "On" : "Off"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
<p className="text-sm text-mineshaft-300">{data.role}</p>
@@ -35,7 +35,7 @@ export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit }: Props
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[120px]" align="end">
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
<OrgPermissionCan
I={OrgPermissionIdentityActions.Edit}
a={OrgPermissionSubjects.Identity}
@@ -72,7 +72,7 @@ export const GroupsSection = () => {
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}
@@ -2,10 +2,11 @@ import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
faUsers,
faUsersSlash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -13,6 +14,10 @@ import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
@@ -184,33 +189,42 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUsersSlash} />}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
>
Remove Group From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
@@ -3,12 +3,13 @@ import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faBookOpen,
faCircleXmark,
faClock,
faEllipsisV,
faMagnifyingGlass,
faPlus,
faServer,
faXmark
faServer
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -21,6 +22,10 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
HoverCard,
HoverCardContent,
@@ -163,20 +168,21 @@ export const IdentityTab = withProjectPermission(
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/identities/overview"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</span>
</div>
</a>
</div>
<ProjectPermissionCan
@@ -185,7 +191,7 @@ export const IdentityTab = withProjectPermission(
>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
@@ -231,7 +237,7 @@ export const IdentityTab = withProjectPermission(
</Th>
<Th className="w-1/3">Role</Th>
<Th>Added on</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
<Th className="w-5">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
@@ -372,37 +378,46 @@ export const IdentityTab = withProjectPermission(
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end space-x-2 opacity-0 duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<IconButton
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
<Td className="flex justify-end space-x-2">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCircleXmark} />}
isDisabled={!isAllowed}
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
>
Remove Identity From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
@@ -56,12 +56,12 @@ export const MembersSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addMember")}
@@ -9,8 +9,8 @@ import {
faFilter,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
faUsers,
faUserXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
@@ -416,32 +416,40 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
</Td>
<Td>
{userId !== u?.id && (
<div className="flex items-center space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
colorSchema="danger"
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
handlePopUpOpen("removeMember", { username: u.username });
}}
>
<FontAwesomeIcon icon={faTrash} />
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserXmark} />}
isDisabled={!isAllowed}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
handlePopUpOpen("removeMember", { username: u.username });
}}
>
Remove User From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
)}
</Td>
</Tr>
@@ -10,10 +10,10 @@ export const ProjectRoleListTab = withProjectPermission(
return (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleList />
</motion.div>
@@ -1,4 +1,16 @@
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faCopy,
faEdit,
faEllipsisV,
faEye,
faMagnifyingGlass,
faPlus,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -12,6 +24,10 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -19,15 +35,27 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole, TProjectRole } from "@app/hooks/api/roles/types";
import { DuplicateProjectRoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/DuplicateProjectRoleModal";
import { RoleModal } from "@app/pages/project/RoleDetailsBySlugPage/components/RoleModal";
enum RolesOrderBy {
Name = "name",
Slug = "slug"
}
export const ProjectRoleList = () => {
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -57,6 +85,75 @@ export const ProjectRoleList = () => {
}
};
const {
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy,
search,
setSearch,
page,
perPage,
setPerPage,
setPage,
offset
} = usePagination<RolesOrderBy>(RolesOrderBy.Name, {
initPerPage: getUserTablePreference("projectRolesTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectRolesTable", PreferenceKey.PerPage, newPerPage);
};
const filteredRoles = useMemo(
() =>
roles
?.filter((role) => {
const { slug, name } = role;
const searchValue = search.trim().toLowerCase();
return (
name.toLowerCase().includes(searchValue) || slug.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [roleOne, roleTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case RolesOrderBy.Slug:
return roleOne.slug.toLowerCase().localeCompare(roleTwo.slug.toLowerCase());
case RolesOrderBy.Name:
default:
return roleOne.name.toLowerCase().localeCompare(roleTwo.name.toLowerCase());
}
}) ?? [],
[roles, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredRoles.length,
offset,
setPage
});
const handleSort = (column: RolesOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: RolesOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: RolesOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
@@ -64,7 +161,7 @@ export const ProjectRoleList = () => {
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
{(isAllowed) => (
<Button
colorSchema="primary"
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("role")}
@@ -75,18 +172,50 @@ export const ProjectRoleList = () => {
)}
</ProjectPermissionCan>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search project roles..."
className="flex-1"
containerClassName="mb-4"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Slug
<IconButton
variant="plain"
className={getClassName(RolesOrderBy.Slug)}
ariaLabel="sort"
onClick={() => handleSort(RolesOrderBy.Slug)}
>
<FontAwesomeIcon icon={getColSortIcon(RolesOrderBy.Slug)} />
</IconButton>
</div>
</Th>
<Th aria-label="actions" className="w-5" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
{isRolesLoading && <TableSkeleton columns={3} innerKey="project-roles" />}
{filteredRoles?.slice(offset, perPage * page).map((role) => {
const { id, name, slug } = role;
const isNonMutatable = Object.values(ProjectMembershipRole).includes(
slug as ProjectMembershipRole
@@ -109,88 +238,118 @@ export const ProjectRoleList = () => {
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
params: {
projectId: currentWorkspace.id,
roleSlug: slug
}
});
}}
disabled={!isAllowed}
>
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("duplicateRole", role);
}}
disabled={!isAllowed}
>
Duplicate Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{!isNonMutatable && (
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isNonMutatable ? faEye : faEdit} />}
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
navigate({
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
params: {
projectId: currentWorkspace.id,
roleSlug: slug
}
});
}}
disabled={!isAllowed}
>
Delete Role
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCopy} />}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("duplicateRole", role);
}}
disabled={!isAllowed}
>
Duplicate Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{!isNonMutatable && (
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faTrash} />}
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50",
"transition-colors duration-100"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
}}
disabled={!isAllowed}
>
Delete Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredRoles?.length) && (
<Pagination
count={filteredRoles!.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!filteredRoles?.length && !isRolesLoading && (
<EmptyState
title={
roles?.length
? "No project roles match search..."
: "This project does not have any roles"
}
icon={roles?.length ? faSearch : undefined}
/>
)}
</TableContainer>
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
@@ -67,13 +67,12 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const ServiceTokenForm = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
@@ -152,13 +151,197 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
}
};
return !hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
readValue: false,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl label="Permissions" errorText={error?.message} isError={Boolean(error)}>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
);
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
return (
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}}
>
<ModalContent
@@ -169,194 +352,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
readValue: false,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
<ServiceTokenForm />
</ModalContent>
</Modal>
);
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
@@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission(
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<div className="mb-2 flex items-center justify-between">
<div>
<div className="flex items-center gap-1">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<a
href="https://infisical.com/docs/documentation/platform/token"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
{t("section.token.service-tokens-description")}
</p>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens}
@@ -63,12 +84,11 @@ export const ServiceTokenSection = withProjectPermission(
}}
isDisabled={!isAllowed}
>
Create token
Create Token
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
@@ -1,10 +1,28 @@
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faEllipsisV,
faFolder,
faKey,
faMagnifyingGlass,
faSearch,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -12,10 +30,18 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -31,78 +57,230 @@ type Props = {
) => void;
};
enum TokensOrderBy {
Name = "name",
Expiration = "expiration"
}
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?.id || ""
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<TokensOrderBy>(TokensOrderBy.Name, {
initPerPage: getUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, newPerPage);
};
const filteredTokens = useMemo(
() =>
data
?.filter((token) => {
const { name, scopes } = token;
const searchValue = search.trim().toLowerCase();
if (name.toLowerCase().includes(searchValue)) {
return true;
}
return scopes.some(
({ environment, secretPath }) =>
environment.toLowerCase().includes(searchValue) ||
secretPath.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case TokensOrderBy.Expiration:
if (!tokenOne.expiresAt && !tokenTwo.expiresAt) return 0;
if (!tokenOne.expiresAt) return 1;
if (!tokenTwo.expiresAt) return -1;
return (
new Date(tokenOne.expiresAt).getTime() - new Date(tokenTwo.expiresAt).getTime()
);
case TokensOrderBy.Name:
default:
return tokenOne.name.toLowerCase().localeCompare(tokenTwo.name.toLowerCase());
}
}) ?? [],
[data, orderDirection, search, orderBy]
);
useResetPageHelper({
totalCount: filteredTokens.length,
offset,
setPage
});
const handleSort = (column: TokensOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: TokensOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: TokensOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment - Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isPending &&
data &&
data.map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="mb-2 flex flex-col flex-wrap space-y-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
>
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
<FontAwesomeIcon icon={faFolder} size="sm" />
<span className="pl-2">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{!isPending && data && data?.length === 0 && (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service tokens by name, environment or secret path..."
className="flex-1"
containerClassName="mb-4 mt-2"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(TokensOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(TokensOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>Environment / Secret Path</Th>
<Th>
<div className="flex items-center">
Valid Until
<IconButton
variant="plain"
className={getClassName(TokensOrderBy.Expiration)}
ariaLabel="sort"
onClick={() => handleSort(TokensOrderBy.Expiration)}
>
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Expiration)} />
</IconButton>
</div>
</Th>
<Th className="w-5" aria-label="button" />
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isPending &&
filteredTokens.slice(offset, perPage * page).map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="flex flex-row flex-wrap gap-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-500 p-1 px-2"
>
<div className="mr-1 border-r border-mineshaft-500 pr-2">
{environment}
</div>
<FontAwesomeIcon icon={faFolder} size="sm" className="text-yellow" />
<span className="pl-1">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>
{row.expiresAt ? (
format(row.expiresAt, "MM/dd/yyyy h:mm:ss aa")
) : (
<span className="text-mineshaft-400">N/A</span>
)}
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faTrash} />}
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
});
}}
>
Delete Token
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
))}
</TBody>
</Table>
{Boolean(filteredTokens.length) && (
<Pagination
count={filteredTokens.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredTokens?.length && (
<EmptyState
title={data?.length ? "No service tokens match search..." : "No service tokens found"}
icon={data?.length ? faSearch : faKey}
/>
)}
</TableContainer>
</div>
);
};
@@ -291,6 +291,13 @@ export const projectRoleFormSchema = z.object({
})
.array()
.default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.Commits]: CommitPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Member]: MemberPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Groups]: GroupPolicyActionSchema.array().default([]),
@@ -342,7 +349,6 @@ export const projectRoleFormSchema = z.object({
.default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretSyncs]: SecretSyncPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kmip]: KmipPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretScanningDataSources]:
SecretScanningDataSourcePolicyActionSchema.array().default([]),
@@ -366,7 +372,8 @@ type TConditionalFields =
| ProjectPermissionSub.CertificateTemplates
| ProjectPermissionSub.SshHosts
| ProjectPermissionSub.SecretRotation
| ProjectPermissionSub.Identity;
| ProjectPermissionSub.Identity
| ProjectPermissionSub.SecretSyncs;
export const isConditionalSubjects = (
subject: ProjectPermissionSub
@@ -379,7 +386,8 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SshHosts ||
subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers ||
subject === ProjectPermissionSub.CertificateTemplates;
subject === ProjectPermissionSub.CertificateTemplates ||
subject === ProjectPermissionSub.SecretSyncs;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = [];
@@ -484,7 +492,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshHostGroups
ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.SecretSyncs
].includes(subject)
) {
// from above statement we are sure it won't be undefined
@@ -515,6 +524,36 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
const canRead = action.includes(ProjectPermissionSecretSyncActions.Read);
const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit);
const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete);
const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create);
const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets);
const canImportSecrets = action.includes(
ProjectPermissionSecretSyncActions.ImportSecrets
);
const canRemoveSecrets = action.includes(
ProjectPermissionSecretSyncActions.RemoveSecrets
);
if (!formVal[subject]) formVal[subject] = [{ conditions: [], inverted: false }];
// from above statement we are sure it won't be undefined
formVal[subject]!.push({
[ProjectPermissionSecretSyncActions.Read]: canRead,
[ProjectPermissionSecretSyncActions.Create]: canCreate,
[ProjectPermissionSecretSyncActions.Edit]: canEdit,
[ProjectPermissionSecretSyncActions.Delete]: canDelete,
[ProjectPermissionSecretSyncActions.SyncSecrets]: canSyncSecrets,
[ProjectPermissionSecretSyncActions.ImportSecrets]: canImportSecrets,
[ProjectPermissionSecretSyncActions.RemoveSecrets]: canRemoveSecrets,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
}
if (subject === ProjectPermissionSub.DynamicSecrets) {
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
@@ -777,31 +816,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
return;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
const canRead = action.includes(ProjectPermissionSecretSyncActions.Read);
const canEdit = action.includes(ProjectPermissionSecretSyncActions.Edit);
const canDelete = action.includes(ProjectPermissionSecretSyncActions.Delete);
const canCreate = action.includes(ProjectPermissionSecretSyncActions.Create);
const canSyncSecrets = action.includes(ProjectPermissionSecretSyncActions.SyncSecrets);
const canImportSecrets = action.includes(ProjectPermissionSecretSyncActions.ImportSecrets);
const canRemoveSecrets = action.includes(ProjectPermissionSecretSyncActions.RemoveSecrets);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject]![0][ProjectPermissionSecretSyncActions.Read] = true;
if (canEdit) formVal[subject]![0][ProjectPermissionSecretSyncActions.Edit] = true;
if (canCreate) formVal[subject]![0][ProjectPermissionSecretSyncActions.Create] = true;
if (canDelete) formVal[subject]![0][ProjectPermissionSecretSyncActions.Delete] = true;
if (canSyncSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.SyncSecrets] = true;
if (canImportSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.ImportSecrets] = true;
if (canRemoveSecrets)
formVal[subject]![0][ProjectPermissionSecretSyncActions.RemoveSecrets] = true;
return;
}
if (subject === ProjectPermissionSub.SecretScanningDataSources) {
const canRead = action.includes(ProjectPermissionSecretScanningDataSourceActions.Read);
const canEdit = action.includes(ProjectPermissionSecretScanningDataSourceActions.Edit);
@@ -35,6 +35,7 @@ import {
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SecretPermissionConditions } from "./SecretPermissionConditions";
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
type Props = {
@@ -69,6 +70,10 @@ export const renderConditionalComponents = (
return <PkiTemplatePermissionConditions isDisabled={isDisabled} />;
}
if (subject === ProjectPermissionSub.SecretSyncs) {
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
}
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
}
@@ -0,0 +1,186 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const SecretSyncPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
setValue,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions`
});
const conditionErrorMessage =
errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.message ||
errors?.permissions?.[ProjectPermissionSub.SecretSyncs]?.[position]?.conditions?.root?.message;
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition = watch(
`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}`
) as {
lhs: string;
rhs: string;
operator: string;
};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue(
`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.SecretSyncs}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="remove"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{conditionErrorMessage && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{conditionErrorMessage}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,14 @@
import { TFlyioSync } from "@app/hooks/api/secretSyncs/types/flyio-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TFlyioSync;
};
export const FlyioSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -8,6 +8,7 @@ import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol";
import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol";
import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol";
@@ -61,6 +62,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <AzureDevOpsSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Render:
return <RenderSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Flyio:
return <FlyioSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { subject } from "@casl/ability";
import {
faBan,
faCalendarCheck,
@@ -117,6 +118,14 @@ export const SecretSyncRow = ({
const destinationDetails = SECRET_SYNC_MAP[destination];
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<Tr
onClick={() =>
@@ -264,7 +273,7 @@ export const SecretSyncRow = ({
</DropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.SyncSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -295,7 +304,7 @@ export const SecretSyncRow = ({
{syncOption?.canImportSecrets && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.ImportSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -326,7 +335,7 @@ export const SecretSyncRow = ({
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.RemoveSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -356,7 +365,7 @@ export const SecretSyncRow = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -373,7 +382,7 @@ export const SecretSyncRow = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Delete}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -120,6 +120,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId;
secondaryText = "Service";
break;
case SecretSync.Flyio:
primaryText = destinationConfig.appId;
secondaryText = "App ID";
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -96,6 +96,10 @@ import { SecretOverviewSecretRotationRow } from "@app/pages/secret-manager/Overv
import { CreateDynamicSecretForm } from "../SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm";
import { FolderForm } from "../SecretDashboardPage/components/ActionBar/FolderForm";
import {
HIDDEN_SECRET_VALUE,
HIDDEN_SECRET_VALUE_API_MASK
} from "../SecretDashboardPage/components/SecretListView/SecretItem";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
@@ -509,15 +513,25 @@ export const OverviewPage = () => {
env: string,
key: string,
value: string,
secretValueHidden: boolean,
type = SecretType.Shared
) => {
let secretValue: string | undefined = value;
if (
secretValueHidden &&
(value === HIDDEN_SECRET_VALUE_API_MASK || value === HIDDEN_SECRET_VALUE)
) {
secretValue = undefined;
}
try {
const result = await updateSecretV3({
environment: env,
workspaceId,
secretPath,
secretKey: key,
secretValue: value,
secretValue,
type
});
@@ -50,6 +50,7 @@ type Props = {
env: string,
key: string,
value: string,
secretValueHidden: boolean,
type?: SecretType,
secretId?: string
) => Promise<void>;
@@ -147,6 +148,7 @@ export const SecretEditRow = ({
environment,
secretName,
value,
secretValueHidden,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
@@ -166,6 +168,7 @@ export const SecretEditRow = ({
environment,
secretName,
secretValue,
secretValueHidden,
isOverride ? SecretType.Personal : SecretType.Shared,
secretId
);
@@ -257,7 +260,7 @@ export const SecretEditRow = ({
>
{(isAllowed) => (
<div>
<Tooltip content="save">
<Tooltip content="Save">
<IconButton
variant="plain"
ariaLabel="submit-value"
@@ -24,6 +24,7 @@ import { useToggle } from "@app/hooks";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { getExpandedRowStyle } from "@app/pages/secret-manager/OverviewPage/components/utils";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
import { SecretEditRow } from "./SecretEditRow";
import SecretRenameRow from "./SecretRenameRow";
@@ -40,6 +41,7 @@ type Props = {
env: string,
key: string,
value: string,
secretValueHidden: boolean,
type?: SecretType,
secretId?: string
) => Promise<void>;
@@ -96,7 +98,7 @@ export const SecretOverviewTableRow = ({
);
if (secret?.secretValueHidden && !secret?.valueOverride) {
return canEditSecretValue ? "******" : "";
return canEditSecretValue ? HIDDEN_SECRET_VALUE : "";
}
return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || "";
};
@@ -11,6 +11,7 @@ import {
faKey
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { SecretInput, Tag, Tooltip } from "@app/components/v2";
import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
@@ -105,21 +106,41 @@ export const SecretApprovalRequestChangeItem = ({
</span>
) : (
<div className="relative">
{secretVersion?.secretValueHidden && (
<div className="absolute left-1 top-1/2 z-50 -translate-y-1/2">
<Tooltip
position="right"
content="You do not have access to view the old secret value."
>
<FontAwesomeIcon
className="pl-2 text-mineshaft-300"
size="sm"
icon={faEyeSlash}
/>
</Tooltip>
</div>
)}
<SecretInput
isReadOnly
isVisible={isOldSecretValueVisible}
valueAlwaysHidden={secretVersion?.secretValueHidden}
value={secretVersion?.secretValue}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
containerClassName={twMerge(
"border border-mineshaft-600 bg-bunker-700 py-1.5 text-bunker-300 hover:border-primary-400/50",
secretVersion?.secretValueHidden ? "pl-8 pr-2" : "px-2"
)}
/>
<div
className="absolute right-1 top-1"
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
>
<FontAwesomeIcon
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
{!secretVersion?.secretValueHidden && (
<div
className="absolute right-1 top-1"
onClick={() => setIsOldSecretValueVisible(!isOldSecretValueVisible)}
>
<FontAwesomeIcon
icon={isOldSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
)}
</div>
)}
</div>
@@ -211,21 +232,41 @@ export const SecretApprovalRequestChangeItem = ({
</span>
) : (
<div className="relative">
{newVersion?.secretValueHidden && (
<div className="absolute left-1 top-1/2 z-50 -translate-y-1/2">
<Tooltip
position="right"
content="You do not have access to view the new secret value."
>
<FontAwesomeIcon
className="pl-2 text-mineshaft-300"
size="sm"
icon={faEyeSlash}
/>
</Tooltip>
</div>
)}
<SecretInput
isReadOnly
valueAlwaysHidden={newVersion?.secretValueHidden}
isVisible={isNewSecretValueVisible}
value={newVersion?.secretValue}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
containerClassName={twMerge(
"border border-mineshaft-600 bg-bunker-700 py-1.5 text-bunker-300 hover:border-primary-400/50",
newVersion?.secretValueHidden ? "pl-8 pr-2" : "px-2"
)}
/>
<div
className="absolute right-1 top-1"
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
>
<FontAwesomeIcon
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
{!newVersion?.secretValueHidden && (
<div
className="absolute right-1 top-1"
onClick={() => setIsNewSecretValueVisible(!isNewSecretValueVisible)}
>
<FontAwesomeIcon
icon={isNewSecretValueVisible ? faEyeSlash : faEye}
className="cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-800 p-1.5 text-mineshaft-300 hover:bg-mineshaft-700"
/>
</div>
)}
</div>
)}
</div>
@@ -62,6 +62,7 @@ import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { camelCaseToSpaces } from "@app/lib/fn/string";
import { CreateReminderForm } from "./CreateReminderForm";
import { HIDDEN_SECRET_VALUE } from "./SecretItem";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
type Props = {
@@ -897,7 +898,9 @@ export const SecretDetailSidebar = ({
</button>
</div>
<span className="group-[.show-value]:hidden">
{secretValueHidden ? "******" : secretValue?.replace(/./g, "*")}
{secretValueHidden
? HIDDEN_SECRET_VALUE
: secretValue?.replace(/./g, "*")}
<button
type="button"
className="ml-1 cursor-pointer"
@@ -59,6 +59,7 @@ import {
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
export const HIDDEN_SECRET_VALUE = "******";
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
type Props = {
secret: SecretV3RawSanitized;
@@ -20,7 +20,7 @@ import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/c
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { SecretDetailSidebar } from "./SecretDetailSidebar";
import { SecretItem } from "./SecretItem";
import { HIDDEN_SECRET_VALUE, HIDDEN_SECRET_VALUE_API_MASK, SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
type Props = {
@@ -85,6 +85,7 @@ export const SecretListView = ({
type: SecretType,
key: string,
{
secretValueHidden,
value,
comment,
reminderRepeatDays,
@@ -97,6 +98,7 @@ export const SecretListView = ({
secretMetadata,
isRotatedSecret
}: Partial<{
secretValueHidden: boolean;
value: string;
comment: string;
reminderRepeatDays: number | null;
@@ -123,6 +125,15 @@ export const SecretListView = ({
}
if (operation === "update") {
let secretValue = value;
if (
secretValueHidden &&
(value === HIDDEN_SECRET_VALUE_API_MASK || value === HIDDEN_SECRET_VALUE)
) {
secretValue = undefined;
}
await updateSecretV3({
environment,
workspaceId,
@@ -130,7 +141,7 @@ export const SecretListView = ({
secretKey: key,
...(!isRotatedSecret && {
newSecretName: newKey,
secretValue: value || ""
secretValue: secretValueHidden ? secretValue : secretValue || ""
}),
type,
tagIds: tags,
@@ -168,7 +179,7 @@ export const SecretListView = ({
},
cb?: () => void
) => {
const { key: oldKey } = orgSecret;
const { key: oldKey, secretValueHidden } = orgSecret;
const {
key,
value,
@@ -246,7 +257,8 @@ export const SecretListView = ({
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding,
secretMetadata,
isRotatedSecret: orgSecret.isRotatedSecret
isRotatedSecret: orgSecret.isRotatedSecret,
secretValueHidden
});
if (cb) cb();
}
@@ -1,4 +1,5 @@
import { useCallback } from "react";
import { subject } from "@casl/ability";
import {
faBan,
faCheck,
@@ -63,7 +64,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
const triggerSyncSecrets = useTriggerSecretSyncSyncSecrets();
const updateSync = useUpdateSecretSync();
const { destination } = secretSync;
const { destination, environment, folder } = secretSync;
const destinationName = SECRET_SYNC_MAP[destination].name;
const { syncOption } = useSecretSyncOption(destination);
@@ -128,6 +129,14 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
}
};
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<>
<div className="ml-auto mt-4 flex flex-wrap items-center justify-end gap-2">
@@ -157,7 +166,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
<div>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.SyncSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<Button
@@ -194,7 +203,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
{syncOption?.canImportSecrets && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.ImportSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -222,7 +231,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.RemoveSecrets}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -249,7 +258,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -267,7 +276,7 @@ export const SecretSyncActionTriggers = ({ secretSync }: Props) => {
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Delete}
a={ProjectPermissionSub.SecretSyncs}
a={permissionSubject}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
@@ -0,0 +1,14 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TFlyioSync } from "@app/hooks/api/secretSyncs/types/flyio-sync";
type Props = {
secretSync: TFlyioSync;
};
export const FlyioSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { appId }
} = secretSync;
return <GenericFieldLabel label="App ID">{appId}</GenericFieldLabel>;
};
@@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -18,6 +19,7 @@ import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationS
import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection";
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection";
import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection";
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection";
import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection";
@@ -35,7 +37,7 @@ type Props = {
};
export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: Props) => {
const { destination, connection } = secretSync;
const { destination, connection, folder, environment } = secretSync;
const app = APP_CONNECTION_MAP[connection.app].name;
@@ -97,18 +99,26 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Render:
DestinationComponents = <RenderSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Flyio:
DestinationComponents = <FlyioSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Destination Configuration</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
@@ -16,7 +17,8 @@ type Props = {
};
export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) => {
const { syncStatus, lastSyncMessage, lastSyncedAt, name, description } = secretSync;
const { syncStatus, lastSyncMessage, lastSyncedAt, name, description, environment, folder } =
secretSync;
const failureMessage = useMemo(() => {
if (syncStatus === SecretSyncStatus.Failed) {
@@ -32,14 +34,19 @@ export const SecretSyncDetailsSection = ({ secretSync, onEditDetails }: Props) =
return null;
}, [syncStatus, lastSyncMessage]);
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Details</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
@@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { subject } from "@casl/ability";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -21,7 +22,9 @@ type Props = {
export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) => {
const {
destination,
syncOptions: { initialSyncBehavior, disableSecretDeletion, keySchema }
syncOptions: { initialSyncBehavior, disableSecretDeletion, keySchema },
environment,
folder
} = secretSync;
let AdditionalSyncOptionsComponent: ReactNode;
@@ -53,21 +56,27 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.OCIVault:
case SecretSync.OnePass:
case SecretSync.Render:
case SecretSync.Flyio:
AdditionalSyncOptionsComponent = null;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Sync Options</h3>
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
@@ -1,3 +1,4 @@
import { subject } from "@casl/ability";
import { faEdit, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -16,6 +17,14 @@ type Props = {
export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) => {
const { folder, environment } = secretSync;
const permissionSubject =
environment && folder
? subject(ProjectPermissionSub.SecretSyncs, {
environment: environment.slug,
secretPath: folder.path
})
: ProjectPermissionSub.SecretSyncs;
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
@@ -35,10 +44,7 @@ export const SecretSyncSourceSection = ({ secretSync, onEditSource }: Props) =>
</div>
</Tooltip>
)}
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
<ProjectPermissionCan I={ProjectPermissionSecretSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"