Merge branch 'main' into ENG-2647

This commit is contained in:
x
2025-05-02 19:03:08 -04:00
108 changed files with 3763 additions and 258 deletions
+4
View File
@@ -304,6 +304,10 @@ export const ROUTE_PATHS = Object.freeze({
SshCaByIDPage: setRoute(
"/ssh/$projectId/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId"
),
SshHostGroupDetailsByIDPage: setRoute(
"/ssh/$projectId/ssh-host-groups/$sshHostGroupId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId"
)
},
Public: {
@@ -175,6 +175,7 @@ export enum ProjectPermissionSub {
SshCertificateTemplates = "ssh-certificate-templates",
SshCertificates = "ssh-certificates",
SshHosts = "ssh-hosts",
SshHostGroups = "ssh-host-groups",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
@@ -272,6 +273,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
| [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups]
| [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
+1
View File
@@ -107,6 +107,7 @@ export type CompleteAccountSignupDTO = CompleteAccountDTO & {
providerAuthToken?: string;
attributionSource?: string;
organizationName: string;
useDefaultOrg?: boolean;
};
export type VerifySignupInviteDTO = {
+1
View File
@@ -44,6 +44,7 @@ export * from "./serviceTokens";
export * from "./sshCa";
export * from "./sshCertificateTemplates";
export * from "./sshHost";
export * from "./sshHostGroup";
export * from "./ssoConfig";
export * from "./subscriptions";
export * from "./tags";
+16 -19
View File
@@ -1,3 +1,16 @@
export enum LoginMappingSource {
HOST = "host",
HOST_GROUP = "hostGroup"
}
export type TLoginMapping = {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
source: LoginMappingSource;
};
export type TSshHost = {
id: string;
projectId: string;
@@ -5,26 +18,15 @@ export type TSshHost = {
alias: string | null;
userCertTtl: string;
hostCertTtl: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
loginMappings: TLoginMapping[];
};
export type TCreateSshHostDTO = {
projectId: string;
hostname: string;
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
loginMappings: Omit<TLoginMapping, "source">[];
};
export type TUpdateSshHostDTO = {
@@ -33,12 +35,7 @@ export type TUpdateSshHostDTO = {
alias?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
loginMappings?: Omit<TLoginMapping, "source">[];
};
export type TDeleteSshHostDTO = {
@@ -0,0 +1,8 @@
export {
useAddHostToSshHostGroup,
useCreateSshHostGroup,
useDeleteSshHostGroup,
useRemoveHostFromSshHostGroup,
useUpdateSshHostGroup
} from "./mutations";
export { useGetSshHostGroupById, useListSshHostGroupHosts } from "./queries";
@@ -0,0 +1,105 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/query-keys";
import { sshHostGroupKeys } from "./queries";
import {
TCreateSshHostGroupDTO,
TDeleteSshHostGroupDTO,
TSshHostGroup,
TUpdateSshHostGroupDTO
} from "./types";
export const useCreateSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<TSshHostGroup, object, TCreateSshHostGroupDTO>({
mutationFn: async (body) => {
const { data: hostGroup } = await apiRequest.post("/api/v1/ssh/host-groups", body);
return hostGroup;
},
onSuccess: ({ projectId, id }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.getSshHostGroupById(id)
});
}
});
};
export const useUpdateSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<TSshHostGroup, object, TUpdateSshHostGroupDTO>({
mutationFn: async ({ sshHostGroupId, ...body }) => {
const { data: hostGroup } = await apiRequest.patch(
`/api/v1/ssh/host-groups/${sshHostGroupId}`,
body
);
return hostGroup;
},
onSuccess: ({ projectId }, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHosts(projectId)
});
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId)
});
}
});
};
export const useDeleteSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<TSshHostGroup, object, TDeleteSshHostGroupDTO>({
mutationFn: async ({ sshHostGroupId }) => {
const { data: hostGroup } = await apiRequest.delete(
`/api/v1/ssh/host-groups/${sshHostGroupId}`
);
return hostGroup;
},
onSuccess: ({ projectId }, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHosts(projectId)
});
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId)
});
}
});
};
export const useAddHostToSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<void, object, { sshHostGroupId: string; sshHostId: string }>({
mutationFn: async ({ sshHostGroupId, sshHostId }) => {
await apiRequest.post(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
},
onSuccess: (_, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
});
}
});
};
export const useRemoveHostFromSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<void, object, { sshHostGroupId: string; sshHostId: string }>({
mutationFn: async ({ sshHostGroupId, sshHostId }) => {
await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
},
onSuccess: (_, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
});
}
});
};
@@ -0,0 +1,60 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { EHostGroupMembershipFilter, TListSshHostGroupHostsResponse, TSshHostGroup } from "./types";
export const sshHostGroupKeys = {
getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"],
allSshHostGroupHosts: () => ["ssh-host-group-hosts"] as const,
forSshHostGroupHosts: (sshHostGroupId: string) =>
[...sshHostGroupKeys.allSshHostGroupHosts(), sshHostGroupId] as const,
specificSshHostGroupHosts: ({
sshHostGroupId,
filter
}: {
sshHostGroupId: string;
filter?: EHostGroupMembershipFilter;
}) => [...sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId), { filter }] as const
};
export const useGetSshHostGroupById = (sshHostGroupId: string) => {
return useQuery({
queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId),
queryFn: async () => {
const { data: sshHostGroup } = await apiRequest.get<TSshHostGroup>(
`/api/v1/ssh/host-groups/${sshHostGroupId}`
);
return sshHostGroup;
},
enabled: Boolean(sshHostGroupId)
});
};
export const useListSshHostGroupHosts = ({
sshHostGroupId,
filter
}: {
sshHostGroupId: string;
filter?: EHostGroupMembershipFilter;
}) => {
return useQuery({
queryKey: sshHostGroupKeys.specificSshHostGroupHosts({ sshHostGroupId, filter }),
queryFn: async () => {
const params = new URLSearchParams({
...(filter ? { filter } : {})
});
const { data } = await apiRequest.get<TListSshHostGroupHostsResponse>(
`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`,
{
params
}
);
return data;
},
enabled: Boolean(sshHostGroupId),
staleTime: 0,
gcTime: 0
});
};
@@ -0,0 +1,34 @@
import { TLoginMapping, TSshHost } from "../sshHost/types";
export type TSshHostGroup = {
id: string;
projectId: string;
name: string;
loginMappings: Omit<TLoginMapping, "source">[];
};
export type TCreateSshHostGroupDTO = {
projectId: string;
name: string;
loginMappings: Omit<TLoginMapping, "source">[];
};
export type TUpdateSshHostGroupDTO = {
sshHostGroupId: string;
name?: string;
loginMappings?: Omit<TLoginMapping, "source">[];
};
export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string;
};
export type TListSshHostGroupHostsResponse = {
hosts: (TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean })[];
totalCount: number;
};
export enum EHostGroupMembershipFilter {
GROUP_MEMBERS = "group-members",
NON_GROUP_MEMBERS = "non-group-members"
}
@@ -24,6 +24,7 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
sshHostGroups: boolean;
secretAccessInsights: boolean;
hsm: boolean;
oidcSSO: boolean;
@@ -38,6 +38,7 @@ export {
useListWorkspaceSshCas,
useListWorkspaceSshCertificates,
useListWorkspaceSshCertificateTemplates,
useListWorkspaceSshHostGroups,
useListWorkspaceSshHosts,
useNameWorkspaceSecrets,
useSearchProjects,
@@ -18,6 +18,7 @@ import { EncryptedSecret } from "../secrets/types";
import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types";
import { TSshCertificateTemplate } from "../sshCertificateTemplates/types";
import { TSshHost } from "../sshHost/types";
import { TSshHostGroup } from "../sshHostGroup/types";
import { userKeys } from "../users/query-keys";
import { TWorkspaceUser } from "../users/types";
import {
@@ -873,6 +874,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => {
});
};
export const useListWorkspaceSshHostGroups = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId),
queryFn: async () => {
const {
data: { groups }
} = await apiRequest.get<{ groups: (TSshHostGroup & { hostCount: number })[] }>(
`/api/v2/workspace/${projectId}/ssh-host-groups`
);
return groups;
},
enabled: Boolean(projectId)
});
};
export const useListWorkspaceSshCertificateTemplates = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId),
@@ -63,6 +63,8 @@ export const workspaceKeys = {
allWorkspaceSshCertificates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificates"] as const,
getWorkspaceSshHosts: (projectId: string) => [{ projectId }, "workspace-ssh-hosts"] as const,
getWorkspaceSshHostGroups: (projectId: string) =>
[{ projectId }, "workspace-ssh-host-groups"] as const,
specificWorkspaceSshCertificates: ({
offset,
limit,
@@ -238,7 +238,7 @@ export const OverviewPage = () => {
Default organization
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the default organization you want to set for SAML/LDAP/OIDC based
Select the default organization you want to set for SAML/LDAP/OIDC/Github
logins. When selected, user logins will be automatically scoped to the
selected organization.
</div>
@@ -13,6 +13,7 @@ export const SignupSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: ROUTE_PATHS.Auth.SignUpSsoPage.id });
const token = search.token as string;
const defaultOrgAllowed = search.defaultOrgAllowed as boolean | undefined;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
@@ -57,6 +58,7 @@ export const SignupSsoPage = () => {
password={password}
setPassword={setPassword}
providerAuthToken={token}
forceDefaultOrg={defaultOrgAllowed}
/>
);
default:
@@ -30,6 +30,7 @@ type Props = {
name: string;
providerOrganizationName: string;
providerAuthToken?: string;
forceDefaultOrg?: boolean;
};
/**
@@ -51,7 +52,8 @@ export const UserInfoSSOStep = ({
providerOrganizationName,
password,
setPassword,
providerAuthToken
providerAuthToken,
forceDefaultOrg
}: Props) => {
const [nameError, setNameError] = useState(false);
const [organizationName, setOrganizationName] = useState("");
@@ -84,7 +86,7 @@ export const UserInfoSSOStep = ({
} else {
setNameError(false);
}
if (!organizationName) {
if (!organizationName && !forceDefaultOrg) {
setOrganizationNameError(true);
errorCheck = true;
} else {
@@ -160,7 +162,8 @@ export const UserInfoSSOStep = ({
salt: result.salt,
verifier: result.verifier,
organizationName,
attributionSource
attributionSource,
useDefaultOrg: forceDefaultOrg
});
// unset signup JWT token and set JWT token
@@ -267,7 +270,7 @@ export const UserInfoSSOStep = ({
</p>
)}
</div>
{providerOrganizationName === undefined && (
{!forceDefaultOrg && providerOrganizationName === undefined && (
<div className="relative z-0 flex w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Organization Name
@@ -279,7 +282,7 @@ export const UserInfoSSOStep = ({
isRequired
className="h-12"
maxLength={64}
disabled
isDisabled={forceDefaultOrg}
/>
{organizationNameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
@@ -5,7 +5,8 @@ import { z } from "zod";
import { SignupSsoPage } from "./SignUpSsoPage";
const SignupSSOPageQueryParamsSchema = z.object({
token: z.string()
token: z.string(),
defaultOrgAllowed: z.boolean().optional()
});
export const Route = createFileRoute("/_restrict-login-signup/signup/sso")({
@@ -234,6 +234,7 @@ export const projectRoleFormSchema = z.object({
})
.array()
.default([]),
[ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
@@ -380,7 +381,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.Kms,
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshHostGroups
].includes(subject)
) {
// from above statement we are sure it won't be undefined
@@ -1064,6 +1066,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert }
]
},
[ProjectPermissionSub.SshHostGroups]: {
title: "SSH Host Groups",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections",
actions: [
@@ -0,0 +1,157 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
PageHeader,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useDeleteSshHostGroup, useGetSshHostGroupById } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshHostGroupModal } from "../SshHostsPage/components/SshHostGroupModal";
import { SshHostGroupDetailsSection, SshHostGroupHostsSection } from "./components";
const Page = () => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const projectId = currentWorkspace?.id || "";
const sshHostGroupId = useParams({
from: ROUTE_PATHS.Ssh.SshHostGroupDetailsByIDPage.id,
select: (el) => el.sshHostGroupId
});
const { data } = useGetSshHostGroupById(sshHostGroupId);
const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHostGroup",
"deleteSshHostGroup"
] as const);
const onRemoveSshGroupSubmit = async (groupIdToDelete: string) => {
try {
if (!projectId) return;
await deleteSshHostGroup({ sshHostGroupId: groupIdToDelete });
createNotification({
text: "Successfully deleted SSH group",
type: "success"
});
handlePopUpClose("deleteSshHostGroup");
navigate({
to: `/${ProjectType.SSH}/$projectId/overview` as const,
params: {
projectId
}
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH group",
type: "error"
});
}
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader title={data.name}>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<Button variant="outline_bg">More</Button>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("deleteSshHostGroup", {
groupId: data.id,
name: data.name
})
}
disabled={!isAllowed}
>
Delete SSH Group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<SshHostGroupDetailsSection
sshHostGroupId={sshHostGroupId}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<div className="w-full">
<SshHostGroupHostsSection sshHostGroupId={sshHostGroupId} />
</div>
</div>
</div>
)}
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHostGroup.isOpen}
title={`Are you sure want to remove the SSH group: ${
(popUp?.deleteSshHostGroup?.data as { name: string })?.name || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId)
}
/>
</div>
);
};
export const SshHostGroupDetailsByIDPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "SSH Group" })}</title>
</Helmet>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.SshHostGroups}
passThrough={false}
renderGuardBanner
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,127 @@
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useAddHostToSshHostGroup, useListSshHostGroupHosts } from "@app/hooks/api";
import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["addHostGroupMembers"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["addHostGroupMembers"]>,
state?: boolean
) => void;
};
export const AddHostGroupMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const popUpData = popUp?.addHostGroupMembers?.data as {
sshHostGroupId: string;
};
const { data, isPending } = useListSshHostGroupHosts({
sshHostGroupId: popUpData?.sshHostGroupId,
filter: EHostGroupMembershipFilter.NON_GROUP_MEMBERS
});
const { mutateAsync: addHostToSshHostGroup, isPending: isAddingHostToSshHostGroup } =
useAddHostToSshHostGroup();
const handleAddHost = async (sshHostId: string) => {
try {
if (!popUpData?.sshHostGroupId) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
await addHostToSshHostGroup({
sshHostGroupId: popUpData.sshHostGroupId,
sshHostId
});
createNotification({
text: "Successfully added host to the group",
type: "success"
});
} catch {
createNotification({
text: "Failed to add host to the group",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addHostGroupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addHostGroupMembers", isOpen);
}}
>
<ModalContent title="Add Hosts to Group">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="ssh-hosts" />}
{!isPending &&
data?.hosts?.map((host) => {
return (
<Tr className="items-center" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Button
isLoading={isAddingHostToSshHostGroup}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="button"
onClick={() => handleAddHost(host.id)}
>
Add
</Button>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.hosts?.length && (
<EmptyState title="No hosts available to add to the SSH host group" icon={faServer} />
)}
</TableContainer>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,84 @@
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetSshHostGroupById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
sshHostGroupId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, data?: object) => void;
};
export const SshHostGroupDetailsSection = ({ sshHostGroupId, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId);
return sshHostGroup ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Host Group Details</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => {
return (
<Tooltip content="Edit SSH Host Group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshHostGroup", {
sshHostGroupId: sshHostGroup.id
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">SSH Host Group ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{sshHostGroup.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(sshHostGroup.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{sshHostGroup.name}</p>
</div>
</div>
</div>
) : (
<div />
);
};
@@ -0,0 +1,113 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useRemoveHostFromSshHostGroup } from "@app/hooks/api";
import { AddHostGroupMemberModal } from "./AddHostGroupMemberModal";
import { SshHostGroupHostsTable } from "./SshHostGroupHostsTable";
type Props = {
sshHostGroupId: string;
};
export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"removeHostFromSshHostGroup",
"addHostGroupMembers",
"upgradePlan"
] as const);
const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup();
const handleAddSshHostModal = () => {
if (!subscription?.sshHostGroups) {
handlePopUpOpen("upgradePlan", {
description:
"You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license."
});
} else {
handlePopUpOpen("addHostGroupMembers", {
sshHostGroupId
});
}
};
const onRemoveSshHostSubmit = async (sshHostId: string) => {
try {
await removeHostFromGroup({
sshHostId,
sshHostGroupId
});
await createNotification({
text: "Successfully removed host from SSH group",
type: "success"
});
handlePopUpClose("removeHostFromSshHostGroup");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove host from SSH group",
type: "error"
});
}
};
return (
<div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Hosts</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<IconButton
ariaLabel="add host"
variant="plain"
className="group relative"
onClick={() => handleAddSshHostModal()}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<SshHostGroupHostsTable sshHostGroupId={sshHostGroupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<AddHostGroupMemberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeHostFromSshHostGroup.isOpen}
title={`Are you sure want to remove ${
(popUp?.removeHostFromSshHostGroup?.data as { hostname: string; alias?: string })
?.alias ||
(popUp?.removeHostFromSshHostGroup?.data as { hostname: string; alias?: string })
?.hostname ||
""
} from this host group?`}
onChange={(isOpen) => handlePopUpToggle("removeHostFromSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostSubmit(
(popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,97 @@
import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useListSshHostGroupHosts } from "@app/hooks/api";
import { EHostGroupMembershipFilter } from "@app/hooks/api/sshHostGroup/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
sshHostGroupId: string;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeHostFromSshHostGroup"]>,
data?: object
) => void;
};
export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Props) => {
const { data, isPending } = useListSshHostGroupHosts({
sshHostGroupId,
filter: EHostGroupMembershipFilter.GROUP_MEMBERS
});
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th>Added On</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="ssh-host-group-hosts" />}
{!isPending &&
data?.hosts.map((host) => {
return (
<Tr className="h-10" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td>{new Date(host.joinedGroupAt).toLocaleDateString()}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Tooltip content="Remove host from group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="Remove host from group"
onClick={() =>
handlePopUpOpen("removeHostFromSshHostGroup", {
sshHostId: host.id,
alias: host.alias,
hostname: host.hostname
})
}
variant="plain"
colorSchema="danger"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.hosts?.length && (
<EmptyState title="No hosts have been added to this SSH host group" icon={faServer} />
)}
</TableContainer>
</div>
);
};
export default SshHostGroupHostsTable;
@@ -0,0 +1,3 @@
export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection";
export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection";
export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { SshHostGroupDetailsByIDPage } from "./SshHostGroupDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId"
)({
component: SshHostGroupDetailsByIDPage
});
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { PageHeader } from "@app/components/v2";
import { SshHostsSection } from "./components";
import { SshHostGroupsSection, SshHostsSection } from "./components";
export const SshHostsPage = () => {
const { t } = useTranslation();
@@ -19,6 +19,7 @@ export const SshHostsPage = () => {
title="Hosts"
description="Manage your SSH hosts, configure access policies, and define login behavior for secure connections."
/>
<SshHostGroupsSection />
<SshHostsSection />
</div>
</div>
@@ -0,0 +1,396 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateSshHostGroup,
useGetSshHostGroupById,
useGetWorkspaceUsers,
useListWorkspaceSshHostGroups,
useUpdateSshHostGroup
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshHostGroup"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, state?: boolean) => void;
};
const schema = z
.object({
name: z.string().trim().min(1).max(64),
loginMappings: z
.object({
loginUser: z.string().trim().min(1),
allowedPrincipals: z.array(z.string().trim()).default([])
})
.array()
.default([])
})
.required();
export type FormData = z.infer<typeof schema>;
export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id);
const { data: members = [] } = useGetWorkspaceUsers(projectId);
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
const { data: sshHostGroup } = useGetSshHostGroupById(
(popUp?.sshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId || ""
);
const { mutateAsync: createMutateAsync } = useCreateSshHostGroup();
const { mutateAsync: updateMutateAsync } = useUpdateSshHostGroup();
const {
control,
handleSubmit,
reset,
getValues,
setValue,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
loginMappings: []
}
});
const loginMappingsFormFields = useFieldArray({
control,
name: "loginMappings"
});
useEffect(() => {
if (sshHostGroup) {
reset({
name: sshHostGroup.name,
loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: allowedPrincipals.usernames
}))
});
setExpandedMappings(
Object.fromEntries(sshHostGroup.loginMappings.map((_, index) => [index, false]))
);
} else {
reset({
name: "",
loginMappings: []
});
}
}, [sshHostGroup]);
const onFormSubmit = async ({ name, loginMappings }: FormData) => {
try {
if (!projectId) return;
// check if there is already a different host group with the same name
const existingNames =
sshHostGroups?.filter((h) => h.id !== sshHostGroup?.id).map((h) => h.name) || [];
if (existingNames.includes(name.trim())) {
createNotification({
text: "A host group with this name already exists.",
type: "error"
});
return;
}
if (sshHostGroup) {
await updateMutateAsync({
sshHostGroupId: sshHostGroup.id,
name,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
});
} else {
await createMutateAsync({
projectId,
name,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
}
}))
});
}
reset();
handlePopUpToggle("sshHostGroup", false);
createNotification({
text: `Successfully ${sshHostGroup ? "updated" : "created"} SSH host group`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${sshHostGroup ? "update" : "create"} SSH host group`,
type: "error"
});
}
};
const toggleMapping = (index: number) => {
setExpandedMappings((prev) => ({
...prev,
[index]: !prev[index]
}));
};
return (
<Modal
isOpen={popUp?.sshHostGroup?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("sshHostGroup", isOpen);
}}
>
<ModalContent title={`${sshHostGroup ? "View" : "Add"} SSH host group`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{sshHostGroup && (
<FormControl label="SSH Host Group ID">
<Input value={sshHostGroup.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="production" />
</FormControl>
)}
/>
<div className="mb-4 flex items-center justify-between">
<FormLabel label="Login Mappings" />
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
setExpandedMappings((prev) => ({
...prev,
[newIndex]: true
}));
}}
>
Add Login Mapping
</Button>
</div>
<div className="mb-4 flex flex-col space-y-4">
{loginMappingsFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div
key={metadataFieldId}
className="flex flex-col space-y-2 rounded-md border border-mineshaft-600 p-4"
>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
className="flex cursor-pointer items-center py-1 text-sm text-mineshaft-200"
onClick={() => toggleMapping(i)}
>
<FontAwesomeIcon
icon={expandedMappings[i] ? faChevronDown : faChevronRight}
className="mr-4"
size="sm"
/>
<Controller
control={control}
name={`loginMappings.${i}.loginUser`}
render={({ field }) => (
<span className="text-sm font-medium leading-tight">
{field.value || "New Login Mapping"}
</span>
)}
/>
</button>
<IconButton
ariaLabel="delete login mapping"
variant="plain"
onClick={() => loginMappingsFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
{expandedMappings[i] && (
<>
<div>
<span className="text-xs text-mineshaft-400">Login User</span>
<Controller
control={control}
name={`loginMappings.${i}.loginUser`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input
{...field}
placeholder="ec2-user"
onChange={(e) => {
const newValue = e.target.value;
const loginMappings = getValues("loginMappings");
const isDuplicate = loginMappings.some(
(mapping, index) => index !== i && mapping.loginUser === newValue
);
if (isDuplicate) {
createNotification({
text: "This login user already exists",
type: "error"
});
return;
}
field.onChange(e);
}}
/>
</FormControl>
)}
/>
</div>
<div className="flex flex-col space-y-2">
<div className="mb-2 mt-4 flex items-center justify-between">
<FormLabel
label="Allowed Principals"
className="text-xs text-mineshaft-400"
/>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
</div>
<Controller
control={control}
name={`loginMappings.${i}.allowedPrincipals`}
render={({ field: { value = [], onChange }, fieldState: { error } }) => (
<div className="flex flex-col space-y-2">
{(value.length === 0 ? [""] : value).map(
(principal: string, principalIndex: number) => (
<div
key={`${metadataFieldId}-principal-${principal}`}
className="flex items-center space-x-2"
>
<div className="flex-1">
<Select
value={principal}
onValueChange={(newValue) => {
if (value.includes(newValue)) {
createNotification({
text: "This principal is already added",
type: "error"
});
return;
}
const newPrincipals = [...value];
newPrincipals[principalIndex] = newValue;
onChange(newPrincipals);
}}
placeholder="Select a member"
className="w-full"
>
{members.map((member) => (
<SelectItem
key={member.user.id}
value={member.user.username}
>
{member.user.username}
</SelectItem>
))}
</Select>
</div>
<IconButton
size="sm"
ariaLabel="delete principal"
variant="plain"
className="h-9"
onClick={() => {
const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex
);
onChange(newPrincipals);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
)
)}
{error && <span className="text-sm text-red-500">{error.message}</span>}
</div>
)}
/>
</div>
</>
)}
</div>
))}
</div>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.sshHostGroup?.data ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("sshHostGroup", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,96 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import { useDeleteSshHostGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { SshHostGroupModal } from "./SshHostGroupModal";
import { SshHostGroupsTable } from "./SshHostGroupsTable";
export const SshHostGroupsSection = () => {
const { subscription } = useSubscription();
const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHostGroup",
"deleteSshHostGroup",
"upgradePlan"
] as const);
const handleAddSshHostGroupModal = () => {
if (!subscription?.sshHostGroups) {
handlePopUpOpen("upgradePlan", {
description:
"You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license."
});
} else {
handlePopUpOpen("sshHostGroup");
}
};
const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => {
try {
const hostGroup = await deleteSshHostGroup({ sshHostGroupId });
createNotification({
text: `Successfully deleted SSH host group: ${hostGroup.name}`,
type: "success"
});
handlePopUpClose("deleteSshHostGroup");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SSH host group",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Host Groups</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="button"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddSshHostGroupModal()}
isDisabled={!isAllowed}
>
Add Group
</Button>
)}
</ProjectPermissionCan>
</div>
<SshHostGroupsTable handlePopUpOpen={handlePopUpOpen} />
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHostGroup.isOpen}
title={`Are you sure you want to remove the SSH host group: ${popUp?.deleteSshHostGroup?.data?.name}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostGroupSubmit(
(popUp?.deleteSshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,184 @@
import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useListWorkspaceSshHostGroups } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteSshHostGroup", "sshHostGroup"]>,
data?: object
) => void;
};
export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListWorkspaceSshHostGroups(currentWorkspace?.id || "");
return (
<div>
<TableContainer>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Name</Th>
<Th># Hosts in Group</Th>
<Th>Login User - Authorized Principals Mapping</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="org-ssh-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((group) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ssh-host-group-${group.id}`}
onClick={() =>
navigate({
to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const,
params: {
projectId: currentWorkspace.id,
sshHostGroupId: group.id
}
})
}
>
<Td>{group.name}</Td>
<Td>{group.hostCount}</Td>
<Td>
{group.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span>
) : (
group.loginMappings.map(({ loginUser, allowedPrincipals }) => (
<div key={`${group.id}-${loginUser}`} className="mb-2">
<div className="text-mineshaft-200">{loginUser}</div>
{allowedPrincipals.usernames.map((username) => (
<div key={`${group.id}-${loginUser}-${username}`} className="ml-4">
└─ {username}
</div>
))}
</div>
))
)}
</Td>
<Td className="text-right align-middle">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshHostGroup", {
sshHostGroupId: group.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit Host Group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const,
params: {
projectId: currentWorkspace.id,
sshHostGroupId: group.id
}
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faServer} />}
>
Manage Hosts
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteSshHostGroup", {
sshHostGroupId: group.id,
name: group.name
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Host Group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState title="No SSH host groups have been created" icon={faServer} />
)}
</TableContainer>
</div>
);
};
@@ -26,6 +26,7 @@ import {
useListWorkspaceSshHosts,
useUpdateSshHost
} from "@app/hooks/api";
import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -48,7 +49,8 @@ const schema = z
loginMappings: z
.object({
loginUser: z.string().trim().min(1),
allowedPrincipals: z.array(z.string().trim()).default([])
allowedPrincipals: z.array(z.string().trim()).default([]),
source: z.nativeEnum(LoginMappingSource)
})
.array()
.default([])
@@ -99,9 +101,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname: sshHost.hostname,
alias: sshHost.alias ?? "",
userCertTtl: sshHost.userCertTtl,
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({
loginUser,
allowedPrincipals: allowedPrincipals.usernames
allowedPrincipals: allowedPrincipals.usernames,
source
}))
});
@@ -122,6 +125,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
try {
if (!projectId) return;
// Filter out login mappings that are from host groups
const hostLoginMappings = loginMappings.filter(
(mapping) => mapping.source === LoginMappingSource.HOST
);
// 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) || [];
@@ -157,7 +165,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname,
alias: trimmedAlias,
userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
@@ -170,7 +178,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname,
alias: trimmedAlias,
userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
allowedPrincipals: {
usernames: allowedPrincipals
@@ -265,7 +273,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="outline_bg"
onClick={() => {
const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] });
loginMappingsFormFields.append({
loginUser: "",
allowedPrincipals: [""],
source: LoginMappingSource.HOST
});
setExpandedMappings((prev) => ({
...prev,
[newIndex]: true
@@ -298,6 +310,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
render={({ field }) => (
<span className="text-sm font-medium leading-tight">
{field.value || "New Login Mapping"}
{loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP && (
<span className="ml-2 text-xs text-mineshaft-400">
(inherited from host group)
</span>
)}
</span>
)}
/>
@@ -306,6 +324,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
ariaLabel="delete login mapping"
variant="plain"
onClick={() => loginMappingsFormFields.remove(i)}
isDisabled={
loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST_GROUP
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
@@ -327,11 +348,24 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<Input
{...field}
placeholder="ec2-user"
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
onChange={(e) => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
const newValue = e.target.value;
const loginMappings = getValues("loginMappings");
const isDuplicate = loginMappings.some(
(mapping, index) => index !== i && mapping.loginUser === newValue
(mapping, index) =>
index !== i &&
mapping.loginUser === newValue &&
mapping.source === LoginMappingSource.HOST
);
if (isDuplicate) {
@@ -355,17 +389,20 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
label="Allowed Principals"
className="text-xs text-mineshaft-400"
/>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
{loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && (
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current =
getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
)}
</div>
<Controller
control={control}
@@ -382,6 +419,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<Select
value={principal}
onValueChange={(newValue) => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
if (value.includes(newValue)) {
createNotification({
text: "This principal is already added",
@@ -395,6 +438,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
}}
placeholder="Select a member"
className="w-full"
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
>
{members.map((member) => (
<SelectItem
@@ -412,11 +459,21 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="plain"
className="h-9"
onClick={() => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex
);
onChange(newPrincipals);
}}
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
@@ -29,6 +29,7 @@ import {
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api";
import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -63,7 +64,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
return (
<div>
<TableContainer>
<Table>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Alias</Th>
@@ -90,16 +91,73 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
{host.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span>
) : (
host.loginMappings.map(({ loginUser, allowedPrincipals }) => (
<div key={`${host.id}-${loginUser}`} className="mb-2">
<div className="text-mineshaft-200">{loginUser}</div>
{allowedPrincipals.usernames.map((username) => (
<div key={`${host.id}-${loginUser}-${username}`} className="ml-4">
└─ {username}
(() => {
const hostMappings = host.loginMappings.filter(
(m) => m.source !== LoginMappingSource.HOST_GROUP
);
const groupMappings = host.loginMappings.filter(
(m) => m.source === LoginMappingSource.HOST_GROUP
);
const hostLoginUserToPrincipals = hostMappings.reduce(
(acc, { loginUser, allowedPrincipals }) => {
acc[loginUser] = new Set(allowedPrincipals.usernames);
return acc;
},
{} as Record<string, Set<string>>
);
const entriesFromHost = hostMappings.map(
({ loginUser, allowedPrincipals }) => ({
loginUser,
source: LoginMappingSource.HOST,
usernames: allowedPrincipals.usernames
})
);
const entriesFromGroup = groupMappings
.map(({ loginUser, allowedPrincipals }) => {
const existing = hostLoginUserToPrincipals[loginUser] || new Set();
const filteredUsernames = allowedPrincipals.usernames.filter(
(u) => !existing.has(u)
);
return filteredUsernames.length > 0
? {
loginUser,
source: LoginMappingSource.HOST_GROUP,
usernames: filteredUsernames
}
: null;
})
.filter(Boolean) as {
loginUser: string;
source: LoginMappingSource;
usernames: string[];
}[];
return [...entriesFromHost, ...entriesFromGroup]
.sort((a, b) => a.loginUser.localeCompare(b.loginUser))
.map(({ loginUser, usernames, source }) => (
<div key={`${host.id}-${loginUser}-${source}`} className="mb-2">
<div className="text-mineshaft-200">
{loginUser}
{source === LoginMappingSource.HOST_GROUP && (
<span className="ml-2 text-xs text-mineshaft-400">
(inherited from host group)
</span>
)}
</div>
{usernames.map((username) => (
<div
key={`${host.id}-${loginUser}-${source}-${username}`}
className="ml-4"
>
└─ {username}
</div>
))}
</div>
))}
</div>
))
));
})()
)}
</Td>
<Td className="text-right align-middle">
@@ -139,7 +197,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faPencil} />}
>
Edit SSH host
Edit Host
</DropdownMenuItem>
)}
</ProjectPermissionCan>
@@ -161,7 +219,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete SSH host
Delete Host
</DropdownMenuItem>
)}
</ProjectPermissionCan>
@@ -1 +1,2 @@
export { SshHostGroupsSection } from "./SshHostGroupsSection";
export { SshHostsSection } from "./SshHostsSection";
+29
View File
@@ -108,6 +108,7 @@ import { Route as projectRoleDetailsBySlugPageRouteCertManagerImport } from './p
import { Route as certManagerPkiCollectionDetailsByIDPageRoutesImport } from './pages/cert-manager/PkiCollectionDetailsByIDPage/routes'
import { Route as projectMemberDetailsByIDPageRouteCertManagerImport } from './pages/project/MemberDetailsByIDPage/route-cert-manager'
import { Route as projectIdentityDetailsByIDPageRouteCertManagerImport } from './pages/project/IdentityDetailsByIDPage/route-cert-manager'
import { Route as sshSshHostGroupDetailsByIDPageRouteImport } from './pages/ssh/SshHostGroupDetailsByIDPage/route'
import { Route as sshSshCaByIDPageRouteImport } from './pages/ssh/SshCaByIDPage/route'
import { Route as secretManagerSecretDashboardPageRouteImport } from './pages/secret-manager/SecretDashboardPage/route'
import { Route as secretManagerIntegrationsSelectIntegrationAuthPageRouteImport } from './pages/secret-manager/integrations/SelectIntegrationAuthPage/route'
@@ -1022,6 +1023,13 @@ const projectIdentityDetailsByIDPageRouteCertManagerRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
const sshSshHostGroupDetailsByIDPageRouteRoute =
sshSshHostGroupDetailsByIDPageRouteImport.update({
id: '/ssh-host-groups/$sshHostGroupId',
path: '/ssh-host-groups/$sshHostGroupId',
getParentRoute: () => sshLayoutRoute,
} as any)
const sshSshCaByIDPageRouteRoute = sshSshCaByIDPageRouteImport.update({
id: '/ca/$caId',
path: '/ca/$caId',
@@ -2414,6 +2422,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof sshSshCaByIDPageRouteImport
parentRoute: typeof sshLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId': {
id: '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId'
path: '/ssh-host-groups/$sshHostGroupId'
fullPath: '/ssh/$projectId/ssh-host-groups/$sshHostGroupId'
preLoaderRoute: typeof sshSshHostGroupDetailsByIDPageRouteImport
parentRoute: typeof sshLayoutImport
}
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': {
id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId'
path: '/identities/$identityId'
@@ -3599,6 +3614,7 @@ interface sshLayoutRouteChildren {
sshSettingsPageRouteRoute: typeof sshSettingsPageRouteRoute
projectAccessControlPageRouteSshRoute: typeof projectAccessControlPageRouteSshRoute
sshSshCaByIDPageRouteRoute: typeof sshSshCaByIDPageRouteRoute
sshSshHostGroupDetailsByIDPageRouteRoute: typeof sshSshHostGroupDetailsByIDPageRouteRoute
projectIdentityDetailsByIDPageRouteSshRoute: typeof projectIdentityDetailsByIDPageRouteSshRoute
projectMemberDetailsByIDPageRouteSshRoute: typeof projectMemberDetailsByIDPageRouteSshRoute
projectRoleDetailsBySlugPageRouteSshRoute: typeof projectRoleDetailsBySlugPageRouteSshRoute
@@ -3611,6 +3627,8 @@ const sshLayoutRouteChildren: sshLayoutRouteChildren = {
sshSettingsPageRouteRoute: sshSettingsPageRouteRoute,
projectAccessControlPageRouteSshRoute: projectAccessControlPageRouteSshRoute,
sshSshCaByIDPageRouteRoute: sshSshCaByIDPageRouteRoute,
sshSshHostGroupDetailsByIDPageRouteRoute:
sshSshHostGroupDetailsByIDPageRouteRoute,
projectIdentityDetailsByIDPageRouteSshRoute:
projectIdentityDetailsByIDPageRouteSshRoute,
projectMemberDetailsByIDPageRouteSshRoute:
@@ -3921,6 +3939,7 @@ export interface FileRoutesByFullPath {
'/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
'/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute
'/ssh/$projectId/ca/$caId': typeof sshSshCaByIDPageRouteRoute
'/ssh/$projectId/ssh-host-groups/$sshHostGroupId': typeof sshSshHostGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4098,6 +4117,7 @@ export interface FileRoutesByTo {
'/secret-manager/$projectId/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
'/secret-manager/$projectId/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute
'/ssh/$projectId/ca/$caId': typeof sshSshCaByIDPageRouteRoute
'/ssh/$projectId/ssh-host-groups/$sshHostGroupId': typeof sshSshHostGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4294,6 +4314,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth': typeof secretManagerIntegrationsSelectIntegrationAuthPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug': typeof secretManagerSecretDashboardPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId': typeof sshSshCaByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId': typeof sshSshHostGroupDetailsByIDPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4482,6 +4503,7 @@ export interface FileRouteTypes {
| '/secret-manager/$projectId/integrations/select-integration-auth'
| '/secret-manager/$projectId/secrets/$envSlug'
| '/ssh/$projectId/ca/$caId'
| '/ssh/$projectId/ssh-host-groups/$sshHostGroupId'
| '/cert-manager/$projectId/identities/$identityId'
| '/cert-manager/$projectId/members/$membershipId'
| '/cert-manager/$projectId/pki-collections/$collectionId'
@@ -4658,6 +4680,7 @@ export interface FileRouteTypes {
| '/secret-manager/$projectId/integrations/select-integration-auth'
| '/secret-manager/$projectId/secrets/$envSlug'
| '/ssh/$projectId/ca/$caId'
| '/ssh/$projectId/ssh-host-groups/$sshHostGroupId'
| '/cert-manager/$projectId/identities/$identityId'
| '/cert-manager/$projectId/members/$membershipId'
| '/cert-manager/$projectId/pki-collections/$collectionId'
@@ -4852,6 +4875,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/integrations/select-integration-auth'
| '/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug'
| '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId'
| '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/members/$membershipId'
| '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId'
@@ -5392,6 +5416,7 @@ export const routeTree = rootRoute
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/settings",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/access-management",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ca/$caId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/identities/$identityId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/members/$membershipId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/roles/$roleSlug"
@@ -5629,6 +5654,10 @@ export const routeTree = rootRoute
"filePath": "ssh/SshCaByIDPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout"
},
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId": {
"filePath": "ssh/SshHostGroupDetailsByIDPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout"
},
"/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId": {
"filePath": "project/IdentityDetailsByIDPage/route-cert-manager.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout"
+1
View File
@@ -315,6 +315,7 @@ const sshRoutes = route("/ssh/$projectId", [
route("/certificates", "ssh/SshCertsPage/route.tsx"),
route("/cas", "ssh/SshCasPage/route.tsx"),
route("/ca/$caId", "ssh/SshCaByIDPage/route.tsx"),
route("/ssh-host-groups/$sshHostGroupId", "ssh/SshHostGroupDetailsByIDPage/route.tsx"),
route("/settings", "ssh/SettingsPage/route.tsx"),
route("/access-management", "project/AccessControlPage/route-ssh.tsx"),
route("/roles/$roleSlug", "project/RoleDetailsBySlugPage/route-ssh.tsx"),