From e51b6b545e8c2aa0188dad4c8799a816c57ffc3c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 5 Nov 2025 12:24:50 -0800 Subject: [PATCH] add users and roles to site resources --- server/db/pg/schema/schema.ts | 18 ++ server/db/sqlite/schema/schema.ts | 25 ++- .../middlewares/verifySiteResourceAccess.ts | 112 +++++++++-- server/routers/external.ts | 32 +++ server/routers/integration.ts | 32 +++ .../siteResource/createSiteResource.ts | 27 ++- server/routers/siteResource/index.ts | 4 + .../siteResource/listSiteResourceRoles.ts | 86 ++++++++ .../siteResource/listSiteResourceUsers.ts | 89 +++++++++ .../siteResource/setSiteResourceRoles.ts | 155 ++++++++++++++ .../siteResource/setSiteResourceUsers.ts | 106 ++++++++++ .../CreateInternalResourceDialog.tsx | 175 +++++++++++++++- src/components/EditInternalResourceDialog.tsx | 189 +++++++++++++++++- 13 files changed, 1017 insertions(+), 33 deletions(-) create mode 100644 server/routers/siteResource/listSiteResourceRoles.ts create mode 100644 server/routers/siteResource/listSiteResourceUsers.ts create mode 100644 server/routers/siteResource/setSiteResourceRoles.ts create mode 100644 server/routers/siteResource/setSiteResourceUsers.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f9be4601..7861f39b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -211,6 +211,24 @@ export const siteResources = pgTable("siteResources", { enabled: boolean("enabled").notNull().default(true) }); +export const roleSiteResources = pgTable("roleSiteResources", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + +export const userSiteResources = pgTable("userSiteResources", { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const users = pgTable("user", { userId: varchar("id").primaryKey(), email: varchar("email"), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 95d2418a..495cf3dc 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -232,6 +232,24 @@ export const siteResources = sqliteTable("siteResources", { enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }); +export const roleSiteResources = sqliteTable("roleSiteResources", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + +export const userSiteResources = sqliteTable("userSiteResources", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) +}); + export const users = sqliteTable("user", { userId: text("id").primaryKey(), email: text("email"), @@ -356,7 +374,8 @@ export const olms = sqliteTable("olms", { // we will switch this depending on the current org it wants to connect to onDelete: "set null" }), - userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }) }); @@ -822,7 +841,9 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { expiresAt: integer("expiresAt").notNull(), createdAt: integer("createdAt").notNull(), verified: integer("verified", { mode: "boolean" }).notNull().default(false), - userId: text("userId").references(() => users.userId, { onDelete: "cascade" }) + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }) }); export type Org = InferSelectModel; diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index e7fefd24..5fad3f9e 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { db } from "@server/db"; +import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; import { eq, and } from "drizzle-orm"; import createHttpError from "http-errors"; @@ -12,44 +12,128 @@ export async function verifySiteResourceAccess( next: NextFunction ): Promise { try { - const siteResourceId = parseInt(req.params.siteResourceId); - const siteId = parseInt(req.params.siteId); - const orgId = req.params.orgId; + const userId = req.user!.userId; + const siteResourceId = + req.params.siteResourceId || + req.body.siteResourceId || + req.query.siteResourceId; - if (!siteResourceId || !siteId || !orgId) { + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!siteResourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Missing required parameters" + "Site resource ID is required" + ) + ); + } + + const siteResourceIdNum = parseInt(siteResourceId as string, 10); + if (isNaN(siteResourceIdNum)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid site resource ID" ) ); } - // Check if the site resource exists and belongs to the specified site and org const [siteResource] = await db .select() .from(siteResources) - .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - )) + .where(eq(siteResources.siteResourceId, siteResourceIdNum)) .limit(1); if (!siteResource) { return next( createHttpError( HttpCode.NOT_FOUND, - "Site resource not found" + `Site resource with ID ${siteResourceIdNum} not found` ) ); } + if (!siteResource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site resource with ID ${siteResourceIdNum} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, siteResource.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + req.userOrgId = siteResource.orgId; + // Attach the siteResource to the request for use in the next middleware/route // @ts-ignore - Extending Request type req.siteResource = siteResource; - next(); + const roleResourceAccess = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceIdNum), + eq(roleSiteResources.roleId, userOrgRoleId) + ) + ) + .limit(1); + + if (roleResourceAccess.length > 0) { + return next(); + } + + const userResourceAccess = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.userId, userId), + eq(userSiteResources.siteResourceId, siteResourceIdNum) + ) + ) + .limit(1); + + if (userResourceAccess.length > 0) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this resource" + ) + ); } catch (error) { logger.error("Error verifying site resource access:", error); return next( diff --git a/server/routers/external.ts b/server/routers/external.ts index 6d7c2183..db90f212 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -285,6 +285,38 @@ authenticated.delete( siteResource.deleteSiteResource, ); +authenticated.get( + "/site-resource/:siteResourceId/roles", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceRoles), + siteResource.listSiteResourceRoles +); + +authenticated.get( + "/site-resource/:siteResourceId/users", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceUsers +); + +authenticated.post( + "/site-resource/:siteResourceId/roles", + verifySiteResourceAccess, + verifyRoleAccess, + verifyUserHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.setSiteResourceRoles, +); + +authenticated.post( + "/site-resource/:siteResourceId/users", + verifySiteResourceAccess, + verifySetResourceUsers, + verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceUsers, +); + authenticated.put( "/org/:orgId/resource", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 9115ab2e..dab8af69 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -198,6 +198,38 @@ authenticated.delete( siteResource.deleteSiteResource ); +authenticated.get( + "/site-resource/:siteResourceId/roles", + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRoles), + siteResource.listSiteResourceRoles +); + +authenticated.get( + "/site-resource/:siteResourceId/users", + verifyApiKeySiteResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + siteResource.listSiteResourceUsers +); + +authenticated.post( + "/site-resource/:siteResourceId/roles", + verifyApiKeySiteResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.setSiteResourceRoles +); + +authenticated.post( + "/site-resource/:siteResourceId/users", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.setSiteResourceUsers +); + authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index ca223b04..48682ae3 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts } from "@server/db"; +import { db, newts, roleResources, roles, roleSiteResources } from "@server/db"; import { siteResources, sites, orgs, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -140,6 +140,23 @@ export async function createSiteResource( }) .returning(); + const adminRole = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + await db.insert(roleSiteResources).values({ + roleId: adminRole[0].roleId, + siteResourceId: newSiteResource.siteResourceId + }); + const [newt] = await db .select() .from(newts) @@ -150,7 +167,13 @@ export async function createSiteResource( return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); } - await addTargets(newt.newtId, destinationIp, destinationPort, protocol, proxyPort); + await addTargets( + newt.newtId, + destinationIp, + destinationPort, + protocol, + proxyPort + ); logger.info( `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 2c3e2526..7fde4187 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -4,3 +4,7 @@ export * from "./getSiteResource"; export * from "./updateSiteResource"; export * from "./listSiteResources"; export * from "./listAllSiteResourcesByOrg"; +export * from "./listSiteResourceRoles"; +export * from "./listSiteResourceUsers"; +export * from "./setSiteResourceRoles"; +export * from "./setSiteResourceUsers"; diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts new file mode 100644 index 00000000..5504c003 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roleSiteResources, roles } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listSiteResourceRolesSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +async function query(siteResourceId: number) { + return await db + .select({ + roleId: roles.roleId, + name: roles.name, + description: roles.description, + isAdmin: roles.isAdmin + }) + .from(roleSiteResources) + .innerJoin(roles, eq(roleSiteResources.roleId, roles.roleId)) + .where(eq(roleSiteResources.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceRolesResponse = { + roles: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/roles", + description: "List all roles for a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: listSiteResourceRolesSchema + }, + responses: {} +}); + +export async function listSiteResourceRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceRolesSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const siteResourceRolesList = await query(siteResourceId); + + return response(res, { + data: { + roles: siteResourceRolesList + }, + success: true, + error: false, + message: "Site resource roles retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts new file mode 100644 index 00000000..6cc19557 --- /dev/null +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -0,0 +1,89 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, userSiteResources, users } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listSiteResourceUsersSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +async function queryUsers(siteResourceId: number) { + return await db + .select({ + userId: userSiteResources.userId, + username: users.username, + type: users.type, + idpName: idp.name, + idpId: users.idpId, + email: users.email + }) + .from(userSiteResources) + .innerJoin(users, eq(userSiteResources.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); +} + +export type ListSiteResourceUsersResponse = { + users: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/users", + description: "List all users for a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: listSiteResourceUsersSchema + }, + responses: {} +}); + +export async function listSiteResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourceUsersSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const siteResourceUsersList = await queryUsers(siteResourceId); + + return response(res, { + data: { + users: siteResourceUsersList + }, + success: true, + error: false, + message: "Site resource users retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts new file mode 100644 index 00000000..ba312134 --- /dev/null +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -0,0 +1,155 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { roleSiteResources, roles } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, ne } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setSiteResourceRolesBodySchema = z + .object({ + roleIds: z.array(z.number().int().positive()) + }) + .strict(); + +const setSiteResourceRolesParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/roles", + description: + "Set roles for a site resource. This will replace all existing roles.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: setSiteResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceRolesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setSiteResourceRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleIds } = parsedBody.data; + + const parsedParams = setSiteResourceRolesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site resource not found" + ) + ); + } + + // get this org's admin role + const adminRole = await db + .select() + .from(roles) + .where( + and( + eq(roles.name, "Admin"), + eq(roles.orgId, siteResource.orgId) + ) + ) + .limit(1); + + if (!adminRole.length) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Admin role not found" + ) + ); + } + + if (roleIds.includes(adminRole[0].roleId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to site resources" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRole[0].roleId) // delete all but the admin role + ) + ); + + await Promise.all( + roleIds.map((roleId) => + trx + .insert(roleSiteResources) + .values({ roleId, siteResourceId }) + .returning() + ) + ); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Roles set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts new file mode 100644 index 00000000..f100f6d6 --- /dev/null +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userSiteResources } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setSiteResourceUsersBodySchema = z + .object({ + userIds: z.array(z.string()) + }) + .strict(); + +const setSiteResourceUsersParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/users", + description: + "Set users for a site resource. This will replace all existing users.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: setSiteResourceUsersParamsSchema, + body: { + content: { + "application/json": { + schema: setSiteResourceUsersBodySchema + } + } + } + }, + responses: {} +}); + +export async function setSiteResourceUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setSiteResourceUsersBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds } = parsedBody.data; + + const parsedParams = setSiteResourceUsersParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + await db.transaction(async (trx) => { + await trx + .delete(userSiteResources) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); + + await Promise.all( + userIds.map((userId) => + trx + .insert(userSiteResources) + .values({ userId, siteResourceId }) + .returning() + ) + ); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 63dfc11d..a398f8f5 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -51,7 +51,13 @@ import { useTranslations } from "next-intl"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { ListSitesResponse } from "@server/routers/site"; +import { ListRolesResponse } from "@server/routers/role"; +import { ListUsersResponse } from "@server/routers/user"; import { cn } from "@app/lib/cn"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Separator } from "@app/components/ui/separator"; +import { AxiosResponse } from "axios"; +import { UserType } from "@server/types/UserTypes"; type Site = ListSitesResponse["sites"][0]; @@ -93,11 +99,28 @@ export default function CreateInternalResourceDialog({ .int() .positive() .min(1, t("createInternalResourceDialogDestinationPortMin")) - .max(65535, t("createInternalResourceDialogDestinationPortMax")) + .max(65535, t("createInternalResourceDialogDestinationPortMax")), + roles: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional(), + users: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional() }); type FormData = z.infer; + const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]); + const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState(null); + const availableSites = sites.filter( (site) => site.type === "newt" && site.subnet ); @@ -110,7 +133,9 @@ export default function CreateInternalResourceDialog({ protocol: "tcp", proxyPort: undefined, destinationIp: "", - destinationPort: undefined + destinationPort: undefined, + roles: [], + users: [] } }); @@ -122,22 +147,75 @@ export default function CreateInternalResourceDialog({ protocol: "tcp", proxyPort: undefined, destinationIp: "", - destinationPort: undefined + destinationPort: undefined, + roles: [], + users: [] }); } }, [open]); + useEffect(() => { + const fetchRolesAndUsers = async () => { + try { + const [rolesResponse, usersResponse] = await Promise.all([ + api.get>(`/org/${orgId}/roles`), + api.get>(`/org/${orgId}/users`) + ]); + + setAllRoles( + rolesResponse.data.data.roles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin") + ); + + setAllUsers( + usersResponse.data.data.users.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })) + ); + } catch (error) { + console.error("Error fetching roles and users:", error); + } + }; + + if (open) { + fetchRolesAndUsers(); + } + }, [open, orgId]); + const handleSubmit = async (data: FormData) => { setIsSubmitting(true); try { - await api.put(`/org/${orgId}/site/${data.siteId}/resource`, { - name: data.name, - protocol: data.protocol, - proxyPort: data.proxyPort, - destinationIp: data.destinationIp, - destinationPort: data.destinationPort, - enabled: true - }); + const response = await api.put>( + `/org/${orgId}/site/${data.siteId}/resource`, + { + name: data.name, + protocol: data.protocol, + proxyPort: data.proxyPort, + destinationIp: data.destinationIp, + destinationPort: data.destinationPort, + enabled: true + } + ); + + const siteResourceId = response.data.data.siteResourceId; + + // Set roles and users if provided + if (data.roles && data.roles.length > 0) { + await api.post(`/site-resource/${siteResourceId}/roles`, { + roleIds: data.roles.map((r) => parseInt(r.id)) + }); + } + + if (data.users && data.users.length > 0) { + await api.post(`/site-resource/${siteResourceId}/users`, { + userIds: data.users.map((u) => u.id) + }); + } toast({ title: t("createInternalResourceDialogSuccess"), @@ -396,6 +474,81 @@ export default function CreateInternalResourceDialog({ + + {/* Access Control Section */} + +
+

+ {t("resourceUsersRoles")} +

+
+ ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + )} + /> +
+
diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index d09f0b6c..ff038920 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -37,6 +37,13 @@ import { useTranslations } from "next-intl"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Separator } from "@app/components/ui/separator"; +import { ListRolesResponse } from "@server/routers/role"; +import { ListUsersResponse } from "@server/routers/user"; +import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles"; +import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { AxiosResponse } from "axios"; +import { UserType } from "@server/types/UserTypes"; type InternalResourceData = { id: number; @@ -74,11 +81,29 @@ export default function EditInternalResourceDialog({ protocol: z.enum(["tcp", "udp"]), proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")), destinationIp: z.string(), - destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")) + destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")), + roles: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional(), + users: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional() }); type FormData = z.infer; + const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]); + const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState(null); + const [loadingRolesUsers, setLoadingRolesUsers] = useState(false); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -86,10 +111,71 @@ export default function EditInternalResourceDialog({ protocol: resource.protocol as "tcp" | "udp", proxyPort: resource.proxyPort || undefined, destinationIp: resource.destinationIp || "", - destinationPort: resource.destinationPort || undefined + destinationPort: resource.destinationPort || undefined, + roles: [], + users: [] } }); + const fetchRolesAndUsers = async () => { + setLoadingRolesUsers(true); + try { + const [ + rolesResponse, + resourceRolesResponse, + usersResponse, + resourceUsersResponse + ] = await Promise.all([ + api.get>(`/org/${orgId}/roles`), + api.get>( + `/site-resource/${resource.id}/roles` + ), + api.get>(`/org/${orgId}/users`), + api.get>( + `/site-resource/${resource.id}/users` + ) + ]); + + setAllRoles( + rolesResponse.data.data.roles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin") + ); + + form.setValue( + "roles", + resourceRolesResponse.data.data.roles + .map((i) => ({ + id: i.roleId.toString(), + text: i.name + })) + .filter((role) => role.text !== "Admin") + ); + + setAllUsers( + usersResponse.data.data.users.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })) + ); + + form.setValue( + "users", + resourceUsersResponse.data.data.users.map((i) => ({ + id: i.userId.toString(), + text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` + })) + ); + } catch (error) { + console.error("Error fetching roles and users:", error); + } finally { + setLoadingRolesUsers(false); + } + }; + useEffect(() => { if (open) { form.reset({ @@ -97,10 +183,14 @@ export default function EditInternalResourceDialog({ protocol: resource.protocol as "tcp" | "udp", proxyPort: resource.proxyPort || undefined, destinationIp: resource.destinationIp || "", - destinationPort: resource.destinationPort || undefined + destinationPort: resource.destinationPort || undefined, + roles: [], + users: [] }); + fetchRolesAndUsers(); } - }, [open, resource, form]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, resource]); const handleSubmit = async (data: FormData) => { setIsSubmitting(true); @@ -114,6 +204,16 @@ export default function EditInternalResourceDialog({ destinationPort: data.destinationPort }); + // Update roles and users + await Promise.all([ + api.post(`/site-resource/${resource.id}/roles`, { + roleIds: (data.roles || []).map((r) => parseInt(r.id)) + }), + api.post(`/site-resource/${resource.id}/users`, { + userIds: (data.users || []).map((u) => u.id) + }) + ]); + toast({ title: t("editInternalResourceDialogSuccess"), description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"), @@ -250,6 +350,87 @@ export default function EditInternalResourceDialog({ + + {/* Access Control Section */} + +
+

+ {t("resourceUsersRoles")} +

+ {loadingRolesUsers ? ( +
+ {t("loading")} +
+ ) : ( +
+ ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + + + )} + /> +
+ )} +