Begin work on ssh host groups

This commit is contained in:
Tuan Dang
2025-04-29 13:39:24 -07:00
parent 6a26a11cbb
commit a9a16c9bd1
50 changed files with 2473 additions and 122 deletions
+4
View File
@@ -298,6 +298,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"
),
SshGroupDetailsByIDPage: setRoute(
"/ssh/$projectId/ssh-groups/$groupId",
"/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/groups/$groupId"
)
},
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
@@ -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";
@@ -0,0 +1,2 @@
export { useCreateSshHostGroup, useDeleteSshHostGroup, useUpdateSshHostGroup } from "./mutations";
export { useGetSshHostGroupById } from "./queries";
@@ -0,0 +1,61 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/query-keys";
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 }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
}
});
};
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 }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
}
});
};
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 }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId)
});
}
});
};
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TSshHostGroup } from "./types";
export const sshHostGroupKeys = {
getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"]
};
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)
});
};
@@ -0,0 +1,37 @@
export type TSshHostGroup = {
id: string;
projectId: string;
name: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
};
export type TCreateSshHostGroupDTO = {
projectId: string;
name: string;
loginMappings: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
};
export type TUpdateSshHostGroupDTO = {
sshHostGroupId: string;
name?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: {
usernames: string[];
};
}[];
};
export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string;
};
@@ -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 { ProjectSlackConfig } from "../workflowIntegrations/types";
@@ -870,6 +871,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[] }>(
`/api/v2/workspace/${projectId}/ssh-host-groups`
);
return groups;
},
enabled: Boolean(projectId)
});
};
export const useListWorkspaceSshCertificateTemplates = (projectId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId),
@@ -60,6 +60,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,
@@ -0,0 +1,147 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate } from "@tanstack/react-router";
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";
const Page = () => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const projectId = currentWorkspace?.id || "";
const groupId = useParams({
from: ROUTE_PATHS.Ssh.SshGroupDetailsByIDPage.id,
select: (el) => el.groupId
});
const { data } = useGetSshHostGroupById(groupId);
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
})
}
disabled={!isAllowed}
>
Delete SSH Group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<SshHostGroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<div className="w-full">
<SshHostsSection groupId={groupId} />
</div>
</div>
</div>
)}
<SshHostGroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteSshHostGroup.isOpen}
title="Are you sure want to remove the SSH group from the project?"
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshGroupSubmit((popUp?.deleteSshHostGroup?.data as { groupId: string })?.groupId)
}
/>
</div>
);
};
export const SshGroupDetailsByIDPage = () => {
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,9 @@
import { createFileRoute } from '@tanstack/react-router'
import { SshGroupDetailsByIDPage } from './SshGroupDetailsByIDPage'
export const Route = createFileRoute(
'/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId',
)({
component: SshGroupDetailsByIDPage,
})
@@ -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,175 @@
import { useState } from "react";
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useDebounce, useResetPageHelper } from "@app/hooks";
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
popUp: UsePopUpState<["sshHostGroupHosts"]>;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["sshHostGroupHosts"]>,
state?: boolean
) => void;
};
export const SshHostGroupHostsModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const [debouncedSearch] = useDebounce(searchMemberFilter);
const popUpData = popUp?.addGroupMembers?.data as {
groupId: string;
slug: string;
};
const offset = (page - 1) * perPage;
const { data, isPending } = useListGroupUsers({
id: popUpData?.groupId,
groupSlug: popUpData?.slug,
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedUsers.NON_MEMBERS
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
const handleAddHost = async (hostId: string) => {
try {
if (!popUpData?.slug) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
// await addUserToGroupMutateAsync({
// groupId: popUpData.groupId,
// username,
// slug: popUpData.slug
// });
createNotification({
text: "Successfully assigned host to the SSH host group",
type: "success"
});
} catch {
createNotification({
text: "Failed to assign host to the SSH host group",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.sshHostGroupHosts?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("sshHostGroupHosts", isOpen);
}}
>
<ModalContent title="Add Hosts to Group">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
{!isPending &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddMember(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.users?.length && (
<EmptyState
title={
debouncedSearch ? "No users match search" : "All users are already in the group"
}
icon={faUsers}
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
};
@@ -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(),
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,74 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } 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 { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHostGroup",
"deleteSshHostGroup"
] as const);
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.SshHosts}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshHostGroup")}
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?"
onChange={(isOpen) => handlePopUpToggle("deleteSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostGroupSubmit(
(popUp?.deleteSshHostGroup?.data as { sshHostGroupId: string })?.sshHostGroupId
)
}
/>
</div>
);
};
@@ -0,0 +1,157 @@
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</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-groups/$sshGroupId` as const,
params: {
projectId: currentWorkspace.id,
sshGroupId: group.id
}
})
}
>
<Td>{group.name}</Td>
<Td>-</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 SSH host group
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SshHosts}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteSshHostGroup", {
sshHostGroupId: group.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete SSH 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>
);
};
@@ -63,7 +63,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
return (
<div>
<TableContainer>
<Table>
<Table className="w-full table-fixed">
<THead>
<Tr>
<Th>Alias</Th>
@@ -1 +1,2 @@
export { SshHostGroupsSection } from "./SshHostGroupsSection";
export { SshHostsSection } from "./SshHostsSection";
+28
View File
@@ -107,6 +107,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 sshSshGroupDetailsByIDPageRouteImport } from './pages/ssh/SshGroupDetailsByIDPage/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'
@@ -1001,6 +1002,13 @@ const projectIdentityDetailsByIDPageRouteCertManagerRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
const sshSshGroupDetailsByIDPageRouteRoute =
sshSshGroupDetailsByIDPageRouteImport.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',
@@ -2379,6 +2387,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 sshSshGroupDetailsByIDPageRouteImport
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'
@@ -3546,6 +3561,7 @@ interface sshLayoutRouteChildren {
sshSettingsPageRouteRoute: typeof sshSettingsPageRouteRoute
projectAccessControlPageRouteSshRoute: typeof projectAccessControlPageRouteSshRoute
sshSshCaByIDPageRouteRoute: typeof sshSshCaByIDPageRouteRoute
sshSshGroupDetailsByIDPageRouteRoute: typeof sshSshGroupDetailsByIDPageRouteRoute
projectIdentityDetailsByIDPageRouteSshRoute: typeof projectIdentityDetailsByIDPageRouteSshRoute
projectMemberDetailsByIDPageRouteSshRoute: typeof projectMemberDetailsByIDPageRouteSshRoute
projectRoleDetailsBySlugPageRouteSshRoute: typeof projectRoleDetailsBySlugPageRouteSshRoute
@@ -3558,6 +3574,7 @@ const sshLayoutRouteChildren: sshLayoutRouteChildren = {
sshSettingsPageRouteRoute: sshSettingsPageRouteRoute,
projectAccessControlPageRouteSshRoute: projectAccessControlPageRouteSshRoute,
sshSshCaByIDPageRouteRoute: sshSshCaByIDPageRouteRoute,
sshSshGroupDetailsByIDPageRouteRoute: sshSshGroupDetailsByIDPageRouteRoute,
projectIdentityDetailsByIDPageRouteSshRoute:
projectIdentityDetailsByIDPageRouteSshRoute,
projectMemberDetailsByIDPageRouteSshRoute:
@@ -3866,6 +3883,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 sshSshGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4042,6 +4060,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 sshSshGroupDetailsByIDPageRouteRoute
'/cert-manager/$projectId/identities/$identityId': typeof projectIdentityDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/members/$membershipId': typeof projectMemberDetailsByIDPageRouteCertManagerRoute
'/cert-manager/$projectId/pki-collections/$collectionId': typeof certManagerPkiCollectionDetailsByIDPageRoutesRoute
@@ -4236,6 +4255,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 sshSshGroupDetailsByIDPageRouteRoute
'/_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
@@ -4422,6 +4442,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'
@@ -4597,6 +4618,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'
@@ -4789,6 +4811,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'
@@ -5321,6 +5344,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"
@@ -5554,6 +5578,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/SshGroupDetailsByIDPage/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
@@ -312,6 +312,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/SshGroupDetailsByIDPage/route.tsx"),
route("/settings", "ssh/SettingsPage/route.tsx"),
route("/access-management", "project/AccessControlPage/route-ssh.tsx"),
route("/roles/$roleSlug", "project/RoleDetailsBySlugPage/route-ssh.tsx"),