misc: migrated to use multiple app connections

This commit is contained in:
Sheen Capadngan
2025-10-16 00:55:43 +08:00
parent 79ff152a67
commit 661a8bcece
19 changed files with 748 additions and 279 deletions
+71 -29
View File
@@ -6,12 +6,7 @@ import { secretKeys } from "@app/hooks/api/secrets/queries";
import { projectKeys } from "../projects";
import { externalMigrationQueryKeys } from "./queries";
import {
ExternalMigrationProviders,
TExternalMigrationConfig,
TImportVaultSecretsDTO,
VaultImportStatus
} from "./types";
import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types";
export const useImportEnvKey = () => {
const queryClient = useQueryClient();
@@ -75,29 +70,6 @@ export const useImportVault = () => {
});
};
export const useUpdateExternalMigrationConfig = (platform: ExternalMigrationProviders) => {
const queryClient = useQueryClient();
return useMutation<TExternalMigrationConfig, Error, { connectionId: string | null }>({
mutationFn: async ({ connectionId }: { connectionId: string | null }) => {
const { data } = await apiRequest.put<{ config: TExternalMigrationConfig }>(
"/api/v3/external-migration/config",
{
connectionId,
platform
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.config(platform)
});
}
});
};
export const useImportVaultSecrets = () => {
const queryClient = useQueryClient();
@@ -121,3 +93,73 @@ export const useImportVaultSecrets = () => {
}
});
};
export const useCreateVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TVaultExternalMigrationConfig,
Error,
{ connectionId: string; namespace: string }
>({
mutationFn: async ({ connectionId, namespace }) => {
const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>(
"/api/v3/external-migration/vault/configs",
{
connectionId,
namespace
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
export const useUpdateVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TVaultExternalMigrationConfig,
Error,
{ id: string; connectionId: string; namespace: string }
>({
mutationFn: async ({ id, connectionId, namespace }) => {
const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>(
`/api/v3/external-migration/vault/configs/${id}`,
{
connectionId,
namespace
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
export const useDeleteVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<TVaultExternalMigrationConfig, Error, { id: string }>({
mutationFn: async ({ id }) => {
const { data } = await apiRequest.delete<{ config: TVaultExternalMigrationConfig }>(
`/api/v3/external-migration/vault/configs/${id}`
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
+8 -12
View File
@@ -4,7 +4,7 @@ import { apiRequest } from "@app/config/request";
import {
ExternalMigrationProviders,
TExternalMigrationConfig,
TVaultExternalMigrationConfig,
VaultKubernetesAuthRole
} from "./types";
@@ -13,7 +13,7 @@ export const externalMigrationQueryKeys = {
"custom-migration-available",
provider
],
config: (platform: string) => ["external-migration-config", { platform }],
vaultConfigs: () => ["vault-external-migration-configs"],
vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: (namespace?: string) => ["vault-policies", namespace],
vaultMounts: (namespace?: string) => ["vault-mounts", namespace],
@@ -31,19 +31,15 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid
});
};
export const useGetExternalMigrationConfig = (platform: string) => {
export const useGetVaultExternalMigrationConfigs = () => {
return useQuery({
queryKey: externalMigrationQueryKeys.config(platform),
queryKey: externalMigrationQueryKeys.vaultConfigs(),
queryFn: async () => {
const { data } = await apiRequest.get<{ config: TExternalMigrationConfig | null }>(
"/api/v3/external-migration/config",
{
params: { platform }
}
const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>(
"/api/v3/external-migration/vault/configs"
);
return data.config;
},
enabled: Boolean(platform)
return data.configs;
}
});
};
+2 -2
View File
@@ -8,10 +8,10 @@ export enum VaultImportStatus {
ApprovalRequired = "approval_required"
}
export type TExternalMigrationConfig = {
export type TVaultExternalMigrationConfig = {
id: string;
orgId: string;
platform: string;
namespace: string;
connectionId: string | null;
createdAt: string;
updatedAt: string;
@@ -37,11 +37,8 @@ import {
IdentityKubernetesAuthTokenReviewMode,
IdentityTrustedIp
} from "@app/hooks/api/identities/types";
import { useGetExternalMigrationConfig } from "@app/hooks/api/migration/queries";
import {
ExternalMigrationProviders,
VaultKubernetesAuthRole
} from "@app/hooks/api/migration/types";
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration/queries";
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
import { usePopUp, UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityFormTab } from "./types";
@@ -130,8 +127,8 @@ export const IdentityKubernetesAuthForm = ({
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
"importFromVault"
] as const);
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const {
control,
@@ -1,100 +1,188 @@
import { useMemo } from "react";
import { useState } from "react";
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
Button,
DeleteActionModal,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useGetExternalMigrationConfig,
useUpdateExternalMigrationConfig
useDeleteVaultExternalMigrationConfig,
useGetVaultExternalMigrationConfigs
} from "@app/hooks/api/migration";
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
import { VaultNamespaceConfigModal } from "./VaultNamespaceConfigModal";
export const VaultConnectionSection = () => {
const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections();
const [selectedConfig, setSelectedConfig] = useState<TVaultExternalMigrationConfig | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [configToDelete, setConfigToDelete] = useState<TVaultExternalMigrationConfig | null>(null);
const vaultConnections = useMemo(
() => appConnections.filter((conn) => conn.app === AppConnection.HCVault),
[appConnections]
);
const { data: configs = [], isPending: isLoadingConfigs } = useGetVaultExternalMigrationConfigs();
const { data: appConnections = [] } = useListAppConnections();
const { mutateAsync: deleteConfig } = useDeleteVaultExternalMigrationConfig();
const { data: currentConfig, isPending: isLoadingConfig } = useGetExternalMigrationConfig(
ExternalMigrationProviders.Vault
);
const handleEdit = (config: TVaultExternalMigrationConfig) => {
setSelectedConfig(config);
setIsModalOpen(true);
};
const { mutateAsync: updateConfig, isPending: isUpdating } = useUpdateExternalMigrationConfig(
ExternalMigrationProviders.Vault
);
const handleAdd = () => {
setSelectedConfig(null);
setIsModalOpen(true);
};
const handleDeleteClick = (config: TVaultExternalMigrationConfig) => {
setConfigToDelete(config);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!configToDelete) return;
const handleConnectionChange = async (
selectedConnection: { id: string; name: string } | null
) => {
try {
await updateConfig({
connectionId: selectedConnection?.id || null
});
await deleteConfig({ id: configToDelete.id });
createNotification({
type: "success",
text: "Vault connection updated successfully"
text: "Namespace configuration deleted successfully"
});
setIsDeleteModalOpen(false);
setConfigToDelete(null);
} catch (error) {
console.error("Failed to update vault connection:", error);
console.error("Failed to delete namespace config:", error);
createNotification({
type: "error",
text: "Failed to update vault connection"
text: "Failed to delete namespace configuration"
});
}
};
const selectedConnection = useMemo(() => {
if (!currentConfig?.connectionId) return null;
return vaultConnections.find((conn) => conn.id === currentConfig.connectionId) || null;
}, [currentConfig?.connectionId, vaultConnections]);
const isLoading = isLoadingConnections || isLoadingConfig;
const getConnectionName = (connectionId: string | null) => {
if (!connectionId) return "None";
const connection = appConnections.find((conn) => conn.id === connectionId);
return connection?.name || "Unknown";
};
return (
<div>
<div className="mb-4 flex items-center gap-3">
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault logo"
className="bg-bunker-500 h-10 w-10 rounded-md p-2"
/>
<div>
<h3 className="text-mineshaft-100 text-lg font-medium">HashiCorp Vault</h3>
<p className="text-sm text-gray-400">
Enable in-platform migration tooling for policy imports and secret engine migrations
</p>
</div>
</div>
<div className="max-w-md">
<FormControl
label="HashiCorp Vault Connection"
tooltipText="Select an existing App Connection to enable in-platform migration features. Manage connections in the App Connections section."
>
<FilterableSelect
value={selectedConnection}
onChange={(newValue) => {
handleConnectionChange(newValue as { id: string; name: string } | null);
}}
isLoading={isLoading}
isDisabled={isUpdating}
options={vaultConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
isClearable
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault logo"
className="bg-bunker-500 h-10 w-10 rounded-md p-2"
/>
</FormControl>
<div>
<h3 className="text-mineshaft-100 text-lg font-medium">HashiCorp Vault</h3>
<p className="text-sm text-gray-400">
Enable in-platform migration tooling for policy imports and secret engine migrations
</p>
</div>
</div>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleAdd}
>
Add Namespace
</Button>
</div>
<p className="text-mineshaft-400 mt-2 text-xs">
Select an existing App Connection to enable in-platform migration features. Manage
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Namespace</Th>
<Th>Connection</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoadingConfigs && (
<TableSkeleton columns={3} innerKey="vault-configs-loading" rows={3} />
)}
{!isLoadingConfigs && configs.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No namespace configurations" icon={faPlus} className="py-8">
<p className="text-mineshaft-400 mb-4 text-sm">
Add a namespace configuration to enable in-platform migration features.
</p>
</EmptyState>
</Td>
</Tr>
)}
{!isLoadingConfigs &&
configs.map((config) => (
<Tr key={config.id} className="group h-10">
<Td>{config.namespace}</Td>
<Td>{getConnectionName(config.connectionId)}</Td>
<Td>
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="plain"
colorSchema="secondary"
size="xs"
onClick={() => handleEdit(config)}
leftIcon={<FontAwesomeIcon icon={faEdit} />}
>
Edit
</Button>
<Button
variant="plain"
colorSchema="danger"
size="xs"
onClick={() => handleDeleteClick(config)}
leftIcon={<FontAwesomeIcon icon={faTrash} />}
>
Delete
</Button>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
<p className="text-mineshaft-400 mt-4 text-xs">
Configure namespace-specific connections to enable in-platform migration features. Manage
connections in the App Connections section.
</p>
<VaultNamespaceConfigModal
isOpen={isModalOpen}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setSelectedConfig(null);
}}
editConfig={selectedConfig || undefined}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete namespace configuration for "${configToDelete?.namespace}"?`}
onChange={(open) => {
setIsDeleteModalOpen(open);
if (!open) setConfigToDelete(null);
}}
deleteKey="confirm"
onDeleteApproved={handleDeleteConfirm}
/>
</div>
);
};
@@ -0,0 +1,187 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useCreateVaultExternalMigrationConfig,
useUpdateVaultExternalMigrationConfig
} from "@app/hooks/api/migration";
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
const schema = z.object({
namespace: z.string().min(1, "Namespace is required"),
connectionId: z.string().min(1, "Connection is required")
});
type FormData = z.infer<typeof schema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
editConfig?: TVaultExternalMigrationConfig;
};
export const VaultNamespaceConfigModal = ({ isOpen, onOpenChange, editConfig }: Props) => {
const isEdit = Boolean(editConfig);
const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections();
const vaultConnections = useMemo(
() => appConnections.filter((conn) => conn.app === AppConnection.HCVault),
[appConnections]
);
const { mutateAsync: createConfig, isPending: isCreating } =
useCreateVaultExternalMigrationConfig();
const { mutateAsync: updateConfig, isPending: isUpdating } =
useUpdateVaultExternalMigrationConfig();
const {
control,
handleSubmit,
reset,
formState: { errors, isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
namespace: "",
connectionId: ""
}
});
// Reset form when editConfig changes or modal opens
useEffect(() => {
if (isOpen) {
reset({
namespace: editConfig?.namespace || "",
connectionId: editConfig?.connectionId || ""
});
}
}, [isOpen, editConfig, reset]);
const onFormSubmit = async (data: FormData) => {
try {
if (isEdit && editConfig) {
await updateConfig({
id: editConfig.id,
namespace: data.namespace,
connectionId: data.connectionId
});
createNotification({
type: "success",
text: "Namespace configuration updated successfully"
});
} else {
await createConfig({
namespace: data.namespace,
connectionId: data.connectionId
});
createNotification({
type: "success",
text: "Namespace configuration created successfully"
});
}
reset();
onOpenChange(false);
} catch (error) {
console.error("Failed to save namespace config:", error);
createNotification({
type: "error",
text: `Failed to ${isEdit ? "update" : "create"} namespace configuration`
});
}
};
const handleClose = () => {
reset();
onOpenChange(false);
};
return (
<Modal isOpen={isOpen} onOpenChange={handleClose}>
<ModalContent
title={isEdit ? "Edit Namespace Configuration" : "Add Namespace Configuration"}
subTitle={`Configure a HashiCorp Vault namespace ${isEdit ? "configuration" : "for migration tooling"}`}
bodyClassName="overflow-visible"
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="namespace"
render={({ field }) => (
<FormControl
label="Namespace"
isError={Boolean(errors.namespace)}
errorText={errors.namespace?.message}
className="mb-4"
>
<Input {...field} placeholder="e.g., admin, dev, prod" autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="connectionId"
render={({ field }) => {
const selectedConnection = vaultConnections.find((conn) => conn.id === field.value);
return (
<FormControl
label="Vault Connection"
isError={Boolean(errors.connectionId)}
errorText={errors.connectionId?.message}
tooltipText="Select a HashiCorp Vault app connection for this namespace"
>
<FilterableSelect
value={selectedConnection || null}
onChange={(newValue) => {
const singleValue = Array.isArray(newValue) ? newValue[0] : newValue;
if (singleValue && "id" in singleValue) {
field.onChange(singleValue.id);
} else {
field.onChange("");
}
}}
isLoading={isLoadingConnections}
options={vaultConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center gap-2">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting || isCreating || isUpdating}
isDisabled={isSubmitting || isCreating || isUpdating}
>
{isEdit ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain" onClick={handleClose}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -12,8 +12,7 @@ import {
import { useOrgPermission } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { useGetExternalMigrationConfig } from "@app/hooks/api/migration";
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
@@ -33,9 +32,8 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
] as const);
const { hasOrgRole } = useOrgPermission();
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
const isVaultImportDisabled = isDisabled || !isOrgAdmin;
@@ -77,8 +77,11 @@ import {
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { useGetExternalMigrationConfig, useImportVaultSecrets } from "@app/hooks/api/migration";
import { ExternalMigrationProviders, VaultImportStatus } from "@app/hooks/api/migration/types";
import {
useGetVaultExternalMigrationConfigs,
useImportVaultSecrets
} from "@app/hooks/api/migration";
import { VaultImportStatus } from "@app/hooks/api/migration/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
@@ -198,8 +201,8 @@ export const ActionBar = ({
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { permission } = useProjectPermission();
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const handleFolderCreate = async (folderName: string, description: string | null) => {
try {