Merge pull request #3446 from Infisical/ssh-non-interactive

Improvements to Infisical V2: Support for Non-Interactive Mode, Updating Default SSH CAs.
This commit is contained in:
BlackMagiq
2025-04-25 10:15:06 -07:00
committed by GitHub
21 changed files with 835 additions and 136 deletions
+3 -1
View File
@@ -4,7 +4,8 @@ export {
useLeaveProject,
useMigrateProjectToV3,
useRequestProjectAccess,
useUpdateGroupWorkspaceRole
useUpdateGroupWorkspaceRole,
useUpdateProjectSshConfig
} from "./mutations";
export {
useAddIdentityToWorkspace,
@@ -14,6 +15,7 @@ export {
useDeleteUserFromWorkspace,
useDeleteWorkspace,
useDeleteWsEnvironment,
useGetProjectSshConfig,
useGetUpgradeProjectStatus,
useGetUserWorkspaceMemberships,
useGetUserWorkspaces,
+22 -1
View File
@@ -4,7 +4,11 @@ import { apiRequest } from "@app/config/request";
import { userKeys } from "../users/query-keys";
import { workspaceKeys } from "./query-keys";
import { TUpdateWorkspaceGroupRoleDTO } from "./types";
import {
TProjectSshConfig,
TUpdateProjectSshConfigDTO,
TUpdateWorkspaceGroupRoleDTO
} from "./types";
export const useAddGroupToWorkspace = () => {
const queryClient = useQueryClient();
@@ -117,3 +121,20 @@ export const useRequestProjectAccess = () => {
}
});
};
export const useUpdateProjectSshConfig = () => {
const queryClient = useQueryClient();
return useMutation<TProjectSshConfig, object, TUpdateProjectSshConfigDTO>({
mutationFn: ({ projectId, defaultUserSshCaId, defaultHostSshCaId }) => {
return apiRequest.patch(`/api/v1/workspace/${projectId}/ssh-config`, {
defaultUserSshCaId,
defaultHostSshCaId
});
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getProjectSshConfig(projectId)
});
}
});
};
@@ -34,6 +34,7 @@ import {
TListProjectIdentitiesDTO,
ToggleAutoCapitalizationDTO,
ToggleDeleteProjectProtectionDTO,
TProjectSshConfig,
TSearchProjectsDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
@@ -887,3 +888,17 @@ export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: strin
enabled: Boolean(workspaceId)
});
};
export const useGetProjectSshConfig = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getProjectSshConfig(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<TProjectSshConfig>(
`/api/v1/workspace/${projectId}/ssh-config`
);
return data;
},
enabled: Boolean(projectId)
});
};
@@ -69,5 +69,6 @@ export const workspaceKeys = {
projectId: string;
}) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const,
getWorkspaceSshCertificateTemplates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificate-templates"] as const
[{ projectId }, "workspace-ssh-certificate-templates"] as const,
getProjectSshConfig: (projectId: string) => [{ projectId }, "project-ssh-config"] as const
};
+15
View File
@@ -184,3 +184,18 @@ export type TSearchProjectsDTO = {
orderBy?: ProjectIdentityOrderBy;
orderDirection?: OrderByDirection;
};
export type TProjectSshConfig = {
id: string;
createdAt: string;
updatedAt: string;
projectId: string;
defaultUserSshCaId: string | null;
defaultHostSshCaId: string | null;
};
export type TUpdateProjectSshConfigDTO = {
projectId: string;
defaultUserSshCaId?: string;
defaultHostSshCaId?: string;
};
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet, useRouterState } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
BreadcrumbContainer,
Menu,
@@ -11,7 +12,12 @@ import {
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import {
useGetAccessRequestsCount,
useGetSecretApprovalRequestCount,
@@ -187,22 +193,31 @@ export const ProjectLayout = () => {
</MenuItem>
)}
</Link> */}
{/* <Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshCertificateAuthorities}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link> */}
{(isAllowed) =>
isAllowed && (
<Link
to={`/${ProjectType.SSH}/$projectId/cas` as const}
params={{
projectId: currentWorkspace.id
}}
>
{({ isActive }) => (
<MenuItem
isSelected={isActive}
icon="certificate-authority"
iconMode="reverse"
>
Certificate Authorities
</MenuItem>
)}
</Link>
)
}
</ProjectPermissionCan>
</>
)}
{isSecretManager && (
@@ -1,11 +1,12 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
const tabs = [{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab }];
import { ProjectSshTab } from "./components/ProjectSshTab";
export const SettingsPage = () => {
const { t } = useTranslation();
@@ -17,19 +18,28 @@ export const SettingsPage = () => {
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.project.title")} />
<Tabs defaultValue={tabs[0].key}>
<Tabs defaultValue="tab-project-general">
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
<Tab value="tab-project-general">General</Tab>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => isAllowed && <Tab value="tab-project-ssh">SSH Settings</Tab>}
</ProjectPermissionCan>
</TabList>
{tabs.map(({ key, Component }) => (
<TabPanel value={key} key={key}>
<Component />
</TabPanel>
))}
<TabPanel value="tab-project-general">
<ProjectGeneralTab />
</TabPanel>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) =>
isAllowed && (
<TabPanel value="tab-project-ssh">
<ProjectSshTab />
</TabPanel>
)
}
</ProjectPermissionCan>
</Tabs>
</div>
</div>
@@ -0,0 +1,9 @@
import { ProjectSshConfigCasSection } from "./components";
export const ProjectSshTab = () => {
return (
<div>
<ProjectSshConfigCasSection />
</div>
);
};
@@ -0,0 +1,139 @@
import { useEffect } 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 { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useGetProjectSshConfig,
useListWorkspaceSshCas,
useUpdateProjectSshConfig
} from "@app/hooks/api";
const schema = z
.object({
defaultUserSshCaId: z.string(),
defaultHostSshCaId: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
export const ProjectSshConfigCasSection = () => {
const { currentWorkspace } = useWorkspace();
const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id);
const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id);
const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
if (sshConfig) {
reset({
defaultUserSshCaId: sshConfig.defaultUserSshCaId || "",
defaultHostSshCaId: sshConfig.defaultHostSshCaId || ""
});
}
}, [sshConfig]);
const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => {
try {
await updateProjectSshConfig({
projectId: currentWorkspace.id,
defaultUserSshCaId: defaultUserSshCaId || undefined,
defaultHostSshCaId: defaultHostSshCaId || undefined
});
createNotification({
text: "Successfully updated SSH project settings",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update SSH project settings",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold">Certificate Authorities</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="defaultUserSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default User CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="defaultHostSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default Host CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection";
@@ -0,0 +1 @@
export { ProjectSshTab } from "./ProjectSshTab";
@@ -23,6 +23,7 @@ import {
useCreateSshHost,
useGetSshHostById,
useGetWorkspaceUsers,
useListWorkspaceSshHosts,
useUpdateSshHost
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -58,6 +59,7 @@ export type FormData = z.infer<typeof schema>;
export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id);
const { data: members = [] } = useGetWorkspaceUsers(projectId);
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
@@ -116,6 +118,18 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
try {
if (!projectId) return;
// check if there is already a different host with the same hostname
const existingHostnames =
sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || [];
if (existingHostnames.includes(hostname)) {
createNotification({
text: "A host with this hostname already exists.",
type: "error"
});
return;
}
if (sshHost) {
await updateMutateAsync({
sshHostId: sshHost.id,