diff --git a/backend/src/ee/routes/v1/ssh-host-group-router.ts b/backend/src/ee/routes/v1/ssh-host-group-router.ts index 0e7026c90..caf6e8758 100644 --- a/backend/src/ee/routes/v1/ssh-host-group-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-group-router.ts @@ -1,7 +1,9 @@ import { z } from "zod"; -import { sanitizedSshHost, loginMappingSchema } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; +import { EHostGroupMembershipFilter } from "@app/ee/services/ssh-host-group/ssh-host-group-types"; import { SSH_HOST_GROUPS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -35,17 +37,17 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.GET_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.GET_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name + } + } + }); return sshHostGroup; } @@ -80,24 +82,18 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - // TODO: audit logs - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.CREATE_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname, - // alias: host.alias ?? null, - // userCertTtl: host.userCertTtl, - // hostCertTtl: host.hostCertTtl, - // loginMappings: host.loginMappings, - // userSshCaId: host.userSshCaId, - // hostSshCaId: host.hostSshCaId - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.CREATE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings + } + } + }); return sshHostGroup; } @@ -135,23 +131,18 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.UPDATE_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname, - // alias: host.alias, - // userCertTtl: host.userCertTtl, - // hostCertTtl: host.hostCertTtl, - // loginMappings: host.loginMappings, - // userSshCaId: host.userSshCaId, - // hostSshCaId: host.hostSshCaId - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.UPDATE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name, + loginMappings: sshHostGroup.loginMappings + } + } + }); return sshHostGroup; } @@ -183,18 +174,17 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - // TODO: audit log - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.DELETE_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.DELETE_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + name: sshHostGroup.name + } + } + }); return sshHostGroup; } @@ -211,20 +201,30 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => sshHostGroupId: z.string().describe(SSH_HOST_GROUPS.GET.sshHostGroupId) }), querystring: z.object({ - offset: z.coerce.number().min(0).max(100).default(0).describe(SSH_HOST_GROUPS.LIST_HOSTS.offset), - limit: z.coerce.number().min(1).max(100).default(10).describe(SSH_HOST_GROUPS.LIST_HOSTS.limit) + filter: z.nativeEnum(EHostGroupMembershipFilter).optional().describe(SSH_HOST_GROUPS.GET.filter) }), response: { 200: z.object({ - hosts: z.array(sanitizedSshHost), + hosts: sanitizedSshHost + .pick({ + id: true, + hostname: true, + alias: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) + ) + .array(), totalCount: z.number() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - console.log("list hosts in group pre"); - const { hosts, totalCount } = await server.services.sshHostGroup.listSshHostGroupHosts({ + const { sshHostGroup, hosts, totalCount } = await server.services.sshHostGroup.listSshHostGroupHosts({ sshHostGroupId: req.params.sshHostGroupId, actor: req.permission.type, actorId: req.permission.id, @@ -232,20 +232,17 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, ...req.query }); - console.log("list hosts in group post"); - // TODO: audit logs - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.GET_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHostGroup.projectId, + event: { + type: EventType.GET_SSH_HOST_GROUP_HOSTS, + metadata: { + sshHostGroupId: req.params.sshHostGroupId + } + } + }); return { hosts, totalCount }; } @@ -270,9 +267,7 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - console.log("add host to group pre"); - - const host = await server.services.sshHostGroup.addHostToSshHostGroup({ + const { sshHostGroup, sshHost } = await server.services.sshHostGroup.addHostToSshHostGroup({ sshHostGroupId: req.params.sshHostGroupId, hostId: req.params.hostId, actor: req.permission.type, @@ -281,22 +276,20 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - console.log("add host to group post"); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHost.projectId, + event: { + type: EventType.ADD_HOST_TO_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + sshHostId: sshHost.id, + hostname: sshHost.hostname + } + } + }); - // TODO: audit logs - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.GET_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname - // } - // } - // }); - - return host; + return sshHost; } }); @@ -319,9 +312,7 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - console.log("remove host from group pre"); - - const host = await server.services.sshHostGroup.removeHostFromSshHostGroup({ + const { sshHostGroup, sshHost } = await server.services.sshHostGroup.removeHostFromSshHostGroup({ sshHostGroupId: req.params.sshHostGroupId, hostId: req.params.hostId, actor: req.permission.type, @@ -330,22 +321,20 @@ export const registerSshHostGroupRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - console.log("remove host from group post"); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: sshHost.projectId, + event: { + type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP, + metadata: { + sshHostGroupId: sshHostGroup.id, + sshHostId: sshHost.id, + hostname: sshHost.hostname + } + } + }); - // TODO: audit logs - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: host.projectId, - // event: { - // type: EventType.GET_SSH_HOST, - // metadata: { - // sshHostId: host.id, - // hostname: host.hostname - // } - // } - // }); - - return host; + return sshHost; } }); }; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index b6527f3f4..cd85cc22b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -12,6 +12,7 @@ import { import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; +import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; import { AsymmetricKeyAlgorithm, SigningAlgorithm } from "@app/lib/crypto/sign/types"; import { TProjectPermission } from "@app/lib/types"; @@ -191,12 +192,19 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + GET_SSH_HOST = "get-ssh-host", CREATE_SSH_HOST = "create-ssh-host", UPDATE_SSH_HOST = "update-ssh-host", DELETE_SSH_HOST = "delete-ssh-host", - GET_SSH_HOST = "get-ssh-host", ISSUE_SSH_HOST_USER_CERT = "issue-ssh-host-user-cert", ISSUE_SSH_HOST_HOST_CERT = "issue-ssh-host-host-cert", + GET_SSH_HOST_GROUP = "get-ssh-host-group", + CREATE_SSH_HOST_GROUP = "create-ssh-host-group", + UPDATE_SSH_HOST_GROUP = "update-ssh-host-group", + DELETE_SSH_HOST_GROUP = "delete-ssh-host-group", + GET_SSH_HOST_GROUP_HOSTS = "get-ssh-host-group-hosts", + ADD_HOST_TO_SSH_HOST_GROUP = "add-host-to-ssh-host-group", + REMOVE_HOST_FROM_SSH_HOST_GROUP = "remove-host-from-ssh-host-group", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -1499,12 +1507,7 @@ interface CreateSshHost { alias: string | null; userCertTtl: string; hostCertTtl: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; userSshCaId: string; hostSshCaId: string; }; @@ -1518,12 +1521,7 @@ interface UpdateSshHost { alias?: string | null; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; userSshCaId?: string; hostSshCaId?: string; }; @@ -1567,6 +1565,65 @@ interface IssueSshHostHostCert { }; } +interface GetSshHostGroupEvent { + type: EventType.GET_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface CreateSshHostGroupEvent { + type: EventType.CREATE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + loginMappings: TLoginMapping[]; + }; +} + +interface UpdateSshHostGroupEvent { + type: EventType.UPDATE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name?: string; + loginMappings?: TLoginMapping[]; + }; +} + +interface DeleteSshHostGroupEvent { + type: EventType.DELETE_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + name: string; + }; +} + +interface GetSshHostGroupHostsEvent { + type: EventType.GET_SSH_HOST_GROUP_HOSTS; + metadata: { + sshHostGroupId: string; + }; +} + +interface AddHostToSshHostGroupEvent { + type: EventType.ADD_HOST_TO_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + sshHostId: string; + hostname: string; + }; +} + +interface RemoveHostFromSshHostGroupEvent { + type: EventType.REMOVE_HOST_FROM_SSH_HOST_GROUP; + metadata: { + sshHostGroupId: string; + sshHostId: string; + hostname: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -2753,6 +2810,13 @@ export type Event = | CreateAppConnectionEvent | UpdateAppConnectionEvent | DeleteAppConnectionEvent + | GetSshHostGroupEvent + | CreateSshHostGroupEvent + | UpdateSshHostGroupEvent + | DeleteSshHostGroupEvent + | GetSshHostGroupHostsEvent + | AddHostToSshHostGroupEvent + | RemoveHostFromSshHostGroupEvent | CreateSharedSecretEvent | DeleteSharedSecretEvent | ReadSharedSecretEvent diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts index e9e652591..aaaefadad 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts @@ -6,6 +6,8 @@ import { DatabaseError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { ormify } from "@app/lib/knex"; +import { EHostGroupMembershipFilter } from "./ssh-host-group-types"; + export type TSshHostGroupDALFactory = ReturnType; export const sshHostGroupDALFactory = (db: TDbClient) => { @@ -13,6 +15,7 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { const findSshHostGroupsWithLoginMappings = async (projectId: string, tx?: Knex) => { try { + // First, get all the SSH host groups with their login mappings const rows = await (tx || db.replicaNode())(TableName.SshHostGroup) .leftJoin( TableName.SshHostLoginUser, @@ -38,6 +41,25 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { const hostsGrouped = groupBy(rows, (r) => r.sshHostGroupId); + const hostGroupIds = Object.keys(hostsGrouped); + + type HostCountRow = { + sshHostGroupId: string; + host_count: string; + }; + + const hostCountsQuery = (await (tx || + db + .replicaNode()(TableName.SshHostGroupMembership) + .select(`${TableName.SshHostGroupMembership}.sshHostGroupId`, db.raw(`count(*) as host_count`)) + .whereIn(`${TableName.SshHostGroupMembership}.sshHostGroupId`, hostGroupIds) + .groupBy(`${TableName.SshHostGroupMembership}.sshHostGroupId`))) as HostCountRow[]; + + const hostCountsMap = hostCountsQuery.reduce>((acc, { sshHostGroupId, host_count }) => { + acc[sshHostGroupId] = Number(host_count); + return acc; + }, {}); + return Object.values(hostsGrouped).map((hostRows) => { const { sshHostGroupId, name } = hostRows[0]; const loginMappingGrouped = groupBy( @@ -54,7 +76,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { id: sshHostGroupId, projectId, name, - loginMappings + loginMappings, + hostCount: hostCountsMap[sshHostGroupId] ?? 0 }; }); } catch (error) { @@ -116,21 +139,40 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { const findAllSshHostsInGroup = async ({ sshHostGroupId, offset = 0, - limit + limit, + filter }: { sshHostGroupId: string; offset?: number; limit?: number; + filter?: EHostGroupMembershipFilter; }) => { try { + const sshHostGroup = await db + .replicaNode()(TableName.SshHostGroup) + .where(`${TableName.SshHostGroup}.id`, sshHostGroupId) + .select("projectId") + .first(); + + if (!sshHostGroup) { + throw new Error(`SSH host group with ID ${sshHostGroupId} not found`); + } + const query = db - .replicaNode()(TableName.SshHostGroupMembership) - .where(`${TableName.SshHostGroupMembership}.sshHostGroupId`, sshHostGroupId) - .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .replicaNode()(TableName.SshHost) + .where(`${TableName.SshHost}.projectId`, sshHostGroup.projectId) + .leftJoin(TableName.SshHostGroupMembership, (bd) => { + bd.on(`${TableName.SshHostGroupMembership}.sshHostId`, "=", `${TableName.SshHost}.id`).andOn( + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + "=", + db.raw("?", [sshHostGroupId]) + ); + }) .select( db.ref("id").withSchema(TableName.SshHost), db.ref("hostname").withSchema(TableName.SshHost), db.ref("alias").withSchema(TableName.SshHost), + db.ref("sshHostGroupId").withSchema(TableName.SshHostGroupMembership), db.ref("createdAt").withSchema(TableName.SshHostGroupMembership).as("joinedGroupAt"), db.raw(`count(*) OVER() as total_count`) ) @@ -141,13 +183,28 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { void query.limit(limit); } + if (filter) { + switch (filter) { + case EHostGroupMembershipFilter.GROUP_MEMBERS: + void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is not", null); + break; + case EHostGroupMembershipFilter.NON_GROUP_MEMBERS: + void query.andWhere(`${TableName.SshHostGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + } + const hosts = await query; return { - hosts: hosts.map(({ id, hostname, alias }) => ({ + hosts: hosts.map(({ id, hostname, alias, sshHostGroupId: memberGroupId, joinedGroupAt }) => ({ id, hostname, - alias + alias, + isPartOfGroup: !!memberGroupId, + joinedGroupAt })), // @ts-expect-error col select is raw and not strongly typed totalCount: Number(hosts?.[0]?.total_count ?? 0) diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts index 4627e2e27..2d3580c2e 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -226,7 +226,8 @@ export const sshHostGroupServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + filter }: TListSshHostGroupHostsDTO) => { const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); @@ -242,10 +243,8 @@ export const sshHostGroupServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); - // TODO: check - const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId }); - console.log("hosts: ", hosts); - return { hosts, totalCount }; + const { hosts, totalCount } = await sshHostGroupDAL.findAllSshHostsInGroup({ sshHostGroupId, filter }); + return { sshHostGroup, hosts, totalCount }; }; const addHostToSshHostGroup = async ({ @@ -259,14 +258,14 @@ export const sshHostGroupServiceFactory = ({ const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); - const host = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); - if (!host) { + const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!sshHost) { throw new NotFoundError({ message: `SSH host with ID ${hostId} not found` }); } - if (sshHostGroup.projectId !== host.projectId) { + if (sshHostGroup.projectId !== sshHost.projectId) { throw new NotFoundError({ message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}` }); @@ -286,7 +285,7 @@ export const sshHostGroupServiceFactory = ({ await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId }); - return host; + return { sshHostGroup, sshHost }; }; const removeHostFromSshHostGroup = async ({ @@ -297,17 +296,21 @@ export const sshHostGroupServiceFactory = ({ actorAuthMethod, actorOrgId }: TRemoveHostFromSshHostGroupDTO) => { + console.log("removeHostFromSshHostGroup args: ", { + sshHostGroupId, + hostId + }); const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); - const host = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); - if (!host) { + const sshHost = await sshHostDAL.findSshHostByIdWithLoginMappings(hostId); + if (!sshHost) { throw new NotFoundError({ message: `SSH host with ID ${hostId} not found` }); } - if (sshHostGroup.projectId !== host.projectId) { + if (sshHostGroup.projectId !== sshHost.projectId) { throw new NotFoundError({ message: `SSH host with ID ${hostId} not found in project ${sshHostGroup.projectId}` }); @@ -336,9 +339,13 @@ export const sshHostGroupServiceFactory = ({ }); } + console.log("boom: ", { + sshHostGroupMembership + }); + await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id); - return host; + return { sshHostGroup, sshHost }; }; return { diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts index 6bc895a73..e5c78cce4 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts @@ -1,13 +1,9 @@ +import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; import { TGenericPermission, TProjectPermission } from "@app/lib/types"; export type TCreateSshHostGroupDTO = { name: string; - loginMappings: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings: TLoginMapping[]; } & TProjectPermission; export type TUpdateSshHostGroupDTO = { @@ -30,6 +26,7 @@ export type TDeleteSshHostGroupDTO = { } & TGenericPermission; export type TListSshHostGroupHostsDTO = { sshHostGroupId: string; + filter?: EHostGroupMembershipFilter; } & TGenericPermission; export type TAddHostToSshHostGroupDTO = { @@ -41,3 +38,8 @@ export type TRemoveHostFromSshHostGroupDTO = { sshHostGroupId: string; hostId: string; } & TGenericPermission; + +export enum EHostGroupMembershipFilter { + GROUP_MEMBERS = "group-members", + NON_GROUP_MEMBERS = "non-group-members" +} diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts index 08da764fc..7440300df 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -9,7 +9,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; export type TListSshHostsDTO = Omit; -type LoginMapping = { +export type TLoginMapping = { loginUser: string; allowedPrincipals: { usernames: string[]; @@ -21,7 +21,7 @@ export type TCreateSshHostDTO = { alias?: string; userCertTtl: string; hostCertTtl: string; - loginMappings: LoginMapping[]; + loginMappings: TLoginMapping[]; userSshCaId?: string; hostSshCaId?: string; } & TProjectPermission; @@ -32,7 +32,7 @@ export type TUpdateSshHostDTO = { alias?: string; userCertTtl?: string; hostCertTtl?: string; - loginMappings?: LoginMapping[]; + loginMappings?: TLoginMapping[]; } & Omit; export type TGetSshHostDTO = { @@ -54,7 +54,7 @@ export type TIssueSshHostHostCertDTO = { } & Omit; type BaseCreateSshLoginMappingsDTO = { - loginMappings: LoginMapping[]; + loginMappings: TLoginMapping[]; sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e861900d0..6cfa9a1e8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1387,7 +1387,8 @@ export const SSH_CERTIFICATE_TEMPLATES = { export const SSH_HOST_GROUPS = { GET: { - sshHostGroupId: "The ID of the SSH host group to get." + sshHostGroupId: "The ID of the SSH host group to get.", + filter: "The filter to apply to the SSH hosts in the SSH host group." }, CREATE: { projectId: "The ID of the project to create the SSH host group in.", diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index d527720a2..d18034d0b 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -666,7 +666,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ groups: z.array( sanitizedSshHostGroup.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: z.array(loginMappingSchema), + hostCount: z.number() }) ) }) diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index a74974328..aea43a52b 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -299,9 +299,9 @@ export const ROUTE_PATHS = Object.freeze({ "/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" + SshHostGroupDetailsByIDPage: setRoute( + "/ssh/$projectId/ssh-host-groups/$sshHostGroupId", + "/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout/ssh-host-groups/$sshHostGroupId" ) }, Public: { diff --git a/frontend/src/hooks/api/sshHostGroup/index.tsx b/frontend/src/hooks/api/sshHostGroup/index.tsx index d78ec3986..b131e1611 100644 --- a/frontend/src/hooks/api/sshHostGroup/index.tsx +++ b/frontend/src/hooks/api/sshHostGroup/index.tsx @@ -1,2 +1,8 @@ -export { useCreateSshHostGroup, useDeleteSshHostGroup, useUpdateSshHostGroup } from "./mutations"; -export { useGetSshHostGroupById } from "./queries"; +export { + useAddHostToSshHostGroup, + useCreateSshHostGroup, + useDeleteSshHostGroup, + useRemoveHostFromSshHostGroup, + useUpdateSshHostGroup +} from "./mutations"; +export { useGetSshHostGroupById, useListSshHostGroupHosts } from "./queries"; diff --git a/frontend/src/hooks/api/sshHostGroup/mutations.tsx b/frontend/src/hooks/api/sshHostGroup/mutations.tsx index 7a5b3f168..2afdaa94c 100644 --- a/frontend/src/hooks/api/sshHostGroup/mutations.tsx +++ b/frontend/src/hooks/api/sshHostGroup/mutations.tsx @@ -3,6 +3,7 @@ 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, @@ -17,10 +18,13 @@ export const useCreateSshHostGroup = () => { const { data: hostGroup } = await apiRequest.post("/api/v1/ssh/host-groups", body); return hostGroup; }, - onSuccess: ({ projectId }) => { + onSuccess: ({ projectId, id }) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(id) + }); } }); }; @@ -35,10 +39,13 @@ export const useUpdateSshHostGroup = () => { ); return hostGroup; }, - onSuccess: ({ projectId }) => { + onSuccess: ({ projectId }, { sshHostGroupId }) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) + }); } }); }; @@ -52,10 +59,41 @@ export const useDeleteSshHostGroup = () => { ); return hostGroup; }, - onSuccess: ({ projectId }) => { + onSuccess: ({ projectId }, { sshHostGroupId }) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId) }); + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.getSshHostGroupById(sshHostGroupId) + }); + } + }); +}; + +export const useAddHostToSshHostGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + 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({ + mutationFn: async ({ sshHostGroupId, sshHostId }) => { + await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`); + }, + onSuccess: (_, { sshHostGroupId }) => { + queryClient.invalidateQueries({ + queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId) + }); } }); }; diff --git a/frontend/src/hooks/api/sshHostGroup/queries.tsx b/frontend/src/hooks/api/sshHostGroup/queries.tsx index 1ba54e445..8be42dd7b 100644 --- a/frontend/src/hooks/api/sshHostGroup/queries.tsx +++ b/frontend/src/hooks/api/sshHostGroup/queries.tsx @@ -2,10 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TSshHostGroup } from "./types"; +import { EHostGroupMembershipFilter, TListSshHostGroupHostsResponse, TSshHostGroup } from "./types"; export const sshHostGroupKeys = { - getSshHostGroupById: (sshHostGroupId: string) => [{ sshHostGroupId }, "ssh-host-group"] + 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) => { @@ -20,3 +30,31 @@ export const useGetSshHostGroupById = (sshHostGroupId: string) => { 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( + `/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`, + { + params + } + ); + return data; + }, + enabled: Boolean(sshHostGroupId), + staleTime: 0, + gcTime: 0 + }); +}; diff --git a/frontend/src/hooks/api/sshHostGroup/types.ts b/frontend/src/hooks/api/sshHostGroup/types.ts index c7d1bf80c..70604e553 100644 --- a/frontend/src/hooks/api/sshHostGroup/types.ts +++ b/frontend/src/hooks/api/sshHostGroup/types.ts @@ -1,3 +1,5 @@ +import { TSshHost } from "../sshHost/types"; + export type TSshHostGroup = { id: string; projectId: string; @@ -35,3 +37,13 @@ export type TUpdateSshHostGroupDTO = { export type TDeleteSshHostGroupDTO = { sshHostGroupId: string; }; + +export type TListSshHostGroupHostsResponse = { + hosts: TSshHost[]; + totalCount: number; +}; + +export enum EHostGroupMembershipFilter { + GROUP_MEMBERS = "group-members", + NON_GROUP_MEMBERS = "non-group-members" +} diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 9b1788c96..6a20fd9d5 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -877,7 +877,7 @@ export const useListWorkspaceSshHostGroups = (projectId: string) => { queryFn: async () => { const { data: { groups } - } = await apiRequest.get<{ groups: TSshHostGroup[] }>( + } = await apiRequest.get<{ groups: (TSshHostGroup & { hostCount: number })[] }>( `/api/v2/workspace/${projectId}/ssh-host-groups` ); return groups; diff --git a/frontend/src/pages/ssh/SshGroupDetailsByIDPage/route.tsx b/frontend/src/pages/ssh/SshGroupDetailsByIDPage/route.tsx deleted file mode 100644 index 37bd30551..000000000 --- a/frontend/src/pages/ssh/SshGroupDetailsByIDPage/route.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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, -}) diff --git a/frontend/src/pages/ssh/SshGroupDetailsByIDPage/SshGroupDetailsByIDPage.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx similarity index 85% rename from frontend/src/pages/ssh/SshGroupDetailsByIDPage/SshGroupDetailsByIDPage.tsx rename to frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx index 1214058d9..2c4f6c839 100644 --- a/frontend/src/pages/ssh/SshGroupDetailsByIDPage/SshGroupDetailsByIDPage.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/SshHostGroupDetailsByIDPage.tsx @@ -1,6 +1,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -20,15 +21,18 @@ 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 groupId = useParams({ - from: ROUTE_PATHS.Ssh.SshGroupDetailsByIDPage.id, - select: (el) => el.groupId + const sshHostGroupId = useParams({ + from: ROUTE_PATHS.Ssh.SshHostGroupDetailsByIDPage.id, + select: (el) => el.sshHostGroupId }); - const { data } = useGetSshHostGroupById(groupId); + const { data } = useGetSshHostGroupById(sshHostGroupId); const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); @@ -105,10 +109,13 @@ const Page = () => {
- +
- +
@@ -127,7 +134,7 @@ const Page = () => { ); }; -export const SshGroupDetailsByIDPage = () => { +export const SshHostGroupDetailsByIDPage = () => { const { t } = useTranslation(); return ( <> diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx new file mode 100644 index 000000000..150f7cbf4 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/AddHostGroupMemberModal.tsx @@ -0,0 +1,126 @@ +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 } = 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 ( + { + handlePopUpToggle("addHostGroupMembers", isOpen); + }} + > + + + + + + + + + + + {isPending && } + {!isPending && + data?.hosts?.map((host) => { + return ( + + + + + + ); + })} + +
AliasHostname +
{host.alias ?? "-"}{host.hostname} + + {(isAllowed) => ( + + )} + +
+ {!isPending && !data?.hosts?.length && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx new file mode 100644 index 000000000..80f1f8d46 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupDetailsSection.tsx @@ -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({ + initialState: "Copy ID to clipboard" + }); + + const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId); + + return sshHostGroup ? ( +
+
+

SSH Host Group Details

+ + {(isAllowed) => { + return ( + + { + e.stopPropagation(); + handlePopUpOpen("sshHostGroup", { + sshHostGroupId: sshHostGroup.id + }); + }} + > + + + + ); + }} + +
+
+
+

SSH Host Group ID

+
+

{sshHostGroup.id}

+
+ + { + navigator.clipboard.writeText(sshHostGroup.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Name

+

{sshHostGroup.name}

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx new file mode 100644 index 000000000..05e047dc6 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx @@ -0,0 +1,89 @@ +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 { DeleteActionModal, IconButton } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } 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 { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "removeHostFromSshHostGroup", + "addHostGroupMembers" + ] as const); + + const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup(); + + 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 ( +
+
+

SSH Hosts

+ + {(isAllowed) => ( + + handlePopUpOpen("addHostGroupMembers", { + sshHostGroupId + }) + } + isDisabled={!isAllowed} + > + + + )} + +
+
+ +
+ + handlePopUpToggle("removeHostFromSshHostGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshHostSubmit( + (popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId + ) + } + /> +
+ ); +}; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx new file mode 100644 index 000000000..056a86f85 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx @@ -0,0 +1,94 @@ +import { faServer, faUserMinus } 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 ( +
+ + + + + + + + + + {isPending && } + {!isPending && + data?.hosts.map((host) => { + return ( + + + + + + ); + })} + +
AliasHostname +
{host.alias ?? "-"}{host.hostname} + + {(isAllowed) => ( + + + handlePopUpOpen("removeHostFromSshHostGroup", { + sshHostId: host.id, + hostname: host.hostname + }) + } + variant="plain" + colorSchema="danger" + > + + + + )} + +
+ {!isPending && !data?.hosts?.length && ( + + )} +
+
+ ); +}; + +export default SshHostGroupHostsTable; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx new file mode 100644 index 000000000..ff25edf77 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/index.tsx @@ -0,0 +1,3 @@ +export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection"; +export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection"; +export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable"; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx new file mode 100644 index 000000000..2a7c1cde6 --- /dev/null +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/route.tsx @@ -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 +}); diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupHostsModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupHostsModal.tsx deleted file mode 100644 index 75c94ac1b..000000000 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupHostsModal.tsx +++ /dev/null @@ -1,175 +0,0 @@ -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 ( - { - handlePopUpToggle("sshHostGroupHosts", isOpen); - }} - > - - setSearchMemberFilter(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> - - - - - - - - - {isPending && } - {!isPending && - data?.users?.map(({ id, firstName, lastName, username }) => { - return ( - - - - - ); - })} - -
User -
-

