mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 11:29:42 +00:00
Merge remote-tracking branch 'origin/main' into feat/add-render-app-connection-and-secret-sync
This commit is contained in:
@@ -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 {
|
||||
|
||||
+71
-35
@@ -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,
|
||||
|
||||
+51
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+3
@@ -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}`);
|
||||
}
|
||||
|
||||
+25
-5
@@ -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>
|
||||
)}
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+12
@@ -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>;
|
||||
};
|
||||
+4
@@ -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]
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
|
||||
+31
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+5
@@ -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}`);
|
||||
}
|
||||
|
||||
+136
@@ -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>
|
||||
);
|
||||
};
|
||||
+15
-2
@@ -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>
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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()}
|
||||
|
||||
+43
-29
@@ -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>
|
||||
);
|
||||
|
||||
+57
-42
@@ -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>
|
||||
);
|
||||
|
||||
+2
-2
@@ -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")}
|
||||
|
||||
+32
-24
@@ -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>
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+227
-68
@@ -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
|
||||
|
||||
+188
-192
@@ -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>
|
||||
);
|
||||
|
||||
+25
-5
@@ -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
|
||||
|
||||
+243
-65
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+43
-29
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
+186
@@ -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>
|
||||
);
|
||||
};
|
||||
+14
@@ -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} />;
|
||||
};
|
||||
+3
@@ -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}`
|
||||
|
||||
+14
-5
@@ -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
|
||||
|
||||
+4
@@ -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
|
||||
});
|
||||
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
+3
-1
@@ -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 || "";
|
||||
};
|
||||
|
||||
+61
-20
@@ -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>
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+16
-4
@@ -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();
|
||||
}
|
||||
|
||||
+15
-6
@@ -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
|
||||
|
||||
+14
@@ -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>;
|
||||
};
|
||||
+15
-5
@@ -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"
|
||||
|
||||
+12
-5
@@ -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"
|
||||
|
||||
+14
-5
@@ -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"
|
||||
|
||||
+10
-4
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user