feat: implemented replication to a folder strategy ui

This commit is contained in:
=
2024-05-30 20:40:51 +05:30
parent 5db6ac711c
commit a13d4a4970
7 changed files with 127 additions and 71 deletions

View File

@@ -1,3 +1,7 @@
export enum ReservedFolders {
SecretReplication = "__reserve_replication_"
}
export type TSecretFolder = { export type TSecretFolder = {
id: string; id: string;
name: string; name: string;

View File

@@ -10,6 +10,7 @@ export type TSecretImport = {
position: string; position: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isReserved?: boolean;
isReplication?: boolean; isReplication?: boolean;
isReplicationSuccess?: boolean; isReplicationSuccess?: boolean;
replicationStatus?: string; replicationStatus?: string;

View File

@@ -0,0 +1,9 @@
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
export const formatReservedPaths = (secretPath: string) => {
const i = secretPath.indexOf(ReservedFolders.SecretReplication);
if (i !== -1) {
return secretPath.slice(0, i);
}
return secretPath;
};

View File

@@ -20,6 +20,7 @@ import {
useUpdateSecretApprovalReviewStatus useUpdateSecretApprovalReviewStatus
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem"; import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
@@ -200,7 +201,9 @@ export const SecretApprovalRequestChanges = ({
<div className="border-r border-mineshaft-500 pr-1"> <div className="border-r border-mineshaft-500 pr-1">
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" /> <FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
</div> </div>
<div className="pl-2 pb-0.5 text-sm">{secretApprovalRequestDetails.secretPath}</div> <div className="pl-2 pb-0.5 text-sm">
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,7 +6,6 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
Checkbox,
FormControl, FormControl,
Modal, Modal,
ModalContent, ModalContent,
@@ -151,11 +150,25 @@ export const CreateSecretImportForm = ({
name="isReplication" name="isReplication"
control={control} control={control}
defaultValue={false} defaultValue={false}
render={({ field }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<Checkbox isChecked={field.value} onCheckedChange={field.onChange} id="isReplication"> <FormControl
Enable replication to synchronize changes. <br /> Warning: This will overwrite any isError={Boolean(error?.message)}
existing secrets with the same name. errorText={error?.message}
</Checkbox> helperText={
value
? "Manual control over when updates propagate in approval mode, giving you the flexibility to push changes as needed."
: "Instantaneous updates from the linked source on approval mode, ensuring real-time synchronization."
}
>
<Select
value={value ? "true" : "false"}
onValueChange={(val) => onChange(val === "true")}
className="w-full border border-mineshaft-500"
>
<SelectItem value="false">Linked Mode</SelectItem>
<SelectItem value="true">Replication Mode</SelectItem>
</Select>
</FormControl>
)} )}
/> />
<div className="mt-7 flex items-center"> <div className="mt-7 flex items-center">

View File

@@ -30,20 +30,17 @@ import {
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { useResyncSecretReplication } from "@app/hooks/api"; import { useResyncSecretReplication } from "@app/hooks/api";
import { TSecretImport } from "@app/hooks/api/types";
type Props = { type Props = {
onDelete: () => void; onDelete: () => void;
environment: string; environment: string;
secretPath?: string; secretPath?: string;
isReplication?: boolean; secretImport?: TSecretImport;
isReplicationSuccess?: boolean; isReplicationExpand?: boolean;
replicationStatus?: string;
lastReplicated?: string;
importEnvName: string;
importEnvPath: string;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[]; importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
searchTerm: string; searchTerm: string;
id: string; onExpandReplicateSecrets: (id: string) => void;
}; };
// to show the environment and folder icon // to show the environment and folder icon
@@ -70,25 +67,29 @@ export const EnvFolderIcon = ({
export const SecretImportItem = ({ export const SecretImportItem = ({
onDelete, onDelete,
id, isReplicationExpand,
importEnvName,
importEnvPath,
isReplication,
importedSecrets = [], importedSecrets = [],
searchTerm = "", searchTerm = "",
secretPath, secretPath,
environment, environment,
isReplicationSuccess, secretImport,
replicationStatus, onExpandReplicateSecrets: onExpandReplicate
lastReplicated
}: Props) => { }: Props) => {
const {
isReserved,
id,
isReplication,
isReplicationSuccess,
replicationStatus,
lastReplicated,
importEnv
} = secretImport as TSecretImport;
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const [isExpanded, setIsExpanded] = useToggle(); const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id id
}); });
const resyncSecretReplication = useResyncSecretReplication(); const resyncSecretReplication = useResyncSecretReplication();
useEffect(() => { useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) => const filteredSecrets = importedSecrets.filter((secret) =>
secret.key.toUpperCase().includes(searchTerm.toUpperCase()) secret.key.toUpperCase().includes(searchTerm.toUpperCase())
@@ -134,24 +135,39 @@ export const SecretImportItem = ({
} }
}; };
const handleRowClick = () => {
if (isReplication) {
onExpandReplicate(id);
} else {
setIsExpanded.toggle();
}
};
return ( return (
<> <>
<div <div
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700" className={twMerge(
"group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700",
isReserved && "hidden"
)}
role="button" role="button"
ref={setNodeRef} ref={setNodeRef}
tabIndex={0} tabIndex={0}
style={style} style={style}
onClick={() => setIsExpanded.toggle()} onClick={handleRowClick}
onKeyDown={() => setIsExpanded.toggle()} onKeyDown={(e) => {
if (e.key === "Enter") {
handleRowClick();
}
}}
> >
<div className="flex w-12 items-center px-4 py-2 text-green-700"> <div className="flex w-12 items-center px-4 py-2 text-green-700">
<FontAwesomeIcon icon={faFileImport} /> <FontAwesomeIcon icon={faFileImport} />
</div> </div>
<div className="flex flex-grow items-center px-4 py-2"> <div className="flex flex-grow items-center px-4 py-2">
<EnvFolderIcon <EnvFolderIcon
env={importEnvName || ""} env={importEnv.slug || ""}
secretPath={importEnvPath} secretPath={secretImport?.importPath || ""}
isReplication={isReplication} isReplication={isReplication}
/> />
</div> </div>
@@ -193,29 +209,31 @@ export const SecretImportItem = ({
</div> </div>
</Tooltip> </Tooltip>
)} )}
<ProjectPermissionCan {isReplication && (
I={ProjectPermissionActions.Edit} <ProjectPermissionCan
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })} I={ProjectPermissionActions.Edit}
renderTooltip a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
allowedLabel="Resync replicated secrets" renderTooltip
> allowedLabel="Resync replicated secrets"
{(isAllowed) => ( >
<IconButton {(isAllowed) => (
size="md" <IconButton
colorSchema="primary" size="md"
variant="plain" colorSchema="primary"
ariaLabel="expand" variant="plain"
className={twMerge( ariaLabel="expand"
"p-0 opacity-0 group-hover:opacity-100", className={twMerge(
resyncSecretReplication.isLoading && "animate-spin opacity-100" "p-0 opacity-0 group-hover:opacity-100",
)} resyncSecretReplication.isLoading && "animate-spin opacity-100"
isDisabled={!isAllowed} )}
onClick={handleResyncSecretReplication} isDisabled={!isAllowed}
> onClick={handleResyncSecretReplication}
<FontAwesomeIcon icon={faRotate} /> >
</IconButton> <FontAwesomeIcon icon={faRotate} />
)} </IconButton>
</ProjectPermissionCan> )}
</ProjectPermissionCan>
)}
</div> </div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<ProjectPermissionCan <ProjectPermissionCan
@@ -264,7 +282,7 @@ export const SecretImportItem = ({
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> </div>
{!isReplication && isExpanded && !isDragging && ( {!isReplication && (isReplicationExpand || isExpanded) && !isDragging && (
<td <td
colSpan={3} colSpan={3}
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`} className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}

View File

@@ -16,8 +16,10 @@ import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2"; import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api"; import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
import { ReservedFolders } from "@app/hooks/api/secretFolders/types";
import { TSecretImport } from "@app/hooks/api/secretImports/types"; import { TSecretImport } from "@app/hooks/api/secretImports/types";
import { DecryptedSecret, WorkspaceEnv } from "@app/hooks/api/types"; import { DecryptedSecret, WorkspaceEnv } from "@app/hooks/api/types";
import { formatReservedPaths } from "@app/lib/fn/string";
import { SecretImportItem } from "./SecretImportItem"; import { SecretImportItem } from "./SecretImportItem";
@@ -50,7 +52,7 @@ export const computeImportedSecretRows = (
importSecrets[i].secrets.forEach((el) => { importSecrets[i].secrets.forEach((el) => {
overridenSec[el.key] = { overridenSec[el.key] = {
env: importSecrets[i].environmentInfo.name, env: importSecrets[i].environmentInfo.name,
secretPath: importSecrets[i].secretPath secretPath: formatReservedPaths(importSecrets[i].secretPath)
}; };
}); });
} }
@@ -91,6 +93,8 @@ export const SecretImportListView = ({
"deleteSecretImport" "deleteSecretImport"
] as const); ] as const);
const [replicationSecrets, setReplicationSecrets] = useState<Record<string, boolean>>({});
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, {}), useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}), useSensor(TouchSensor, {}),
@@ -149,6 +153,22 @@ export const SecretImportListView = ({
} }
}; };
const handleOpenReplicationSecrets = (replicationImportId: string) => {
console.log(secretImports);
const reservedImport = secretImports.find(
({ isReserved, importPath, importEnv }) =>
importEnv.slug === environment &&
isReserved &&
importPath === `/${ReservedFolders.SecretReplication}${replicationImportId}`
);
if (reservedImport) {
setReplicationSecrets((state) => ({
...state,
[reservedImport.id]: !state?.[reservedImport.id]
}));
}
};
return ( return (
<> <>
<DndContext <DndContext
@@ -159,29 +179,17 @@ export const SecretImportListView = ({
> >
<SortableContext items={items} strategy={verticalListSortingStrategy}> <SortableContext items={items} strategy={verticalListSortingStrategy}>
{items?.map((item) => { {items?.map((item) => {
const { // TODO(akhilmhdh): change this and pass this whole object instead of one by one
importPath,
importEnv,
id,
isReplication,
replicationStatus,
lastReplicated,
isReplicationSuccess
} = item;
return ( return (
<SecretImportItem <SecretImportItem
searchTerm={searchTerm} searchTerm={searchTerm}
key={`imported-env-${id}`} key={`imported-env-${item.id}`}
id={id} isReplicationExpand={replicationSecrets?.[item.id]}
isReplication={isReplication} onExpandReplicateSecrets={handleOpenReplicationSecrets}
importEnvPath={importPath} secretImport={item}
importEnvName={importEnv.name}
lastReplicated={lastReplicated}
replicationStatus={replicationStatus}
isReplicationSuccess={isReplicationSuccess}
importedSecrets={computeImportedSecretRows( importedSecrets={computeImportedSecretRows(
importEnv.slug, item.importEnv.slug,
importPath, item.importPath,
importedSecrets, importedSecrets,
secrets secrets
)} )}