misc: filter optimizations

This commit is contained in:
Sheen Capadngan
2025-10-16 05:08:48 +08:00
parent efb8616c63
commit a95cb63682
8 changed files with 300 additions and 47 deletions
@@ -332,6 +332,35 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
} }
}); });
server.route({
method: "GET",
url: "/vault/auth-mounts",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string(),
authType: z.string().optional()
}),
response: {
200: z.object({
mounts: z.array(z.object({ path: z.string(), type: z.string() }))
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mounts = await server.services.migration.getVaultAuthMounts({
actor: req.permission,
namespace: req.query.namespace,
authType: req.query.authType
});
return { mounts };
}
});
server.route({ server.route({
method: "POST", method: "POST",
url: "/vault/import-secrets", url: "/vault/import-secrets",
@@ -372,7 +401,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
}, },
schema: { schema: {
querystring: z.object({ querystring: z.object({
namespace: z.string() namespace: z.string(),
mountPath: z.string()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -384,7 +414,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
handler: async (req) => { handler: async (req) => {
const secretPaths = await server.services.migration.getVaultSecretPaths({ const secretPaths = await server.services.migration.getVaultSecretPaths({
actor: req.permission, actor: req.permission,
namespace: req.query.namespace namespace: req.query.namespace,
mountPath: req.query.mountPath
}); });
return { secretPaths }; return { secretPaths };
@@ -399,7 +430,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
}, },
schema: { schema: {
querystring: z.object({ querystring: z.object({
namespace: z.string() namespace: z.string(),
mountPath: z.string()
}), }),
response: { response: {
@@ -437,7 +469,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
handler: async (req) => { handler: async (req) => {
const roles = await server.services.migration.getVaultKubernetesAuthRoles({ const roles = await server.services.migration.getVaultKubernetesAuthRoles({
actor: req.permission, actor: req.permission,
namespace: req.query.namespace namespace: req.query.namespace,
mountPath: req.query.mountPath
}); });
return { roles }; return { roles };
@@ -455,7 +455,8 @@ export const listHCVaultMounts = async (
export const listHCVaultSecretPaths = async ( export const listHCVaultSecretPaths = async (
namespace: string, namespace: string,
connection: THCVaultConnection, connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
filterMountPath?: string
) => { ) => {
const instanceUrl = await getHCVaultInstanceUrl(connection); const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService); const accessToken = await getHCVaultAccessToken(connection, gatewayService);
@@ -532,7 +533,13 @@ export const listHCVaultSecretPaths = async (
const mounts = await listHCVaultMounts(connection, gatewayService, namespace); const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
// Filter for KV mounts (kv, kv-v1, kv-v2) // Filter for KV mounts (kv, kv-v1, kv-v2)
const kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// If filterMountPath is provided, filter to only that mount
if (filterMountPath) {
const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash
kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath);
}
// Create concurrency limiter to avoid overwhelming the Vault instance // Create concurrency limiter to avoid overwhelming the Vault instance
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
@@ -648,7 +655,7 @@ export const getHCVaultSecretsForPath = async (
export const getHCVaultAuthMounts = async ( export const getHCVaultAuthMounts = async (
namespace: string, namespace: string,
authType: HCVaultAuthType, authType: HCVaultAuthType | undefined,
connection: THCVaultConnection, connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultAuthMount[]> => { ): Promise<THCVaultAuthMount[]> => {
@@ -668,7 +675,8 @@ export const getHCVaultAuthMounts = async (
const authMounts: THCVaultAuthMount[] = []; const authMounts: THCVaultAuthMount[] = [];
Object.entries(data.data).forEach(([path, authMethod]) => { Object.entries(data.data).forEach(([path, authMethod]) => {
if (authMethod.type === authType) { // If authType is specified, filter by it; otherwise, include all
if (!authType || authMethod.type === authType) {
authMounts.push({ authMounts.push({
path, path,
type: authMethod.type, type: authMethod.type,
@@ -680,16 +688,17 @@ export const getHCVaultAuthMounts = async (
return authMounts; return authMounts;
} catch (error: unknown) { } catch (error: unknown) {
logger.error(error, `Unable to list HC Vault ${authType} auth mounts`); const authTypeStr = authType || "all";
logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`);
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
throw new BadRequestError({ throw new BadRequestError({
message: `Failed to list ${authType} auth mounts: ${error.message || "Unknown error"}` message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}`
}); });
} }
throw new BadRequestError({ throw new BadRequestError({
message: `Unable to list ${authType} auth mounts from HashiCorp Vault` message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault`
}); });
} }
}; };
@@ -444,7 +444,15 @@ export const externalMigrationServiceFactory = ({
return mounts; return mounts;
}; };
const getVaultSecretPaths = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { const getVaultSecretPaths = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission( const { hasRole } = await permissionService.getOrgPermission(
actor.type, actor.type,
actor.id, actor.id,
@@ -482,7 +490,7 @@ export const externalMigrationServiceFactory = ({
credentials credentials
} as THCVaultConnection; } as THCVaultConnection;
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService); const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath);
return secretPaths; return secretPaths;
}; };
@@ -617,7 +625,66 @@ export const externalMigrationServiceFactory = ({
return deletedConfig; return deletedConfig;
}; };
const getVaultKubernetesAuthRoles = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { const getVaultAuthMounts = async ({
actor,
namespace,
authType
}: {
actor: OrgServiceActor;
namespace: string;
authType?: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
if (!hasRole(OrgMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService);
return authMounts;
};
const getVaultKubernetesAuthRoles = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission( const { hasRole } = await permissionService.getOrgPermission(
actor.type, actor.type,
actor.id, actor.id,
@@ -655,18 +722,10 @@ export const externalMigrationServiceFactory = ({
credentials credentials
} as THCVaultConnection; } as THCVaultConnection;
// Get all Kubernetes auth mounts for this namespace // Get roles for the specified mount path only
const authMounts = await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService); const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService);
// For each mount, get all roles with their configuration return roles;
const allRolesPromises = authMounts.map(async (mount) => {
const roles = await getHCVaultKubernetesAuthRoles(namespace, mount.path, connection, gatewayService);
return roles;
});
const rolesPerMount = await Promise.all(allRolesPromises);
return rolesPerMount.flat();
}; };
return { return {
@@ -680,6 +739,7 @@ export const externalMigrationServiceFactory = ({
getVaultNamespaces, getVaultNamespaces,
getVaultPolicies, getVaultPolicies,
getVaultMounts, getVaultMounts,
getVaultAuthMounts,
getVaultSecretPaths, getVaultSecretPaths,
importVaultSecrets, importVaultSecrets,
getVaultKubernetesAuthRoles getVaultKubernetesAuthRoles
Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 505 KiB

+55 -9
View File
@@ -17,8 +17,21 @@ export const externalMigrationQueryKeys = {
vaultNamespaces: () => ["vault-namespaces"], vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: (namespace?: string) => ["vault-policies", namespace], vaultPolicies: (namespace?: string) => ["vault-policies", namespace],
vaultMounts: (namespace?: string) => ["vault-mounts", namespace], vaultMounts: (namespace?: string) => ["vault-mounts", namespace],
vaultSecretPaths: (namespace?: string) => ["vault-secret-paths", namespace], vaultAuthMounts: (namespace?: string, authType?: string) => [
vaultKubernetesAuthRoles: (namespace?: string) => ["vault-kubernetes-auth-roles", namespace] "vault-auth-mounts",
namespace,
authType
],
vaultSecretPaths: (namespace?: string, mountPath?: string) => [
"vault-secret-paths",
namespace,
mountPath
],
vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [
"vault-kubernetes-auth-roles",
namespace,
mountPath
]
}; };
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => { export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
@@ -91,38 +104,71 @@ export const useGetVaultMounts = (enabled = true, namespace?: string) => {
}); });
}; };
export const useGetVaultSecretPaths = (enabled = true, namespace?: string) => { export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => {
return useQuery({ return useQuery({
queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace), queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath),
queryFn: async () => { queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{ const { data } = await apiRequest.get<{
secretPaths: string[]; secretPaths: string[];
}>("/api/v3/external-migration/vault/secret-paths", { }>("/api/v3/external-migration/vault/secret-paths", {
params: { params: {
namespace namespace,
mountPath
} }
}); });
return data.secretPaths; return data.secretPaths;
}, },
enabled: enabled && !!namespace && !!mountPath
});
};
export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType),
queryFn: async () => {
const { data } = await apiRequest.get<{
mounts: Array<{ path: string; type: string }>;
}>("/api/v3/external-migration/vault/auth-mounts", {
params: {
namespace,
...(authType && { authType })
}
});
return data.mounts;
},
enabled enabled
}); });
}; };
export const useGetVaultKubernetesAuthRoles = (enabled = true, namespace?: string) => { export const useGetVaultKubernetesAuthRoles = (
enabled = true,
namespace?: string,
mountPath?: string
) => {
return useQuery({ return useQuery({
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace), queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath),
queryFn: async () => { queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{ const { data } = await apiRequest.get<{
roles: VaultKubernetesAuthRole[]; roles: VaultKubernetesAuthRole[];
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", { }>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
params: { params: {
namespace namespace,
mountPath
} }
}); });
return data.roles; return data.roles;
}, },
enabled enabled: enabled && !!namespace && !!mountPath
}); });
}; };
@@ -10,6 +10,7 @@ import {
ModalContent ModalContent
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
useGetVaultAuthMounts,
useGetVaultKubernetesAuthRoles, useGetVaultKubernetesAuthRoles,
useGetVaultNamespaces useGetVaultNamespaces
} from "@app/hooks/api/migration/queries"; } from "@app/hooks/api/migration/queries";
@@ -28,21 +29,39 @@ type ContentProps = {
const Content = ({ onClose, onImport }: ContentProps) => { const Content = ({ onClose, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null); const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null); const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
const [shouldFetchRoles, setShouldFetchRoles] = useState(false); const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts(
shouldFetchMounts,
selectedNamespace ?? undefined,
"kubernetes"
);
const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles( const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles(
shouldFetchRoles, shouldFetchRoles,
selectedNamespace ?? undefined selectedNamespace ?? undefined,
selectedMountPath ?? undefined
); );
// Enable fetching mounts when namespace is selected
useEffect(() => { useEffect(() => {
if (selectedNamespace) { if (selectedNamespace) {
setShouldFetchRoles(true); setShouldFetchMounts(true);
} }
}, [selectedNamespace]); }, [selectedNamespace]);
// Enable fetching roles when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchRoles(true);
} else {
setShouldFetchRoles(false);
}
}, [selectedNamespace, selectedMountPath]);
const handleImportAndApply = () => { const handleImportAndApply = () => {
if (!selectedRole) { if (!selectedRole) {
createNotification({ createNotification({
@@ -70,6 +89,7 @@ const Content = ({ onClose, onImport }: ContentProps) => {
if (value && !Array.isArray(value)) { if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string }; const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name); setSelectedNamespace(namespace.name);
setSelectedMountPath(null);
setSelectedRole(null); setSelectedRole(null);
} }
}} }}
@@ -81,7 +101,42 @@ const Content = ({ onClose, onImport }: ContentProps) => {
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-mineshaft-400"> <p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available Kubernetes auth roles Select the Vault namespace to fetch available auth mounts
</p>
</>
</FormControl>
<FormControl
label="Auth Engine"
className="mb-4"
tooltipText="Select the Kubernetes auth engine to narrow down available roles."
>
<>
<FilterableSelect
value={
selectedMountPath
? authMounts?.find((mount) => mount.path === selectedMountPath)
: null
}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedRole(null);
} else {
setSelectedMountPath(null);
}
}}
options={authMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !authMounts?.length}
placeholder="Select auth engine..."
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a Kubernetes auth engine to filter available roles
</p> </p>
</> </>
</FormControl> </FormControl>
@@ -99,9 +154,13 @@ const Content = ({ onClose, onImport }: ContentProps) => {
}} }}
options={roles || []} options={roles || []}
getOptionValue={(option) => option.name} getOptionValue={(option) => option.name}
getOptionLabel={(option) => `${option.name} (${option.mountPath})`} getOptionLabel={(option) => option.name}
isDisabled={isLoadingRoles || !roles?.length} isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath}
placeholder="Select a Kubernetes role to load..." placeholder={
!selectedMountPath
? "Select an auth engine first..."
: "Select a Kubernetes role to load..."
}
isClearable isClearable
className="w-full" className="w-full"
/> />
@@ -34,6 +34,7 @@ type ContentProps = {
const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => { const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null); const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [shouldFetchPaths, setShouldFetchPaths] = useState(false); const [shouldFetchPaths, setShouldFetchPaths] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false); const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
@@ -41,21 +42,33 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths( const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths(
shouldFetchPaths, shouldFetchPaths,
selectedNamespace ?? undefined selectedNamespace ?? undefined,
selectedMountPath ?? undefined
); );
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts( const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
shouldFetchMounts, shouldFetchMounts,
selectedNamespace ?? undefined selectedNamespace ?? undefined
); );
// Enable fetching paths and mounts when namespace is selected // Filter to only show KV mounts
const kvMounts = mounts?.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// Enable fetching mounts when namespace is selected
useEffect(() => { useEffect(() => {
if (selectedNamespace) { if (selectedNamespace) {
setShouldFetchPaths(true);
setShouldFetchMounts(true); setShouldFetchMounts(true);
} }
}, [selectedNamespace]); }, [selectedNamespace]);
// Enable fetching paths when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchPaths(true);
} else {
setShouldFetchPaths(false);
}
}, [selectedNamespace, selectedMountPath]);
const handleImport = () => { const handleImport = () => {
if (!selectedPath) { if (!selectedPath) {
createNotification({ type: "error", text: "Please select a Vault secret path to import" }); createNotification({ type: "error", text: "Please select a Vault secret path to import" });
@@ -111,6 +124,7 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
if (value && !Array.isArray(value)) { if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string }; const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name); setSelectedNamespace(namespace.name);
setSelectedMountPath(null);
setSelectedPath(null); setSelectedPath(null);
} }
}} }}
@@ -122,7 +136,35 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-mineshaft-400"> <p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available secret paths Select the Vault namespace to fetch available mounts
</p>
</>
</FormControl>
<FormControl
label="Secrets Engine"
className="mb-4"
tooltipText="Select the KV secrets engine to narrow down secret paths."
>
<>
<FilterableSelect
value={kvMounts?.find((mount) => mount.path === selectedMountPath)}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string; version: string | null };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedPath(null);
}
}}
options={kvMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !kvMounts?.length}
placeholder="Select secrets engine..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a KV secrets engine to filter available secret paths
</p> </p>
</> </>
</FormControl> </FormControl>
@@ -141,13 +183,17 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
options={(secretPaths || []).map((path) => ({ path }))} options={(secretPaths || []).map((path) => ({ path }))}
getOptionValue={(option) => option.path} getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path} getOptionLabel={(option) => option.path}
isDisabled={isLoadingPaths || !secretPaths?.length} isDisabled={isLoadingPaths || !secretPaths?.length || !selectedMountPath}
placeholder="Select a Vault path to import..." placeholder={
!selectedMountPath
? "Select a mount path first..."
: "Select a Vault path to import..."
}
isClearable isClearable
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-mineshaft-400"> <p className="mt-1 text-xs text-mineshaft-400">
Choose a secret path from your Vault namespace to import into Infisical Choose a secret path from the selected mount to import into Infisical
</p> </p>
</> </>
</FormControl> </FormControl>