Add add/remove/list hosts in ssh host groups functionality

This commit is contained in:
Tuan Dang
2025-04-29 23:31:57 -07:00
parent a9a16c9bd1
commit b06eeb0d40
26 changed files with 820 additions and 376 deletions

View File

@@ -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;
}
});
};

View File

@@ -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

View File

@@ -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<typeof sshHostGroupDALFactory>;
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<Record<string, number>>((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)

View File

@@ -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 {

View File

@@ -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"
}

View File

@@ -9,7 +9,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
export type TListSshHostsDTO = Omit<TProjectPermission, "projectId">;
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<TProjectPermission, "projectId">;
export type TGetSshHostDTO = {
@@ -54,7 +54,7 @@ export type TIssueSshHostHostCertDTO = {
} & Omit<TProjectPermission, "projectId">;
type BaseCreateSshLoginMappingsDTO = {
loginMappings: LoginMapping[];
loginMappings: TLoginMapping[];
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">;

View File

@@ -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.",

View File

@@ -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()
})
)
})

View File

@@ -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: {

View File

@@ -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";

View File

@@ -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<void, object, { sshHostGroupId: string; sshHostId: string }>({
mutationFn: async ({ sshHostGroupId, sshHostId }) => {
await apiRequest.post(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
},
onSuccess: (_, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
});
}
});
};
export const useRemoveHostFromSshHostGroup = () => {
const queryClient = useQueryClient();
return useMutation<void, object, { sshHostGroupId: string; sshHostId: string }>({
mutationFn: async ({ sshHostGroupId, sshHostId }) => {
await apiRequest.delete(`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts/${sshHostId}`);
},
onSuccess: (_, { sshHostGroupId }) => {
queryClient.invalidateQueries({
queryKey: sshHostGroupKeys.forSshHostGroupHosts(sshHostGroupId)
});
}
});
};

View File

@@ -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<TListSshHostGroupHostsResponse>(
`/api/v1/ssh/host-groups/${sshHostGroupId}/hosts`,
{
params
}
);
return data;
},
enabled: Boolean(sshHostGroupId),
staleTime: 0,
gcTime: 0
});
};

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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,
})

View File

@@ -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 = () => {
</PageHeader>
<div className="flex">
<div className="mr-4 w-96">
<SshHostGroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
<SshHostGroupDetailsSection
sshHostGroupId={sshHostGroupId}
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<div className="w-full">
<SshHostsSection groupId={groupId} />
<SshHostGroupHostsSection sshHostGroupId={sshHostGroupId} />
</div>
</div>
</div>
@@ -127,7 +134,7 @@ const Page = () => {
);
};
export const SshGroupDetailsByIDPage = () => {
export const SshHostGroupDetailsByIDPage = () => {
const { t } = useTranslation();
return (
<>

View File

@@ -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 (
<Modal
isOpen={popUp?.addHostGroupMembers?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addHostGroupMembers", isOpen);
}}
>
<ModalContent title="Add Hosts to Group">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="ssh-hosts" />}
{!isPending &&
data?.hosts?.map((host) => {
return (
<Tr className="items-center" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddHost(host.id)}
>
Add
</Button>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.hosts?.length && (
<EmptyState title="No hosts available to add to the SSH host group" icon={faServer} />
)}
</TableContainer>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,84 @@
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetSshHostGroupById } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
sshHostGroupId: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshHostGroup"]>, data?: object) => void;
};
export const SshHostGroupDetailsSection = ({ sshHostGroupId, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { data: sshHostGroup } = useGetSshHostGroupById(sshHostGroupId);
return sshHostGroup ? (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Host Group Details</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => {
return (
<Tooltip content="Edit SSH Host Group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="edit icon"
variant="plain"
className="group relative"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("sshHostGroup", {
sshHostGroupId: sshHostGroup.id
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">SSH Host Group ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{sshHostGroup.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(sshHostGroup.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{sshHostGroup.name}</p>
</div>
</div>
</div>
) : (
<div />
);
};

View File

@@ -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 (
<div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Hosts</h3>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.SshHosts}>
{(isAllowed) => (
<IconButton
ariaLabel="add host"
variant="plain"
className="group relative"
onClick={() =>
handlePopUpOpen("addHostGroupMembers", {
sshHostGroupId
})
}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<SshHostGroupHostsTable sshHostGroupId={sshHostGroupId} handlePopUpOpen={handlePopUpOpen} />
</div>
<AddHostGroupMemberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeHostFromSshHostGroup.isOpen}
title={`Are you sure want to remove the host ${
(popUp?.removeHostFromSshHostGroup?.data as { hostname: string })?.hostname || ""
} from this SSH group?`}
onChange={(isOpen) => handlePopUpToggle("removeHostFromSshHostGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveSshHostSubmit(
(popUp?.removeHostFromSshHostGroup?.data as { sshHostId: string })?.sshHostId
)
}
/>
</div>
);
};

View File

@@ -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 (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="ssh-host-group-hosts" />}
{!isPending &&
data?.hosts.map((host) => {
return (
<Tr className="h-10" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<Tooltip content="Remove user from group">
<IconButton
isDisabled={!isAllowed}
ariaLabel="Remove user from group"
onClick={() =>
handlePopUpOpen("removeHostFromSshHostGroup", {
sshHostId: host.id,
hostname: host.hostname
})
}
variant="plain"
colorSchema="danger"
>
<FontAwesomeIcon icon={faUserMinus} />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !data?.hosts?.length && (
<EmptyState title="No hosts have been added to this SSH host group" icon={faServer} />
)}
</TableContainer>
</div>
);
};
export default SshHostGroupHostsTable;

View File

@@ -0,0 +1,3 @@
export { SshHostGroupDetailsSection } from "./SshHostGroupDetailsSection";
export { SshHostGroupHostsSection } from "./SshHostGroupHostsSection";
export { SshHostGroupHostsTable } from "./SshHostGroupHostsTable";

View File

@@ -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
});

View File

@@ -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 (
<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>
);
};

View File

@@ -43,7 +43,7 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
<THead>
<Tr>
<Th>Name</Th>
<Th>Hosts</Th>
<Th># Hosts</Th>
<Th>Login User - Authorized Principals Mapping</Th>
<Th />
</Tr>
@@ -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
}
})
}
>
<Td>{group.name}</Td>
<Td>-</Td>
<Td>{group.hostCount}</Td>
<Td>
{group.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span>

View File

@@ -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": {

View File

@@ -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"),