{`${firstName ?? "-"} ${lastName ?? ""}`}

-

{username}

-
- - {(isAllowed) => { - return ( - - ); - }} - -
- {!isPending && totalCount > 0 && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} - /> - )} - {!isPending && !data?.users?.length && ( - - )} -
-
-
- ); -}; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx index cbad2c12b..a3ebed6a8 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -43,7 +43,7 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { Name - Hosts + # Hosts Login User - Authorized Principals Mapping @@ -60,16 +60,16 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { key={`ssh-host-group-${group.id}`} onClick={() => navigate({ - to: `/${ProjectType.SSH}/$projectId/ssh-groups/$sshGroupId` as const, + to: `/${ProjectType.SSH}/$projectId/ssh-host-groups/$sshHostGroupId` as const, params: { projectId: currentWorkspace.id, - sshGroupId: group.id + sshHostGroupId: group.id } }) } > {group.name} - - + {group.hostCount} {group.loginMappings.length === 0 ? ( None diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index fa2ef2d46..1f209bd5e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -107,7 +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 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' @@ -1002,8 +1002,8 @@ const projectIdentityDetailsByIDPageRouteCertManagerRoute = getParentRoute: () => certManagerLayoutRoute, } as any) -const sshSshGroupDetailsByIDPageRouteRoute = - sshSshGroupDetailsByIDPageRouteImport.update({ +const sshSshHostGroupDetailsByIDPageRouteRoute = + sshSshHostGroupDetailsByIDPageRouteImport.update({ id: '/ssh-host-groups/$sshHostGroupId', path: '/ssh-host-groups/$sshHostGroupId', getParentRoute: () => sshLayoutRoute, @@ -2391,7 +2391,7 @@ declare module '@tanstack/react-router' { 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 + preLoaderRoute: typeof sshSshHostGroupDetailsByIDPageRouteImport parentRoute: typeof sshLayoutImport } '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId': { @@ -3561,7 +3561,7 @@ interface sshLayoutRouteChildren { sshSettingsPageRouteRoute: typeof sshSettingsPageRouteRoute projectAccessControlPageRouteSshRoute: typeof projectAccessControlPageRouteSshRoute sshSshCaByIDPageRouteRoute: typeof sshSshCaByIDPageRouteRoute - sshSshGroupDetailsByIDPageRouteRoute: typeof sshSshGroupDetailsByIDPageRouteRoute + sshSshHostGroupDetailsByIDPageRouteRoute: typeof sshSshHostGroupDetailsByIDPageRouteRoute projectIdentityDetailsByIDPageRouteSshRoute: typeof projectIdentityDetailsByIDPageRouteSshRoute projectMemberDetailsByIDPageRouteSshRoute: typeof projectMemberDetailsByIDPageRouteSshRoute projectRoleDetailsBySlugPageRouteSshRoute: typeof projectRoleDetailsBySlugPageRouteSshRoute @@ -3574,7 +3574,8 @@ const sshLayoutRouteChildren: sshLayoutRouteChildren = { sshSettingsPageRouteRoute: sshSettingsPageRouteRoute, projectAccessControlPageRouteSshRoute: projectAccessControlPageRouteSshRoute, sshSshCaByIDPageRouteRoute: sshSshCaByIDPageRouteRoute, - sshSshGroupDetailsByIDPageRouteRoute: sshSshGroupDetailsByIDPageRouteRoute, + sshSshHostGroupDetailsByIDPageRouteRoute: + sshSshHostGroupDetailsByIDPageRouteRoute, projectIdentityDetailsByIDPageRouteSshRoute: projectIdentityDetailsByIDPageRouteSshRoute, projectMemberDetailsByIDPageRouteSshRoute: @@ -3883,7 +3884,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 + '/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 @@ -4060,7 +4061,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 + '/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 @@ -4255,7 +4256,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/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 @@ -5579,7 +5580,7 @@ export const routeTree = rootRoute "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", + "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": { diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 302d8bbab..f33a71de7 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -312,7 +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("/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"),