diff --git a/config/config.example.yml b/config/config.example.yml index c5f70641..fcb7edde 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -2,47 +2,27 @@ # https://docs.digpangolin.com/self-host/advanced/config-file app: - dashboard_url: "http://localhost:3002" - log_level: "info" - save_logs: false + dashboard_url: http://localhost:3002 + log_level: debug domains: - domain1: - base_domain: "example.com" - cert_resolver: "letsencrypt" + domain1: + base_domain: example.com server: - external_port: 3000 - internal_port: 3001 - next_port: 3002 - internal_hostname: "pangolin" - session_cookie_name: "p_session_token" - resource_access_token_param: "p_token" - secret: "your_secret_key_here" - resource_access_token_headers: - id: "P-Access-Token-Id" - token: "P-Access-Token" - resource_session_request_param: "p_session_request" - -traefik: - http_entrypoint: "web" - https_entrypoint: "websecure" + secret: my_secret_key gerbil: - start_port: 51820 - base_endpoint: "localhost" - block_size: 24 - site_block_size: 30 - subnet_group: 100.89.137.0/20 - use_subdomain: true + base_endpoint: example.com -rate_limits: - global: - window_minutes: 1 - max_requests: 500 +orgs: + block_size: 24 + subnet_group: 100.90.137.0/20 flags: - require_email_verification: false - disable_signup_without_invite: true - disable_user_create_org: true - allow_raw_resources: true + require_email_verification: false + disable_signup_without_invite: true + disable_user_create_org: true + allow_raw_resources: true + enable_integration_api: true + enable_clients: true diff --git a/messages/en-US.json b/messages/en-US.json index 1a3fdfa8..6f80cbe9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -166,7 +166,7 @@ "siteSelect": "Select site", "siteSearch": "Search site", "siteNotFound": "No site found.", - "siteSelectionDescription": "This site will provide connectivity to the resource.", + "siteSelectionDescription": "This site will provide connectivity to the target.", "resourceType": "Resource Type", "resourceTypeDescription": "Determine how you want to access your resource", "resourceHTTPSSettings": "HTTPS Settings", @@ -197,6 +197,7 @@ "general": "General", "generalSettings": "General Settings", "proxy": "Proxy", + "internal": "Internal", "rules": "Rules", "resourceSettingDescription": "Configure the settings on your resource", "resourceSetting": "{resourceName} Settings", @@ -490,7 +491,7 @@ "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", "targetTlsSubmit": "Save Settings", "targets": "Targets Configuration", - "targetsDescription": "Set up targets to route traffic to your services", + "targetsDescription": "Set up targets to route traffic to your backend services", "targetStickySessions": "Enable Sticky Sessions", "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "methodSelect": "Select method", @@ -986,7 +987,7 @@ "actionGetSite": "Get Site", "actionListSites": "List Sites", "setupToken": "Setup Token", - "setupTokenPlaceholder": "Enter the setup token from the server console", + "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", "actionUpdateSite": "Update Site", "actionListSiteRoles": "List Allowed Site Roles", @@ -1345,9 +1346,106 @@ "resourceEnableProxy": "Enable Public Proxy", "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", "externalProxyEnabled": "External Proxy Enabled", + "addNewTarget": "Add New Target", + "targetsList": "Targets List", + "targetErrorDuplicateTargetFound": "Duplicate target found", + "httpMethod": "HTTP Method", + "selectHttpMethod": "Select HTTP method", + "domainPickerSubdomainLabel": "Subdomain", + "domainPickerBaseDomainLabel": "Base Domain", + "domainPickerSearchDomains": "Search domains...", + "domainPickerNoDomainsFound": "No domains found", + "domainPickerLoadingDomains": "Loading domains...", + "domainPickerSelectBaseDomain": "Select base domain...", + "domainPickerNotAvailableForCname": "Not available for CNAME domains", + "domainPickerEnterSubdomainOrLeaveBlank": "Enter subdomain or leave blank to use base domain.", + "domainPickerEnterSubdomainToSearch": "Enter a subdomain to search and select from available free domains.", + "domainPickerFreeDomains": "Free Domains", + "domainPickerSearchForAvailableDomains": "Search for available domains", + "resourceDomain": "Domain", + "resourceEditDomain": "Edit Domain", + "siteName": "Site Name", + "proxyPort": "Port", + "resourcesTableProxyResources": "Proxy Resources", + "resourcesTableClientResources": "Client Resources", + "resourcesTableNoProxyResourcesFound": "No proxy resources found.", + "resourcesTableNoInternalResourcesFound": "No internal resources found.", + "resourcesTableDestination": "Destination", + "resourcesTableTheseResourcesForUseWith": "These resources are for use with", + "resourcesTableClients": "Clients", + "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", + "editInternalResourceDialogEditClientResource": "Edit Client Resource", + "editInternalResourceDialogUpdateResourceProperties": "Update the resource properties and target configuration for {resourceName}.", + "editInternalResourceDialogResourceProperties": "Resource Properties", + "editInternalResourceDialogName": "Name", + "editInternalResourceDialogProtocol": "Protocol", + "editInternalResourceDialogSitePort": "Site Port", + "editInternalResourceDialogTargetConfiguration": "Target Configuration", + "editInternalResourceDialogDestinationIP": "Destination IP", + "editInternalResourceDialogDestinationPort": "Destination Port", + "editInternalResourceDialogCancel": "Cancel", + "editInternalResourceDialogSaveResource": "Save Resource", + "editInternalResourceDialogSuccess": "Success", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully", + "editInternalResourceDialogError": "Error", + "editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource", + "editInternalResourceDialogNameRequired": "Name is required", + "editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", + "editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1", + "editInternalResourceDialogProxyPortMax": "Proxy port must be less than 65536", + "editInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format", + "editInternalResourceDialogDestinationPortMin": "Destination port must be at least 1", + "editInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536", + "createInternalResourceDialogNoSitesAvailable": "No Sites Available", + "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.", + "createInternalResourceDialogClose": "Close", + "createInternalResourceDialogCreateClientResource": "Create Client Resource", + "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will be accessible to clients connected to the selected site.", + "createInternalResourceDialogResourceProperties": "Resource Properties", + "createInternalResourceDialogName": "Name", + "createInternalResourceDialogSite": "Site", + "createInternalResourceDialogSelectSite": "Select site...", + "createInternalResourceDialogSearchSites": "Search sites...", + "createInternalResourceDialogNoSitesFound": "No sites found.", + "createInternalResourceDialogProtocol": "Protocol", + "createInternalResourceDialogTcp": "TCP", + "createInternalResourceDialogUdp": "UDP", + "createInternalResourceDialogSitePort": "Site Port", + "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", + "createInternalResourceDialogTargetConfiguration": "Target Configuration", + "createInternalResourceDialogDestinationIP": "Destination IP", + "createInternalResourceDialogDestinationIPDescription": "The IP address of the resource on the site's network.", + "createInternalResourceDialogDestinationPort": "Destination Port", + "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", + "createInternalResourceDialogCancel": "Cancel", + "createInternalResourceDialogCreateResource": "Create Resource", + "createInternalResourceDialogSuccess": "Success", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully", + "createInternalResourceDialogError": "Error", + "createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource", + "createInternalResourceDialogNameRequired": "Name is required", + "createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", + "createInternalResourceDialogPleaseSelectSite": "Please select a site", + "createInternalResourceDialogProxyPortMin": "Proxy port must be at least 1", + "createInternalResourceDialogProxyPortMax": "Proxy port must be less than 65536", + "createInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format", + "createInternalResourceDialogDestinationPortMin": "Destination port must be at least 1", + "createInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnectionsDescription": "Allow other devices to connect through this Newt instance as a gateway using clients.", "siteAddress": "Site Address", - "siteAddressDescription": "Specify the IP address of the host for clients to connect to." -} \ No newline at end of file + "siteAddressDescription": "Specify the IP address of the host for clients to connect to.", + "autoLoginExternalIdp": "Auto Login with External IDP", + "autoLoginExternalIdpDescription": "Immediately redirect the user to the external IDP for authentication.", + "selectIdp": "Select IDP", + "selectIdpPlaceholder": "Choose an IDP...", + "selectIdpRequired": "Please select an IDP when auto login is enabled.", + "autoLoginTitle": "Redirecting", + "autoLoginDescription": "Redirecting you to the external identity provider for authentication.", + "autoLoginProcessing": "Preparing authentication...", + "autoLoginRedirecting": "Redirecting to login...", + "autoLoginError": "Auto Login Error", + "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", + "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL." +} diff --git a/server/auth/actions.ts b/server/auth/actions.ts index ee2c5dac..a3ad60ab 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -69,6 +69,11 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + createSiteResource = "createSiteResource", + deleteSiteResource = "deleteSiteResource", + getSiteResource = "getSiteResource", + listSiteResources = "listSiteResources", + updateSiteResource = "updateSiteResource", createClient = "createClient", deleteClient = "deleteClient", updateClient = "updateClient", diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 2ba10e3e..a2ec521e 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -66,11 +66,6 @@ export const sites = pgTable("sites", { export const resources = pgTable("resources", { resourceId: serial("resourceId").primaryKey(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -97,6 +92,9 @@ export const resources = pgTable("resources", { tlsServerName: varchar("tlsServerName"), setHostHeader: varchar("setHostHeader"), enableProxy: boolean("enableProxy").default(true), + skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { + onDelete: "cascade" + }), }); export const targets = pgTable("targets", { @@ -106,6 +104,11 @@ export const targets = pgTable("targets", { onDelete: "cascade" }) .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), ip: varchar("ip").notNull(), method: varchar("method"), port: integer("port").notNull(), @@ -124,6 +127,22 @@ export const exitNodes = pgTable("exitNodes", { maxConnections: integer("maxConnections") }); +export const siteResources = pgTable("siteResources", { // this is for the clients + siteResourceId: serial("siteResourceId").primaryKey(), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: varchar("name").notNull(), + protocol: varchar("protocol").notNull(), + proxyPort: integer("proxyPort").notNull(), + destinationPort: integer("destinationPort").notNull(), + destinationIp: varchar("destinationIp").notNull(), + enabled: boolean("enabled").notNull().default(true), +}); + export const users = pgTable("user", { userId: varchar("id").primaryKey(), email: varchar("email"), @@ -647,4 +666,5 @@ export type OlmSession = InferSelectModel; export type UserClient = InferSelectModel; export type RoleClient = InferSelectModel; export type OrgDomains = InferSelectModel; +export type SiteResource = InferSelectModel; export type SetupToken = InferSelectModel; diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 5bd81d6a..3dde2dd7 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -67,16 +67,11 @@ export const sites = sqliteTable("sites", { dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true), - remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access + remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access }); export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -109,6 +104,9 @@ export const resources = sqliteTable("resources", { tlsServerName: text("tlsServerName"), setHostHeader: text("setHostHeader"), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), + skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { + onDelete: "cascade" + }), }); export const targets = sqliteTable("targets", { @@ -118,6 +116,11 @@ export const targets = sqliteTable("targets", { onDelete: "cascade" }) .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), ip: text("ip").notNull(), method: text("method"), port: integer("port").notNull(), @@ -136,6 +139,22 @@ export const exitNodes = sqliteTable("exitNodes", { maxConnections: integer("maxConnections") }); +export const siteResources = sqliteTable("siteResources", { // this is for the clients + siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + name: text("name").notNull(), + protocol: text("protocol").notNull(), + proxyPort: integer("proxyPort").notNull(), + destinationPort: integer("destinationPort").notNull(), + destinationIp: text("destinationIp").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), +}); + export const users = sqliteTable("user", { userId: text("id").primaryKey(), email: text("email"), @@ -166,9 +185,11 @@ export const users = sqliteTable("user", { export const securityKeys = sqliteTable("webauthnCredentials", { credentialId: text("credentialId").primaryKey(), - userId: text("userId").notNull().references(() => users.userId, { - onDelete: "cascade" - }), + userId: text("userId") + .notNull() + .references(() => users.userId, { + onDelete: "cascade" + }), publicKey: text("publicKey").notNull(), signCount: integer("signCount").notNull(), transports: text("transports"), @@ -688,6 +709,7 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type SiteResource = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index b1180995..28a73afd 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,3 +27,4 @@ export * from "./verifyApiKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; +export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts new file mode 100644 index 00000000..e7fefd24 --- /dev/null +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { siteResources } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifySiteResourceAccess( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const siteResourceId = parseInt(req.params.siteResourceId); + const siteId = parseInt(req.params.siteId); + const orgId = req.params.orgId; + + if (!siteResourceId || !siteId || !orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Missing required parameters" + ) + ); + } + + // 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) + )) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Site resource not found" + ) + ); + } + + // Attach the siteResource to the request for use in the next middleware/route + // @ts-ignore - Extending Request type + req.siteResource = siteResource; + + next(); + } catch (error) { + logger.error("Error verifying site resource access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site resource access" + ) + ); + } +} diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts new file mode 100644 index 00000000..8d13d8cf --- /dev/null +++ b/server/routers/client/targets.ts @@ -0,0 +1,39 @@ +import { sendToClient } from "../ws"; + +export async function addTargets( + newtId: string, + destinationIp: string, + destinationPort: number, + protocol: string, + port: number | null = null +) { + const target = `${port ? port + ":" : ""}${ + destinationIp + }:${destinationPort}`; + + await sendToClient(newtId, { + type: `newt/wg/${protocol}/add`, + data: { + targets: [target] // We can only use one target for WireGuard right now + } + }); +} + +export async function removeTargets( + newtId: string, + destinationIp: string, + destinationPort: number, + protocol: string, + port: number | null = null +) { + const target = `${port ? port + ":" : ""}${ + destinationIp + }:${destinationPort}`; + + await sendToClient(newtId, { + type: `newt/wg/${protocol}/remove`, + data: { + targets: [target] // We can only use one target for WireGuard right now + } + }); +} diff --git a/server/routers/external.ts b/server/routers/external.ts index f9ff7377..65dc6108 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -9,6 +9,7 @@ import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; import * as client from "./client"; +import * as siteResource from "./siteResource"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; @@ -34,7 +35,8 @@ import { verifyDomainAccess, verifyClientsEnabled, verifyUserHasAction, - verifyUserIsOrgOwner + verifyUserIsOrgOwner, + verifySiteResourceAccess } from "@server/middlewares"; import { createStore } from "@server/lib/rateLimitStore"; import { ActionsEnum } from "@server/auth/actions"; @@ -213,9 +215,60 @@ authenticated.get( site.listContainers ); +// Site Resource endpoints authenticated.put( "/org/:orgId/site/:siteId/resource", verifyOrgAccess, + verifySiteAccess, + verifyUserHasAction(ActionsEnum.createSiteResource), + siteResource.createSiteResource +); + +authenticated.get( + "/org/:orgId/site/:siteId/resources", + verifyOrgAccess, + verifySiteAccess, + verifyUserHasAction(ActionsEnum.listSiteResources), + siteResource.listSiteResources +); + +authenticated.get( + "/org/:orgId/site-resources", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listSiteResources), + siteResource.listAllSiteResourcesByOrg +); + +authenticated.get( + "/org/:orgId/site/:siteId/resource/:siteResourceId", + verifyOrgAccess, + verifySiteAccess, + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.getSiteResource), + siteResource.getSiteResource +); + +authenticated.post( + "/org/:orgId/site/:siteId/resource/:siteResourceId", + verifyOrgAccess, + verifySiteAccess, + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource +); + +authenticated.delete( + "/org/:orgId/site/:siteId/resource/:siteResourceId", + verifyOrgAccess, + verifySiteAccess, + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource +); + +authenticated.put( + "/org/:orgId/resource", + verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), resource.createResource ); @@ -397,28 +450,6 @@ authenticated.post( user.addUserRole ); -// authenticated.put( -// "/role/:roleId/site", -// verifyRoleAccess, -// verifyUserInRole, -// verifyUserHasAction(ActionsEnum.addRoleSite), -// role.addRoleSite -// ); -// authenticated.delete( -// "/role/:roleId/site", -// verifyRoleAccess, -// verifyUserInRole, -// verifyUserHasAction(ActionsEnum.removeRoleSite), -// role.removeRoleSite -// ); -// authenticated.get( -// "/role/:roleId/sites", -// verifyRoleAccess, -// verifyUserInRole, -// verifyUserHasAction(ActionsEnum.listRoleSites), -// role.listRoleSites -// ); - authenticated.post( "/resource/:resourceId/roles", verifyResourceAccess, @@ -463,13 +494,6 @@ authenticated.get( resource.getResourceWhitelist ); -authenticated.post( - `/resource/:resourceId/transfer`, - verifyResourceAccess, - verifyUserHasAction(ActionsEnum.updateResource), - resource.transferResource -); - authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 39939e1c..ee707333 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -341,13 +341,6 @@ authenticated.get( resource.getResourceWhitelist ); -authenticated.post( - `/resource/:resourceId/transfer`, - verifyApiKeyResourceAccess, - verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.transferResource -); - authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index b2594a71..7d6b3567 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -220,78 +220,37 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { // Filter out any null values from peers that didn't have an olm const validPeers = peers.filter((peer) => peer !== null); - // Improved version - const allResources = await db.transaction(async (tx) => { - // First get all resources for the site - const resourcesList = await tx - .select({ - resourceId: resources.resourceId, - subdomain: resources.subdomain, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - blockAccess: resources.blockAccess, - sso: resources.sso, - emailWhitelistEnabled: resources.emailWhitelistEnabled, - http: resources.http, - proxyPort: resources.proxyPort, - protocol: resources.protocol - }) - .from(resources) - .where(and(eq(resources.siteId, siteId), eq(resources.http, false))); + // Get all enabled targets with their resource protocol information + const allTargets = await db + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + enabled: targets.enabled, + protocol: resources.protocol + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); - // Get all enabled targets for these resources in a single query - const resourceIds = resourcesList.map((r) => r.resourceId); - const allTargets = - resourceIds.length > 0 - ? await tx - .select({ - resourceId: targets.resourceId, - targetId: targets.targetId, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - enabled: targets.enabled, - }) - .from(targets) - .where( - and( - inArray(targets.resourceId, resourceIds), - eq(targets.enabled, true) - ) - ) - : []; + const { tcpTargets, udpTargets } = allTargets.reduce( + (acc, target) => { + // Filter out invalid targets + if (!target.internalPort || !target.ip || !target.port) { + return acc; + } - // Combine the data in JS instead of using SQL for the JSON - return resourcesList.map((resource) => ({ - ...resource, - targets: allTargets.filter( - (target) => target.resourceId === resource.resourceId - ) - })); - }); - - const { tcpTargets, udpTargets } = allResources.reduce( - (acc, resource) => { - // Skip resources with no targets - if (!resource.targets?.length) return acc; - - // Format valid targets into strings - const formattedTargets = resource.targets - .filter( - (target: Target) => - resource.proxyPort && target?.ip && target?.port - ) - .map( - (target: Target) => - `${resource.proxyPort}:${target.ip}:${target.port}` - ); + // Format target into string + const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`; // Add to the appropriate protocol array - if (resource.protocol === "tcp") { - acc.tcpTargets.push(...formattedTargets); + if (target.protocol === "tcp") { + acc.tcpTargets.push(formattedTarget); } else { - acc.udpTargets.push(...formattedTargets); + acc.udpTargets.push(formattedTarget); } return acc; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 71a6fd5c..0255e97c 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -105,7 +105,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .limit(1); const blockSize = config.getRawConfig().gerbil.site_block_size; - const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); + const subnets = sitesQuery + .map((site) => site.subnet) + .filter((subnet) => subnet !== null); subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); const newSubnet = findNextAvailableCidr( subnets, @@ -160,78 +162,37 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { allowedIps: [siteSubnet] }); - // Improved version - const allResources = await db.transaction(async (tx) => { - // First get all resources for the site - const resourcesList = await tx - .select({ - resourceId: resources.resourceId, - subdomain: resources.subdomain, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - blockAccess: resources.blockAccess, - sso: resources.sso, - emailWhitelistEnabled: resources.emailWhitelistEnabled, - http: resources.http, - proxyPort: resources.proxyPort, - protocol: resources.protocol - }) - .from(resources) - .where(eq(resources.siteId, siteId)); + // Get all enabled targets with their resource protocol information + const allTargets = await db + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + enabled: targets.enabled, + protocol: resources.protocol + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); - // Get all enabled targets for these resources in a single query - const resourceIds = resourcesList.map((r) => r.resourceId); - const allTargets = - resourceIds.length > 0 - ? await tx - .select({ - resourceId: targets.resourceId, - targetId: targets.targetId, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - enabled: targets.enabled - }) - .from(targets) - .where( - and( - inArray(targets.resourceId, resourceIds), - eq(targets.enabled, true) - ) - ) - : []; + const { tcpTargets, udpTargets } = allTargets.reduce( + (acc, target) => { + // Filter out invalid targets + if (!target.internalPort || !target.ip || !target.port) { + return acc; + } - // Combine the data in JS instead of using SQL for the JSON - return resourcesList.map((resource) => ({ - ...resource, - targets: allTargets.filter( - (target) => target.resourceId === resource.resourceId - ) - })); - }); - - const { tcpTargets, udpTargets } = allResources.reduce( - (acc, resource) => { - // Skip resources with no targets - if (!resource.targets?.length) return acc; - - // Format valid targets into strings - const formattedTargets = resource.targets - .filter( - (target: Target) => - target?.internalPort && target?.ip && target?.port - ) - .map( - (target: Target) => - `${target.internalPort}:${target.ip}:${target.port}` - ); + // Format target into string + const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`; // Add to the appropriate protocol array - if (resource.protocol === "tcp") { - acc.tcpTargets.push(...formattedTargets); + if (target.protocol === "tcp") { + acc.tcpTargets.push(formattedTarget); } else { - acc.udpTargets.push(...formattedTargets); + acc.udpTargets.push(formattedTarget); } return acc; diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 642fc2df..91a0ac3f 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,7 +1,8 @@ import { Target } from "@server/db"; import { sendToClient } from "../ws"; +import logger from "@server/logger"; -export function addTargets( +export async function addTargets( newtId: string, targets: Target[], protocol: string, @@ -20,22 +21,9 @@ export function addTargets( targets: payloadTargets } }); - - const payloadTargetsResources = targets.map((target) => { - return `${port ? port + ":" : ""}${ - target.ip - }:${target.port}`; - }); - - sendToClient(newtId, { - type: `newt/wg/${protocol}/add`, - data: { - targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now - } - }); } -export function removeTargets( +export async function removeTargets( newtId: string, targets: Target[], protocol: string, @@ -48,23 +36,10 @@ export function removeTargets( }:${target.port}`; }); - sendToClient(newtId, { + await sendToClient(newtId, { type: `newt/${protocol}/remove`, data: { targets: payloadTargets } }); - - const payloadTargetsResources = targets.map((target) => { - return `${port ? port + ":" : ""}${ - target.ip - }:${target.port}`; - }); - - sendToClient(newtId, { - type: `newt/wg/${protocol}/remove`, - data: { - targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now - } - }); } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 8c80c90c..e3e431ec 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -15,7 +15,6 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, and } from "drizzle-orm"; -import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { subdomainSchema } from "@server/lib/schemas"; @@ -25,7 +24,6 @@ import { build } from "@server/build"; const createResourceParamsSchema = z .object({ - siteId: z.string().transform(stoi).pipe(z.number().int().positive()), orgId: z.string() }) .strict(); @@ -34,7 +32,6 @@ const createHttpResourceSchema = z .object({ name: z.string().min(1).max(255), subdomain: z.string().nullable().optional(), - siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), domainId: z.string() @@ -53,11 +50,10 @@ const createHttpResourceSchema = z const createRawResourceSchema = z .object({ name: z.string().min(1).max(255), - siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), proxyPort: z.number().int().min(1).max(65535), - enableProxy: z.boolean().default(true) + // enableProxy: z.boolean().default(true) // always true now }) .strict() .refine( @@ -78,7 +74,7 @@ export type CreateResourceResponse = Resource; registry.registerPath({ method: "put", - path: "/org/{orgId}/site/{siteId}/resource", + path: "/org/{orgId}/resource", description: "Create a resource.", tags: [OpenAPITags.Org, OpenAPITags.Resource], request: { @@ -111,7 +107,7 @@ export async function createResource( ); } - const { siteId, orgId } = parsedParams.data; + const { orgId } = parsedParams.data; if (req.user && !req.userOrgRoleId) { return next( @@ -146,7 +142,7 @@ export async function createResource( if (http) { return await createHttpResource( { req, res, next }, - { siteId, orgId } + { orgId } ); } else { if ( @@ -162,7 +158,7 @@ export async function createResource( } return await createRawResource( { req, res, next }, - { siteId, orgId } + { orgId } ); } } catch (error) { @@ -180,12 +176,11 @@ async function createHttpResource( next: NextFunction; }, meta: { - siteId: number; orgId: string; } ) { const { req, res, next } = route; - const { siteId, orgId } = meta; + const { orgId } = meta; const parsedBody = createHttpResourceSchema.safeParse(req.body); if (!parsedBody.success) { @@ -292,7 +287,6 @@ async function createHttpResource( const newResource = await trx .insert(resources) .values({ - siteId, fullDomain, domainId, orgId, @@ -357,12 +351,11 @@ async function createRawResource( next: NextFunction; }, meta: { - siteId: number; orgId: string; } ) { const { req, res, next } = route; - const { siteId, orgId } = meta; + const { orgId } = meta; const parsedBody = createRawResourceSchema.safeParse(req.body); if (!parsedBody.success) { @@ -374,7 +367,7 @@ async function createRawResource( ); } - const { name, http, protocol, proxyPort, enableProxy } = parsedBody.data; + const { name, http, protocol, proxyPort } = parsedBody.data; // if http is false check to see if there is already a resource with the same port and protocol const existingResource = await db @@ -402,13 +395,12 @@ async function createRawResource( const newResource = await trx .insert(resources) .values({ - siteId, orgId, name, http, protocol, proxyPort, - enableProxy + // enableProxy }) .returning(); diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 99adc5f7..3b0e9df4 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -71,44 +71,44 @@ export async function deleteResource( ); } - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, deletedResource.siteId!)) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${deletedResource.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "wireguard") { - await addPeer(site.exitNodeId!, { - publicKey: site.pubKey, - allowedIps: await getAllowedIps(site.siteId) - }); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - removeTargets( - newt.newtId, - targetsToBeRemoved, - deletedResource.protocol, - deletedResource.proxyPort - ); - } - } - + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, deletedResource.siteId!)) + // .limit(1); + // + // if (!site) { + // return next( + // createHttpError( + // HttpCode.NOT_FOUND, + // `Site with ID ${deletedResource.siteId} not found` + // ) + // ); + // } + // + // if (site.pubKey) { + // if (site.type == "wireguard") { + // await addPeer(site.exitNodeId!, { + // publicKey: site.pubKey, + // allowedIps: await getAllowedIps(site.siteId) + // }); + // } else if (site.type == "newt") { + // // get the newt on the site by querying the newt table for siteId + // const [newt] = await db + // .select() + // .from(newts) + // .where(eq(newts.siteId, site.siteId)) + // .limit(1); + // + // removeTargets( + // newt.newtId, + // targetsToBeRemoved, + // deletedResource.protocol, + // deletedResource.proxyPort + // ); + // } + // } + // return response(res, { data: null, success: true, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 0cffb1cf..a2c1c0d1 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -19,9 +19,7 @@ const getResourceSchema = z }) .strict(); -export type GetResourceResponse = Resource & { - siteName: string; -}; +export type GetResourceResponse = Resource; registry.registerPath({ method: "get", @@ -56,11 +54,9 @@ export async function getResource( .select() .from(resources) .where(eq(resources.resourceId, resourceId)) - .leftJoin(sites, eq(sites.siteId, resources.siteId)) .limit(1); - const resource = resp.resources; - const site = resp.sites; + const resource = resp; if (!resource) { return next( @@ -73,8 +69,7 @@ export async function getResource( return response(res, { data: { - ...resource, - siteName: site?.name + ...resource }, success: true, error: false, diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 64fade89..191221f1 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -31,6 +31,7 @@ export type GetResourceAuthInfoResponse = { blockAccess: boolean; url: string; whitelist: boolean; + skipToIdpId: number | null; }; export async function getResourceAuthInfo( @@ -86,7 +87,8 @@ export async function getResourceAuthInfo( sso: resource.sso, blockAccess: resource.blockAccess, url, - whitelist: resource.emailWhitelistEnabled + whitelist: resource.emailWhitelistEnabled, + skipToIdpId: resource.skipToIdpId }, success: true, error: false, diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 681ec4d0..3d28da6f 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -1,16 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { and, eq, or, inArray } from "drizzle-orm"; -import { - resources, - userResources, - roleResources, - userOrgs, - roles, +import { + resources, + userResources, + roleResources, + userOrgs, resourcePassword, resourcePincode, - resourceWhitelist, - sites + resourceWhitelist } from "@server/db"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -37,12 +35,7 @@ export async function getUserResources( roleId: userOrgs.roleId }) .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, orgId) - ) - ) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); if (userOrgResult.length === 0) { @@ -71,8 +64,8 @@ export async function getUserResources( // Combine all accessible resource IDs const accessibleResourceIds = [ - ...directResources.map(r => r.resourceId), - ...roleResourceResults.map(r => r.resourceId) + ...directResources.map((r) => r.resourceId), + ...roleResourceResults.map((r) => r.resourceId) ]; if (accessibleResourceIds.length === 0) { @@ -95,11 +88,9 @@ export async function getUserResources( enabled: resources.enabled, sso: resources.sso, protocol: resources.protocol, - emailWhitelistEnabled: resources.emailWhitelistEnabled, - siteName: sites.name + emailWhitelistEnabled: resources.emailWhitelistEnabled }) .from(resources) - .leftJoin(sites, eq(sites.siteId, resources.siteId)) .where( and( inArray(resources.resourceId, accessibleResourceIds), @@ -111,28 +102,61 @@ export async function getUserResources( // Check for password, pincode, and whitelist protection for each resource const resourcesWithAuth = await Promise.all( resourcesData.map(async (resource) => { - const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ - db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1), - db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1), - db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1) - ]); + const [passwordCheck, pincodeCheck, whitelistCheck] = + await Promise.all([ + db + .select() + .from(resourcePassword) + .where( + eq( + resourcePassword.resourceId, + resource.resourceId + ) + ) + .limit(1), + db + .select() + .from(resourcePincode) + .where( + eq( + resourcePincode.resourceId, + resource.resourceId + ) + ) + .limit(1), + db + .select() + .from(resourceWhitelist) + .where( + eq( + resourceWhitelist.resourceId, + resource.resourceId + ) + ) + .limit(1) + ]); const hasPassword = passwordCheck.length > 0; const hasPincode = pincodeCheck.length > 0; - const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; + const hasWhitelist = + whitelistCheck.length > 0 || resource.emailWhitelistEnabled; return { resourceId: resource.resourceId, name: resource.name, domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, enabled: resource.enabled, - protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), + protected: !!( + resource.sso || + hasPassword || + hasPincode || + hasWhitelist + ), protocol: resource.protocol, sso: resource.sso, password: hasPassword, pincode: hasPincode, - whitelist: hasWhitelist, - siteName: resource.siteName + whitelist: hasWhitelist }; }) ); @@ -144,11 +168,13 @@ export async function getUserResources( message: "User resources retrieved successfully", status: HttpCode.OK }); - } catch (error) { console.error("Error fetching user resources:", error); return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error") + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) ); } } @@ -165,4 +191,4 @@ export type GetUserResourcesResponse = { protocol: string; }>; }; -}; \ No newline at end of file +}; diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index f97fcdf4..1a2e5c2d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,10 +16,9 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; -export * from "./transferResource"; export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; -export * from "./getUserResources"; \ No newline at end of file +export * from "./getUserResources"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 6df56001..43757b27 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { db } from "@server/db"; import { resources, - sites, userResources, roleResources, resourcePassword, @@ -20,17 +19,9 @@ import { OpenAPITags, registry } from "@server/openApi"; const listResourcesParamsSchema = z .object({ - siteId: z - .string() - .optional() - .transform(stoi) - .pipe(z.number().int().positive().optional()), - orgId: z.string().optional() + orgId: z.string() }) - .strict() - .refine((data) => !!data.siteId !== !!data.orgId, { - message: "Either siteId or orgId must be provided, but not both" - }); + .strict(); const listResourcesSchema = z.object({ limit: z @@ -48,82 +39,38 @@ const listResourcesSchema = z.object({ .pipe(z.number().int().nonnegative()) }); -function queryResources( - accessibleResourceIds: number[], - siteId?: number, - orgId?: string -) { - if (siteId) { - return db - .select({ - resourceId: resources.resourceId, - name: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - siteName: sites.name, - siteId: sites.niceId, - passwordId: resourcePassword.passwordId, - pincodeId: resourcePincode.pincodeId, - sso: resources.sso, - whitelist: resources.emailWhitelistEnabled, - http: resources.http, - protocol: resources.protocol, - proxyPort: resources.proxyPort, - enabled: resources.enabled, - domainId: resources.domainId - }) - .from(resources) - .leftJoin(sites, eq(resources.siteId, sites.siteId)) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) +function queryResources(accessibleResourceIds: number[], orgId: string) { + return db + .select({ + resourceId: resources.resourceId, + name: resources.name, + ssl: resources.ssl, + fullDomain: resources.fullDomain, + passwordId: resourcePassword.passwordId, + sso: resources.sso, + pincodeId: resourcePincode.pincodeId, + whitelist: resources.emailWhitelistEnabled, + http: resources.http, + protocol: resources.protocol, + proxyPort: resources.proxyPort, + enabled: resources.enabled, + domainId: resources.domainId + }) + .from(resources) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId) ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.siteId, siteId) - ) - ); - } else if (orgId) { - return db - .select({ - resourceId: resources.resourceId, - name: resources.name, - ssl: resources.ssl, - fullDomain: resources.fullDomain, - siteName: sites.name, - siteId: sites.niceId, - passwordId: resourcePassword.passwordId, - sso: resources.sso, - pincodeId: resourcePincode.pincodeId, - whitelist: resources.emailWhitelistEnabled, - http: resources.http, - protocol: resources.protocol, - proxyPort: resources.proxyPort, - enabled: resources.enabled, - domainId: resources.domainId - }) - .from(resources) - .leftJoin(sites, eq(resources.siteId, sites.siteId)) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) - ); - } + ); } export type ListResourcesResponse = { @@ -131,20 +78,6 @@ export type ListResourcesResponse = { pagination: { total: number; limit: number; offset: number }; }; -registry.registerPath({ - method: "get", - path: "/site/{siteId}/resources", - description: "List resources for a site.", - tags: [OpenAPITags.Site, OpenAPITags.Resource], - request: { - params: z.object({ - siteId: z.number() - }), - query: listResourcesSchema - }, - responses: {} -}); - registry.registerPath({ method: "get", path: "/org/{orgId}/resources", @@ -185,9 +118,11 @@ export async function listResources( ) ); } - const { siteId } = parsedParams.data; - const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId; + const orgId = + parsedParams.data.orgId || + req.userOrg?.orgId || + req.apiKeyOrg?.orgId; if (!orgId) { return next( @@ -207,24 +142,27 @@ export async function listResources( let accessibleResources; if (req.user) { accessibleResources = await db - .select({ - resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` - }) - .from(userResources) - .fullJoin( - roleResources, - eq(userResources.resourceId, roleResources.resourceId) - ) - .where( - or( - eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + .select({ + resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` + }) + .from(userResources) + .fullJoin( + roleResources, + eq(userResources.resourceId, roleResources.resourceId) ) - ); + .where( + or( + eq(userResources.userId, req.user!.userId), + eq(roleResources.roleId, req.userOrgRoleId!) + ) + ); } else { - accessibleResources = await db.select({ - resourceId: resources.resourceId - }).from(resources).where(eq(resources.orgId, orgId)); + accessibleResources = await db + .select({ + resourceId: resources.resourceId + }) + .from(resources) + .where(eq(resources.orgId, orgId)); } const accessibleResourceIds = accessibleResources.map( @@ -236,7 +174,7 @@ export async function listResources( .from(resources) .where(inArray(resources.resourceId, accessibleResourceIds)); - const baseQuery = queryResources(accessibleResourceIds, siteId, orgId); + const baseQuery = queryResources(accessibleResourceIds, orgId); const resourcesList = await baseQuery!.limit(limit).offset(offset); const totalCountResult = await countQuery; diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts deleted file mode 100644 index a99405df..00000000 --- a/server/routers/resource/transferResource.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { newts, resources, sites, targets } 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 { addPeer } from "../gerbil/peers"; -import { addTargets, removeTargets } from "../newt/targets"; -import { getAllowedIps } from "../target/helpers"; -import { OpenAPITags, registry } from "@server/openApi"; - -const transferResourceParamsSchema = z - .object({ - resourceId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) - }) - .strict(); - -const transferResourceBodySchema = z - .object({ - siteId: z.number().int().positive() - }) - .strict(); - -registry.registerPath({ - method: "post", - path: "/resource/{resourceId}/transfer", - description: - "Transfer a resource to a different site. This will also transfer the targets associated with the resource.", - tags: [OpenAPITags.Resource], - request: { - params: transferResourceParamsSchema, - body: { - content: { - "application/json": { - schema: transferResourceBodySchema - } - } - } - }, - responses: {} -}); - -export async function transferResource( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = transferResourceParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const parsedBody = transferResourceBodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { resourceId } = parsedParams.data; - const { siteId } = parsedBody.data; - - const [oldResource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - if (!oldResource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } - - if (oldResource.siteId === siteId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Resource is already assigned to this site` - ) - ); - } - - const [newSite] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!newSite) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${siteId} not found` - ) - ); - } - - const [oldSite] = await db - .select() - .from(sites) - .where(eq(sites.siteId, oldResource.siteId)) - .limit(1); - - if (!oldSite) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${oldResource.siteId} not found` - ) - ); - } - - const [updatedResource] = await db - .update(resources) - .set({ siteId }) - .where(eq(resources.resourceId, resourceId)) - .returning(); - - if (!updatedResource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } - - const resourceTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); - - if (resourceTargets.length > 0) { - ////// REMOVE THE TARGETS FROM THE OLD SITE ////// - if (oldSite.pubKey) { - if (oldSite.type == "wireguard") { - await addPeer(oldSite.exitNodeId!, { - publicKey: oldSite.pubKey, - allowedIps: await getAllowedIps(oldSite.siteId) - }); - } else if (oldSite.type == "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, oldSite.siteId)) - .limit(1); - - removeTargets( - newt.newtId, - resourceTargets, - updatedResource.protocol, - updatedResource.proxyPort - ); - } - } - - ////// ADD THE TARGETS TO THE NEW SITE ////// - if (newSite.pubKey) { - if (newSite.type == "wireguard") { - await addPeer(newSite.exitNodeId!, { - publicKey: newSite.pubKey, - allowedIps: await getAllowedIps(newSite.siteId) - }); - } else if (newSite.type == "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, newSite.siteId)) - .limit(1); - - addTargets( - newt.newtId, - resourceTargets, - updatedResource.protocol, - updatedResource.proxyPort - ); - } - } - } - - return response(res, { - data: updatedResource, - success: true, - error: false, - message: "Resource transferred successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") - ); - } -} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 5cf68c2b..30acc0c1 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -20,7 +20,6 @@ import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; -import { build } from "@server/build"; const updateResourceParamsSchema = z .object({ @@ -44,7 +43,8 @@ const updateHttpResourceBodySchema = z enabled: z.boolean().optional(), stickySession: z.boolean().optional(), tlsServerName: z.string().nullable().optional(), - setHostHeader: z.string().nullable().optional() + setHostHeader: z.string().nullable().optional(), + skipToIdpId: z.number().int().positive().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -91,8 +91,8 @@ const updateRawResourceBodySchema = z name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), - enabled: z.boolean().optional(), - enableProxy: z.boolean().optional() + enabled: z.boolean().optional() + // enableProxy: z.boolean().optional() // always true now }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/role/addRoleSite.ts b/server/routers/role/addRoleSite.ts index 58da9879..d268eed4 100644 --- a/server/routers/role/addRoleSite.ts +++ b/server/routers/role/addRoleSite.ts @@ -60,18 +60,18 @@ export async function addRoleSite( }) .returning(); - const siteResources = await db - .select() - .from(resources) - .where(eq(resources.siteId, siteId)); - - for (const resource of siteResources) { - await trx.insert(roleResources).values({ - roleId, - resourceId: resource.resourceId - }); - } - + // const siteResources = await db + // .select() + // .from(resources) + // .where(eq(resources.siteId, siteId)); + // + // for (const resource of siteResources) { + // await trx.insert(roleResources).values({ + // roleId, + // resourceId: resource.resourceId + // }); + // } + // return response(res, { data: newRoleSite[0], success: true, diff --git a/server/routers/role/index.ts b/server/routers/role/index.ts index 0194c1f0..bbbe4ba8 100644 --- a/server/routers/role/index.ts +++ b/server/routers/role/index.ts @@ -1,6 +1,5 @@ export * from "./addRoleAction"; export * from "../resource/setResourceRoles"; -export * from "./addRoleSite"; export * from "./createRole"; export * from "./deleteRole"; export * from "./getRole"; @@ -11,5 +10,4 @@ export * from "./listRoles"; export * from "./listRoleSites"; export * from "./removeRoleAction"; export * from "./removeRoleResource"; -export * from "./removeRoleSite"; -export * from "./updateRole"; \ No newline at end of file +export * from "./updateRole"; diff --git a/server/routers/role/removeRoleSite.ts b/server/routers/role/removeRoleSite.ts index c88e4711..2670272d 100644 --- a/server/routers/role/removeRoleSite.ts +++ b/server/routers/role/removeRoleSite.ts @@ -71,22 +71,22 @@ export async function removeRoleSite( ); } - const siteResources = await db - .select() - .from(resources) - .where(eq(resources.siteId, siteId)); - - for (const resource of siteResources) { - await trx - .delete(roleResources) - .where( - and( - eq(roleResources.roleId, roleId), - eq(roleResources.resourceId, resource.resourceId) - ) - ) - .returning(); - } + // const siteResources = await db + // .select() + // .from(resources) + // .where(eq(resources.siteId, siteId)); + // + // for (const resource of siteResources) { + // await trx + // .delete(roleResources) + // .where( + // and( + // eq(roleResources.roleId, roleId), + // eq(roleResources.resourceId, resource.resourceId) + // ) + // ) + // .returning(); + // } }); return response(res, { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts new file mode 100644 index 00000000..4d80c7a0 --- /dev/null +++ b/server/routers/siteResource/createSiteResource.ts @@ -0,0 +1,171 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts } from "@server/db"; +import { siteResources, sites, orgs, SiteResource } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { addTargets } from "../client/targets"; + +const createSiteResourceParamsSchema = z + .object({ + siteId: z.string().transform(Number).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +const createSiteResourceSchema = z + .object({ + name: z.string().min(1).max(255), + protocol: z.enum(["tcp", "udp"]), + proxyPort: z.number().int().positive(), + destinationPort: z.number().int().positive(), + destinationIp: z.string().ip(), + enabled: z.boolean().default(true) + }) + .strict(); + +export type CreateSiteResourceBody = z.infer; +export type CreateSiteResourceResponse = SiteResource; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/site/{siteId}/resource", + description: "Create a new site resource.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: createSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: createSiteResourceSchema + } + } + } + }, + responses: {} +}); + +export async function createSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = createSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createSiteResourceSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteId, orgId } = parsedParams.data; + const { + name, + protocol, + proxyPort, + destinationPort, + destinationIp, + enabled + } = parsedBody.data; + + // Verify the site exists and belongs to the org + const [site] = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (!site) { + return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + } + + // check if resource with same protocol and proxy port already exists + const [existingResource] = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId), + eq(siteResources.protocol, protocol), + eq(siteResources.proxyPort, proxyPort) + ) + ) + .limit(1); + if (existingResource && existingResource.siteResourceId) { + return next( + createHttpError( + HttpCode.CONFLICT, + "A resource with the same protocol and proxy port already exists" + ) + ); + } + + // Create the site resource + const [newSiteResource] = await db + .insert(siteResources) + .values({ + siteId, + orgId, + name, + protocol, + proxyPort, + destinationPort, + destinationIp, + enabled + }) + .returning(); + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); + } + + await addTargets(newt.newtId, destinationIp, destinationPort, protocol); + + logger.info( + `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` + ); + + return response(res, { + data: newSiteResource, + success: true, + error: false, + message: "Site resource created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error("Error creating site resource:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create site resource" + ) + ); + } +} diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts new file mode 100644 index 00000000..df29faf5 --- /dev/null +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -0,0 +1,124 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts, sites } from "@server/db"; +import { siteResources } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { removeTargets } from "../client/targets"; + +const deleteSiteResourceParamsSchema = z + .object({ + siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()), + siteId: z.string().transform(Number).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +export type DeleteSiteResourceResponse = { + message: string; +}; + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", + description: "Delete a site resource.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: deleteSiteResourceParamsSchema + }, + responses: {} +}); + +export async function deleteSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = deleteSiteResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId, siteId, orgId } = parsedParams.data; + + const [site] = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (!site) { + return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + } + + // Check if site resource exists + const [existingSiteResource] = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(1); + + if (!existingSiteResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Site resource not found" + ) + ); + } + + // Delete the site resource + await db + .delete(siteResources) + .where(and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )); + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); + } + + await removeTargets( + newt.newtId, + existingSiteResource.destinationIp, + existingSiteResource.destinationPort, + existingSiteResource.protocol + ); + + logger.info(`Deleted site resource ${siteResourceId} for site ${siteId}`); + + return response(res, { + data: { message: "Site resource deleted successfully" }, + success: true, + error: false, + message: "Site resource deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error deleting site resource:", error); + return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete site resource")); + } +} diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts new file mode 100644 index 00000000..914706cd --- /dev/null +++ b/server/routers/siteResource/getSiteResource.ts @@ -0,0 +1,83 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { siteResources, SiteResource } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const getSiteResourceParamsSchema = z + .object({ + siteResourceId: z.string().transform(Number).pipe(z.number().int().positive()), + siteId: z.string().transform(Number).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +export type GetSiteResourceResponse = SiteResource; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", + description: "Get a specific site resource.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: getSiteResourceParamsSchema + }, + responses: {} +}); + +export async function getSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getSiteResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId, siteId, orgId } = parsedParams.data; + + // Get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(1); + + if (!siteResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Site resource not found" + ) + ); + } + + return response(res, { + data: siteResource, + success: true, + error: false, + message: "Site resource retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error getting site resource:", error); + return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to get site resource")); + } +} diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts new file mode 100644 index 00000000..2c3e2526 --- /dev/null +++ b/server/routers/siteResource/index.ts @@ -0,0 +1,6 @@ +export * from "./createSiteResource"; +export * from "./deleteSiteResource"; +export * from "./getSiteResource"; +export * from "./updateSiteResource"; +export * from "./listSiteResources"; +export * from "./listAllSiteResourcesByOrg"; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts new file mode 100644 index 00000000..948fc2c2 --- /dev/null +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -0,0 +1,111 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { siteResources, sites, SiteResource } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listAllSiteResourcesByOrgParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listAllSiteResourcesByOrgQuerySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export type ListAllSiteResourcesByOrgResponse = { + siteResources: (SiteResource & { siteName: string, siteNiceId: string })[]; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site-resources", + description: "List all site resources for an organization.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: listAllSiteResourcesByOrgParamsSchema, + query: listAllSiteResourcesByOrgQuerySchema + }, + responses: {} +}); + +export async function listAllSiteResourcesByOrg( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listAllSiteResourcesByOrgParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = listAllSiteResourcesByOrgQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { limit, offset } = parsedQuery.data; + + // Get all site resources for the org with site names + const siteResourcesList = await db + .select({ + siteResourceId: siteResources.siteResourceId, + siteId: siteResources.siteId, + orgId: siteResources.orgId, + name: siteResources.name, + protocol: siteResources.protocol, + proxyPort: siteResources.proxyPort, + destinationPort: siteResources.destinationPort, + destinationIp: siteResources.destinationIp, + enabled: siteResources.enabled, + siteName: sites.name, + siteNiceId: sites.niceId + }) + .from(siteResources) + .innerJoin(sites, eq(siteResources.siteId, sites.siteId)) + .where(eq(siteResources.orgId, orgId)) + .limit(limit) + .offset(offset); + + return response(res, { + data: { siteResources: siteResourcesList }, + success: true, + error: false, + message: "Site resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error listing all site resources by org:", error); + return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to list site resources")); + } +} diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts new file mode 100644 index 00000000..7fdb7a85 --- /dev/null +++ b/server/routers/siteResource/listSiteResources.ts @@ -0,0 +1,118 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { siteResources, sites, SiteResource } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listSiteResourcesParamsSchema = z + .object({ + siteId: z.string().transform(Number).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +const listSiteResourcesQuerySchema = z.object({ + limit: z + .string() + .optional() + .default("100") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export type ListSiteResourcesResponse = { + siteResources: SiteResource[]; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{siteId}/resources", + description: "List site resources for a site.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: listSiteResourcesParamsSchema, + query: listSiteResourcesQuerySchema + }, + responses: {} +}); + +export async function listSiteResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listSiteResourcesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = listSiteResourcesQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { siteId, orgId } = parsedParams.data; + const { limit, offset } = parsedQuery.data; + + // Verify the site exists and belongs to the org + const site = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (site.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Site not found" + ) + ); + } + + // Get site resources + const siteResourcesList = await db + .select() + .from(siteResources) + .where(and( + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + )) + .limit(limit) + .offset(offset); + + return response(res, { + data: { siteResources: siteResourcesList }, + success: true, + error: false, + message: "Site resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error listing site resources:", error); + return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to list site resources")); + } +} diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts new file mode 100644 index 00000000..bd717463 --- /dev/null +++ b/server/routers/siteResource/updateSiteResource.ts @@ -0,0 +1,196 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, newts, sites } from "@server/db"; +import { siteResources, SiteResource } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { addTargets } from "../client/targets"; + +const updateSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()), + siteId: z.string().transform(Number).pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +const updateSiteResourceSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + protocol: z.enum(["tcp", "udp"]).optional(), + proxyPort: z.number().int().positive().optional(), + destinationPort: z.number().int().positive().optional(), + destinationIp: z.string().ip().optional(), + enabled: z.boolean().optional() + }) + .strict(); + +export type UpdateSiteResourceBody = z.infer; +export type UpdateSiteResourceResponse = SiteResource; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", + description: "Update a site resource.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: updateSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteResourceSchema + } + } + } + }, + responses: {} +}); + +export async function updateSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = updateSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = updateSiteResourceSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteResourceId, siteId, orgId } = parsedParams.data; + const updateData = parsedBody.data; + + const [site] = await db + .select() + .from(sites) + .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .limit(1); + + if (!site) { + return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); + } + + // Check if site resource exists + const [existingSiteResource] = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) + .limit(1); + + if (!existingSiteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const protocol = updateData.protocol || existingSiteResource.protocol; + const proxyPort = + updateData.proxyPort || existingSiteResource.proxyPort; + + // check if resource with same protocol and proxy port already exists + const [existingResource] = await db + .select() + .from(siteResources) + .where( + and( + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId), + eq(siteResources.protocol, protocol), + eq(siteResources.proxyPort, proxyPort) + ) + ) + .limit(1); + if ( + existingResource && + existingResource.siteResourceId !== siteResourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "A resource with the same protocol and proxy port already exists" + ) + ); + } + + // Update the site resource + const [updatedSiteResource] = await db + .update(siteResources) + .set(updateData) + .where( + and( + eq(siteResources.siteResourceId, siteResourceId), + eq(siteResources.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) + .returning(); + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (!newt) { + return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found")); + } + + await addTargets( + newt.newtId, + updatedSiteResource.destinationIp, + updatedSiteResource.destinationPort, + updatedSiteResource.protocol + ); + + logger.info( + `Updated site resource ${siteResourceId} for site ${siteId}` + ); + + return response(res, { + data: updatedSiteResource, + success: true, + error: false, + message: "Site resource updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error updating site resource:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update site resource" + ) + ); + } +} diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index ffea1571..7a3acd55 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -26,6 +26,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ + siteId: z.number().int().positive(), ip: z.string().refine(isTargetValid), method: z.string().optional().nullable(), port: z.number().int().min(1).max(65535), @@ -98,17 +99,41 @@ export async function createTarget( ); } + const siteId = targetData.siteId; + const [site] = await db .select() .from(sites) - .where(eq(sites.siteId, resource.siteId!)) + .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, - `Site with ID ${resource.siteId} not found` + `Site with ID ${siteId} not found` + ) + ); + } + + const existingTargets = await db + .select() + .from(targets) + .where(eq(targets.resourceId, resourceId)); + + const existingTarget = existingTargets.find( + (target) => + target.ip === targetData.ip && + target.port === targetData.port && + target.method === targetData.method && + target.siteId === targetData.siteId + ); + + if (existingTarget) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` ) ); } @@ -173,7 +198,12 @@ export async function createTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort); + await addTargets( + newt.newtId, + newTarget, + resource.protocol, + resource.proxyPort + ); } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 6eadeccd..596691e4 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -76,38 +76,38 @@ export async function deleteTarget( ); } - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, resource.siteId!)) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${resource.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "wireguard") { - await addPeer(site.exitNodeId!, { - publicKey: site.pubKey, - allowedIps: await getAllowedIps(site.siteId) - }); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); - } - } + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, resource.siteId!)) + // .limit(1); + // + // if (!site) { + // return next( + // createHttpError( + // HttpCode.NOT_FOUND, + // `Site with ID ${resource.siteId} not found` + // ) + // ); + // } + // + // if (site.pubKey) { + // if (site.type == "wireguard") { + // await addPeer(site.exitNodeId!, { + // publicKey: site.pubKey, + // allowedIps: await getAllowedIps(site.siteId) + // }); + // } else if (site.type == "newt") { + // // get the newt on the site by querying the newt table for siteId + // const [newt] = await db + // .select() + // .from(newts) + // .where(eq(newts.siteId, site.siteId)) + // .limit(1); + // + // removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); + // } + // } return response(res, { data: null, diff --git a/server/routers/target/getTarget.ts b/server/routers/target/getTarget.ts index 071ec8a6..b0691087 100644 --- a/server/routers/target/getTarget.ts +++ b/server/routers/target/getTarget.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, Target } from "@server/db"; import { targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -16,6 +16,8 @@ const getTargetSchema = z }) .strict(); +type GetTargetResponse = Target; + registry.registerPath({ method: "get", path: "/target/{targetId}", @@ -60,7 +62,7 @@ export async function getTarget( ); } - return response(res, { + return response(res, { data: target[0], success: true, error: false, diff --git a/server/routers/target/helpers.ts b/server/routers/target/helpers.ts index e5aa2ba9..4935d28a 100644 --- a/server/routers/target/helpers.ts +++ b/server/routers/target/helpers.ts @@ -8,29 +8,21 @@ export async function pickPort(siteId: number): Promise<{ internalPort: number; targetIps: string[]; }> { - const resourcesRes = await db - .select() - .from(resources) - .where(eq(resources.siteId, siteId)); - - // TODO: is this all inefficient? // Fetch targets for all resources of this site const targetIps: string[] = []; const targetInternalPorts: number[] = []; - await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resource.resourceId)); - targetsRes.forEach((target) => { - targetIps.push(`${target.ip}/32`); - if (target.internalPort) { - targetInternalPorts.push(target.internalPort); - } - }); - }) - ); + + const targetsRes = await db + .select() + .from(targets) + .where(eq(targets.siteId, siteId)); + + targetsRes.forEach((target) => { + targetIps.push(`${target.ip}/32`); + if (target.internalPort) { + targetInternalPorts.push(target.internalPort); + } + }); let internalPort!: number; // pick a port random port from 40000 to 65535 that is not in use @@ -43,28 +35,20 @@ export async function pickPort(siteId: number): Promise<{ break; } } + currentBannedPorts.push(internalPort); return { internalPort, targetIps }; } export async function getAllowedIps(siteId: number) { - // TODO: is this all inefficient? - - const resourcesRes = await db - .select() - .from(resources) - .where(eq(resources.siteId, siteId)); - // Fetch targets for all resources of this site - const targetIps = await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resource.resourceId)); - return targetsRes.map((target) => `${target.ip}/32`); - }) - ); + const targetsRes = await db + .select() + .from(targets) + .where(eq(targets.siteId, siteId)); + + const targetIps = targetsRes.map((target) => `${target.ip}/32`); + return targetIps.flat(); } diff --git a/server/routers/target/index.ts b/server/routers/target/index.ts index b128edcd..dc1323f7 100644 --- a/server/routers/target/index.ts +++ b/server/routers/target/index.ts @@ -2,4 +2,4 @@ export * from "./getTarget"; export * from "./createTarget"; export * from "./deleteTarget"; export * from "./updateTarget"; -export * from "./listTargets"; \ No newline at end of file +export * from "./listTargets"; diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 44f27d48..eab8f1c8 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, sites } from "@server/db"; import { targets } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -42,11 +42,12 @@ function queryTargets(resourceId: number) { method: targets.method, port: targets.port, enabled: targets.enabled, - resourceId: targets.resourceId - // resourceName: resources.name, + resourceId: targets.resourceId, + siteId: targets.siteId, + siteType: sites.type }) .from(targets) - // .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(sites.siteId, targets.siteId)) .where(eq(targets.resourceId, resourceId)); return baseQuery; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 0b7c4692..67d9a8df 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -22,6 +22,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ + siteId: z.number().int().positive(), ip: z.string().refine(isTargetValid), method: z.string().min(1).max(10).optional().nullable(), port: z.number().int().min(1).max(65535).optional(), @@ -77,6 +78,7 @@ export async function updateTarget( } const { targetId } = parsedParams.data; + const { siteId } = parsedBody.data; const [target] = await db .select() @@ -111,14 +113,42 @@ export async function updateTarget( const [site] = await db .select() .from(sites) - .where(eq(sites.siteId, resource.siteId!)) + .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, - `Site with ID ${resource.siteId} not found` + `Site with ID ${siteId} not found` + ) + ); + } + + const targetData = { + ...target, + ...parsedBody.data + }; + + const existingTargets = await db + .select() + .from(targets) + .where(eq(targets.resourceId, target.resourceId)); + + const foundTarget = existingTargets.find( + (target) => + target.targetId !== targetId && // Exclude the current target being updated + target.ip === targetData.ip && + target.port === targetData.port && + target.method === targetData.method && + target.siteId === targetData.siteId + ); + + if (foundTarget) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target with IP ${targetData.ip}, port ${targetData.port}, and method ${targetData.method} already exists on the same site.` ) ); } @@ -157,7 +187,12 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort); + await addTargets( + newt.newtId, + [updatedTarget], + resource.protocol, + resource.proxyPort + ); } } return response(res, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 882a296a..e3b62176 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,11 +1,21 @@ import { Request, Response } from "express"; import { db, exitNodes } from "@server/db"; -import { and, eq, inArray, or, isNull } from "drizzle-orm"; +import { and, eq, inArray, or, isNull, ne } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; +// Extended Target interface that includes site information +interface TargetWithSite extends Target { + site: { + siteId: number; + type: string; + subnet: string | null; + exitNodeId: number | null; + }; +} + let currentExitNodeId: number; export async function traefikConfigProvider( @@ -44,8 +54,9 @@ export async function traefikConfigProvider( } } - // Get the site(s) on this exit node - const resourcesWithRelations = await tx + // Get resources with their targets and sites in a single optimized query + // Start from sites on this exit node, then join to targets and resources + const resourcesWithTargetsAndSites = await tx .select({ // Resource fields resourceId: resources.resourceId, @@ -56,67 +67,82 @@ export async function traefikConfigProvider( protocol: resources.protocol, subdomain: resources.subdomain, domainId: resources.domainId, - // Site fields - site: { - siteId: sites.siteId, - type: sites.type, - subnet: sites.subnet, - exitNodeId: sites.exitNodeId - }, enabled: resources.enabled, stickySession: resources.stickySession, tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, - enableProxy: resources.enableProxy + enableProxy: resources.enableProxy, + // Target fields + targetId: targets.targetId, + targetEnabled: targets.enabled, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + // Site fields + siteId: sites.siteId, + siteType: sites.type, + subnet: sites.subnet, + exitNodeId: sites.exitNodeId }) - .from(resources) - .innerJoin(sites, eq(sites.siteId, resources.siteId)) + .from(sites) + .innerJoin(targets, eq(targets.siteId, sites.siteId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) .where( - or( - eq(sites.exitNodeId, currentExitNodeId), - isNull(sites.exitNodeId) + and( + eq(targets.enabled, true), + eq(resources.enabled, true), + or( + eq(sites.exitNodeId, currentExitNodeId), + isNull(sites.exitNodeId) + ) ) ); - // Get all resource IDs from the first query - const resourceIds = resourcesWithRelations.map((r) => r.resourceId); + // Group by resource and include targets with their unique site data + const resourcesMap = new Map(); - // Second query to get all enabled targets for these resources - const allTargets = - resourceIds.length > 0 - ? await tx - .select({ - resourceId: targets.resourceId, - targetId: targets.targetId, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - enabled: targets.enabled - }) - .from(targets) - .where( - and( - inArray(targets.resourceId, resourceIds), - eq(targets.enabled, true) - ) - ) - : []; + resourcesWithTargetsAndSites.forEach((row) => { + const resourceId = row.resourceId; - // Create a map for fast target lookup by resourceId - const targetsMap = allTargets.reduce((map, target) => { - if (!map.has(target.resourceId)) { - map.set(target.resourceId, []); + if (!resourcesMap.has(resourceId)) { + resourcesMap.set(resourceId, { + resourceId: row.resourceId, + fullDomain: row.fullDomain, + ssl: row.ssl, + http: row.http, + proxyPort: row.proxyPort, + protocol: row.protocol, + subdomain: row.subdomain, + domainId: row.domainId, + enabled: row.enabled, + stickySession: row.stickySession, + tlsServerName: row.tlsServerName, + setHostHeader: row.setHostHeader, + enableProxy: row.enableProxy, + targets: [] + }); } - map.get(target.resourceId).push(target); - return map; - }, new Map()); - // Combine the data - return resourcesWithRelations.map((resource) => ({ - ...resource, - targets: targetsMap.get(resource.resourceId) || [] - })); + // Add target with its associated site data + resourcesMap.get(resourceId).targets.push({ + resourceId: row.resourceId, + targetId: row.targetId, + ip: row.ip, + method: row.method, + port: row.port, + internalPort: row.internalPort, + enabled: row.targetEnabled, + site: { + siteId: row.siteId, + type: row.siteType, + subnet: row.subnet, + exitNodeId: row.exitNodeId + } + }); + }); + + return Array.from(resourcesMap.values()); }); if (!allResources.length) { @@ -167,8 +193,7 @@ export async function traefikConfigProvider( }; for (const resource of allResources) { - const targets = resource.targets as Target[]; - const site = resource.site; + const targets = resource.targets as TargetWithSite[]; const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; @@ -272,13 +297,13 @@ export async function traefikConfigProvider( config_output.http.services![serviceName] = { loadBalancer: { servers: targets - .filter((target: Target) => { + .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { if ( !target.ip || @@ -287,27 +312,27 @@ export async function traefikConfigProvider( ) { return false; } - } else if (site.type === "newt") { + } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || - !site.subnet + !target.site.subnet ) { return false; } } return true; }) - .map((target: Target) => { + .map((target: TargetWithSite) => { if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { return { url: `${target.method}://${target.ip}:${target.port}` }; - } else if (site.type === "newt") { - const ip = site.subnet!.split("/")[0]; + } else if (target.site.type === "newt") { + const ip = target.site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; @@ -393,34 +418,34 @@ export async function traefikConfigProvider( config_output[protocol].services[serviceName] = { loadBalancer: { servers: targets - .filter((target: Target) => { + .filter((target: TargetWithSite) => { if (!target.enabled) { return false; } if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { if (!target.ip || !target.port) { return false; } - } else if (site.type === "newt") { - if (!target.internalPort || !site.subnet) { + } else if (target.site.type === "newt") { + if (!target.internalPort || !target.site.subnet) { return false; } } return true; }) - .map((target: Target) => { + .map((target: TargetWithSite) => { if ( - site.type === "local" || - site.type === "wireguard" + target.site.type === "local" || + target.site.type === "wireguard" ) { return { address: `${target.ip}:${target.port}` }; - } else if (site.type === "newt") { - const ip = site.subnet!.split("/")[0]; + } else if (target.site.type === "newt") { + const ip = target.site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; diff --git a/server/routers/user/addUserSite.ts b/server/routers/user/addUserSite.ts index c55d5463..f094e20e 100644 --- a/server/routers/user/addUserSite.ts +++ b/server/routers/user/addUserSite.ts @@ -43,17 +43,17 @@ export async function addUserSite( }) .returning(); - const siteResources = await trx - .select() - .from(resources) - .where(eq(resources.siteId, siteId)); - - for (const resource of siteResources) { - await trx.insert(userResources).values({ - userId, - resourceId: resource.resourceId - }); - } + // const siteResources = await trx + // .select() + // .from(resources) + // .where(eq(resources.siteId, siteId)); + // + // for (const resource of siteResources) { + // await trx.insert(userResources).values({ + // userId, + // resourceId: resource.resourceId + // }); + // } return response(res, { data: newUserSite[0], diff --git a/server/routers/user/removeUserSite.ts b/server/routers/user/removeUserSite.ts index 200999fd..7dbb4a15 100644 --- a/server/routers/user/removeUserSite.ts +++ b/server/routers/user/removeUserSite.ts @@ -71,22 +71,22 @@ export async function removeUserSite( ); } - const siteResources = await trx - .select() - .from(resources) - .where(eq(resources.siteId, siteId)); - - for (const resource of siteResources) { - await trx - .delete(userResources) - .where( - and( - eq(userResources.userId, userId), - eq(userResources.resourceId, resource.resourceId) - ) - ) - .returning(); - } + // const siteResources = await trx + // .select() + // .from(resources) + // .where(eq(resources.siteId, siteId)); + // + // for (const resource of siteResources) { + // await trx + // .delete(userResources) + // .where( + // and( + // eq(userResources.userId, userId), + // eq(userResources.resourceId, resource.resourceId) + // ) + // ) + // .returning(); + // } }); return response(res, { diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index d85cc277..05faece3 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -23,7 +23,7 @@ export const messageHandlers: Record = { "olm/ping": handleOlmPingMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, - "newt/ping/request": handleNewtPingRequestMessage, + "newt/ping/request": handleNewtPingRequestMessage }; startOfflineChecker(); // this is to handle the offline check for olms diff --git a/server/setup/scriptsPg/1.9.0.ts b/server/setup/scriptsPg/1.9.0.ts index 22259cae..a12f5617 100644 --- a/server/setup/scriptsPg/1.9.0.ts +++ b/server/setup/scriptsPg/1.9.0.ts @@ -22,4 +22,4 @@ export default async function migration() { console.log("Unable to add setupTokens table:", e); throw e; } -} \ No newline at end of file +} diff --git a/server/setup/scriptsSqlite/1.9.0.ts b/server/setup/scriptsSqlite/1.9.0.ts index a4a20dda..83dbf9d0 100644 --- a/server/setup/scriptsSqlite/1.9.0.ts +++ b/server/setup/scriptsSqlite/1.9.0.ts @@ -32,4 +32,4 @@ export default async function migration() { console.log("Unable to add setupTokens table:", e); throw e; } -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx deleted file mode 100644 index a675213a..00000000 --- a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { ColumnDef } from "@tanstack/react-table"; -import { DataTable } from "@app/components/ui/data-table"; -import { useTranslations } from 'next-intl'; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; - createResource?: () => void; -} - -export function ResourcesDataTable({ - columns, - data, - createResource -}: DataTableProps) { - - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx b/src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx deleted file mode 100644 index 50f6fd0b..00000000 --- a/src/app/[orgId]/settings/resources/ResourcesSplashCard.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Server, Lock, Key, Users, X, ArrowRight } from "lucide-react"; // Replace with actual imports -import { Card, CardContent } from "@app/components/ui/card"; -import { Button } from "@app/components/ui/button"; -import { useTranslations } from "next-intl"; - -export const ResourcesSplashCard = () => { - const [isDismissed, setIsDismissed] = useState(false); - - const key = "resources-splash-dismissed"; - - useEffect(() => { - const dismissed = localStorage.getItem(key); - if (dismissed === "true") { - setIsDismissed(true); - } - }, []); - - const handleDismiss = () => { - setIsDismissed(true); - localStorage.setItem(key, "true"); - }; - - const t = useTranslations(); - - if (isDismissed) { - return null; - } - - return ( - - - -
-

- - {t('resources')} -

-

- {t('resourcesDescription')} -

-
    -
  • - - {t('resourcesWireGuardConnect')} -
  • -
  • - - {t('resourcesMultipleAuthenticationMethods')} -
  • -
  • - - {t('resourcesUsersRolesAccess')} -
  • -
-
-
-
- ); -}; - -export default ResourcesSplashCard; diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index e64fb4e3..a4209bee 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -1,7 +1,16 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; -import { ResourcesDataTable } from "./ResourcesDataTable"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; import { DropdownMenu, DropdownMenuContent, @@ -10,18 +19,16 @@ import { } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { - Copy, ArrowRight, ArrowUpDown, MoreHorizontal, - Check, ArrowUpRight, ShieldOff, ShieldCheck } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; @@ -31,17 +38,37 @@ import CopyToClipboard from "@app/components/CopyToClipboard"; import { Switch } from "@app/components/ui/switch"; import { AxiosResponse } from "axios"; import { UpdateResourceResponse } from "@server/routers/resource"; +import { ListSitesResponse } from "@server/routers/site"; import { useTranslations } from "next-intl"; import { InfoPopup } from "@app/components/ui/info-popup"; -import { Badge } from "@app/components/ui/badge"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Plus, Search } from "lucide-react"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@app/components/ui/tabs"; +import { useSearchParams } from "next/navigation"; +import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; +import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; export type ResourceRow = { id: number; name: string; orgId: string; domain: string; - site: string; - siteId: string; authState: string; http: boolean; protocol: string; @@ -50,20 +77,147 @@ export type ResourceRow = { domainId?: string; }; -type ResourcesTableProps = { - resources: ResourceRow[]; +export type InternalResourceRow = { + id: number; + name: string; orgId: string; + siteName: string; + protocol: string; + proxyPort: number | null; + siteId: number; + siteNiceId: string; + destinationIp: string; + destinationPort: number; }; -export default function SitesTable({ resources, orgId }: ResourcesTableProps) { +type Site = ListSitesResponse["sites"][0]; + +type ResourcesTableProps = { + resources: ResourceRow[]; + internalResources: InternalResourceRow[]; + orgId: string; + defaultView?: "proxy" | "internal"; +}; + +export default function SitesTable({ + resources, + internalResources, + orgId, + defaultView = "proxy" +}: ResourcesTableProps) { const router = useRouter(); + const searchParams = useSearchParams(); const t = useTranslations(); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + + const api = createApiClient({ env }); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); + const [selectedInternalResource, setSelectedInternalResource] = + useState(); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingResource, setEditingResource] = + useState(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [sites, setSites] = useState([]); + + const [proxySorting, setProxySorting] = useState([]); + const [proxyColumnFilters, setProxyColumnFilters] = + useState([]); + const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); + + const [internalSorting, setInternalSorting] = useState([]); + const [internalColumnFilters, setInternalColumnFilters] = + useState([]); + const [internalGlobalFilter, setInternalGlobalFilter] = useState([]); + + const currentView = searchParams.get("view") || defaultView; + + useEffect(() => { + const fetchSites = async () => { + try { + const res = await api.get>( + `/org/${orgId}/sites` + ); + setSites(res.data.data.sites); + } catch (error) { + console.error("Failed to fetch sites:", error); + } + }; + + if (orgId) { + fetchSites(); + } + }, [orgId]); + + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams); + if (value === "internal") { + params.set("view", "internal"); + } else { + params.delete("view"); + } + + const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`; + router.replace(newUrl, { scroll: false }); + }; + + const getSearchInput = () => { + if (currentView === "internal") { + return ( +
+ + internalTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+ ); + } + return ( +
+ + proxyTable.setGlobalFilter(String(e.target.value)) + } + className="w-full pl-8" + /> + +
+ ); + }; + + const getActionButton = () => { + if (currentView === "internal") { + return ( + + ); + } + return ( + + ); + }; const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) @@ -81,6 +235,26 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }); }; + const deleteInternalResource = async ( + resourceId: number, + siteId: number + ) => { + try { + await api.delete( + `/org/${orgId}/site/${siteId}/resource/${resourceId}` + ); + router.refresh(); + setIsDeleteModalOpen(false); + } catch (e) { + console.error(t("resourceErrorDelete"), e); + toast({ + variant: "destructive", + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("v")) + }); + } + }; + async function toggleResourceEnabled(val: boolean, resourceId: number) { const res = await api .post>( @@ -101,7 +275,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }); } - const columns: ColumnDef[] = [ + const proxyColumns: ColumnDef[] = [ { accessorKey: "name", header: ({ column }) => { @@ -118,35 +292,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { ); } }, - { - accessorKey: "site", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - - - ); - } - }, { accessorKey: "protocol", header: t("protocol"), @@ -225,10 +370,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { toggleResourceEnabled(val, row.original.id) } @@ -289,6 +436,163 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { } ]; + const internalColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "siteName", + header: t("siteName"), + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + + + ); + } + }, + { + accessorKey: "protocol", + header: t("protocol"), + cell: ({ row }) => { + const resourceRow = row.original; + return {resourceRow.protocol.toUpperCase()}; + } + }, + { + accessorKey: "proxyPort", + header: t("proxyPort"), + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + ); + } + }, + { + accessorKey: "destination", + header: t("resourcesTableDestination"), + cell: ({ row }) => { + const resourceRow = row.original; + const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`; + return ; + } + }, + + { + id: "actions", + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ + + + + + { + setSelectedInternalResource( + resourceRow + ); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + const proxyTable = useReactTable({ + data: resources, + columns: proxyColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setProxySorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setProxyColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setProxyGlobalFilter, + initialState: { + pagination: { + pageSize: 20, + pageIndex: 0 + } + }, + state: { + sorting: proxySorting, + columnFilters: proxyColumnFilters, + globalFilter: proxyGlobalFilter + } + }); + + const internalTable = useReactTable({ + data: internalResources, + columns: internalColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setInternalSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setInternalColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setInternalGlobalFilter, + initialState: { + pagination: { + pageSize: 20, + pageIndex: 0 + } + }, + state: { + sorting: internalSorting, + columnFilters: internalColumnFilters, + globalFilter: internalGlobalFilter + } + }); + return ( <> {selectedResource && ( @@ -320,11 +624,271 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { /> )} - { - router.push(`/${orgId}/settings/resources/create`); + {selectedInternalResource && ( + { + setIsDeleteModalOpen(val); + setSelectedInternalResource(null); + }} + dialog={ +
+

+ {t("resourceQuestionRemove", { + selectedResource: + selectedInternalResource?.name || + selectedInternalResource?.id + })} +

+ +

{t("resourceMessageRemove")}

+ +

{t("resourceMessageConfirm")}

+
+ } + buttonText={t("resourceDeleteConfirm")} + onConfirm={async () => + deleteInternalResource( + selectedInternalResource!.id, + selectedInternalResource!.siteId + ) + } + string={selectedInternalResource.name} + title={t("resourceDelete")} + /> + )} + +
+ + + +
+ {getSearchInput()} + + {env.flags.enableClients && ( + + + {t("resourcesTableProxyResources")} + + + {t("resourcesTableClientResources")} + + + )} +
+
+ {getActionButton()} +
+
+ + + + + {proxyTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers.map( + (header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ))} + + + {proxyTable.getRowModel().rows + ?.length ? ( + proxyTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t( + "resourcesTableNoProxyResourcesFound" + )} + + + )} + +
+
+ +
+
+ +
+ + + {t( + "resourcesTableTheseResourcesForUseWith" + )}{" "} + + {t("resourcesTableClients")} + + {" "} + {t( + "resourcesTableAndOnlyAccessibleInternally" + )} + + +
+ + + {internalTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers.map( + (header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ))} + + + {internalTable.getRowModel().rows + ?.length ? ( + internalTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t( + "resourcesTableNoInternalResourcesFound" + )} + + + )} + +
+
+ +
+
+
+
+
+
+ + {editingResource && ( + { + router.refresh(); + setEditingResource(null); + }} + /> + )} + + { + router.refresh(); }} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 68331ff9..af7d96fc 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -10,35 +10,22 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useDockerSocket } from "@app/hooks/useDockerSocket"; import { useTranslations } from "next-intl"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { RotateCw } from "lucide-react"; -import { createApiClient } from "@app/lib/api"; import { build } from "@server/build"; type ResourceInfoBoxType = {}; export default function ResourceInfoBox({}: ResourceInfoBoxType) { - const { resource, authInfo, site } = useResourceContext(); - const api = createApiClient(useEnvContext()); + const { resource, authInfo } = useResourceContext(); - const { isEnabled, isAvailable } = useDockerSocket(site!); const t = useTranslations(); const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; return ( - - - {t("resourceInfo")} - - - + + {resource.http ? ( <> @@ -71,12 +58,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { /> - - {t("site")} - - {resource.siteName} - - {/* {isEnabled && ( Socket @@ -117,7 +98,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { /> - {build == "oss" && ( + {/* {build == "oss" && ( {t("externalProxyEnabled")} @@ -130,7 +111,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - )} + )} */} )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index c8f6255c..9bb9919a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -49,6 +49,15 @@ import { UserType } from "@server/types/UserTypes"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Separator } from "@app/components/ui/separator"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -110,6 +119,14 @@ export default function ResourceAuthenticationPage() { resource.emailWhitelistEnabled ); + const [autoLoginEnabled, setAutoLoginEnabled] = useState( + resource.skipToIdpId !== null && resource.skipToIdpId !== undefined + ); + const [selectedIdpId, setSelectedIdpId] = useState( + resource.skipToIdpId || null + ); + const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]); + const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false); @@ -139,7 +156,8 @@ export default function ResourceAuthenticationPage() { resourceRolesResponse, usersResponse, resourceUsersResponse, - whitelist + whitelist, + idpsResponse ] = await Promise.all([ api.get>( `/org/${org?.org.orgId}/roles` @@ -155,7 +173,12 @@ export default function ResourceAuthenticationPage() { ), api.get>( `/resource/${resource.resourceId}/whitelist` - ) + ), + api.get< + AxiosResponse<{ + idps: { idpId: number; name: string }[]; + }> + >("/idp") ]); setAllRoles( @@ -200,6 +223,21 @@ export default function ResourceAuthenticationPage() { })) ); + setAllIdps( + idpsResponse.data.data.idps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })) + ); + + if ( + autoLoginEnabled && + !selectedIdpId && + idpsResponse.data.data.idps.length > 0 + ) { + setSelectedIdpId(idpsResponse.data.data.idps[0].idpId); + } + setPageLoading(false); } catch (e) { console.error(e); @@ -260,6 +298,16 @@ export default function ResourceAuthenticationPage() { try { setLoadingSaveUsersRoles(true); + // Validate that an IDP is selected if auto login is enabled + if (autoLoginEnabled && !selectedIdpId) { + toast({ + variant: "destructive", + title: t("error"), + description: t("selectIdpRequired") + }); + return; + } + const jobs = [ api.post(`/resource/${resource.resourceId}/roles`, { roleIds: data.roles.map((i) => parseInt(i.id)) @@ -268,14 +316,16 @@ export default function ResourceAuthenticationPage() { userIds: data.users.map((i) => i.id) }), api.post(`/resource/${resource.resourceId}`, { - sso: ssoEnabled + sso: ssoEnabled, + skipToIdpId: autoLoginEnabled ? selectedIdpId : null }) ]; await Promise.all(jobs); updateResource({ - sso: ssoEnabled + sso: ssoEnabled, + skipToIdpId: autoLoginEnabled ? selectedIdpId : null }); updateAuthInfo({ @@ -542,6 +592,89 @@ export default function ResourceAuthenticationPage() { /> )} + + {ssoEnabled && allIdps.length > 0 && ( +
+
+ { + setAutoLoginEnabled( + checked as boolean + ); + if ( + checked && + allIdps.length > 0 + ) { + setSelectedIdpId( + allIdps[0].id + ); + } else { + setSelectedIdpId( + null + ); + } + }} + /> +

+ {t( + "autoLoginExternalIdpDescription" + )} +

+
+ + {autoLoginEnabled && ( +
+ + +
+ )} +
+ )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index b4e14d64..8c5ee667 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -14,19 +14,6 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem -} from "@/components/ui/command"; -import { cn } from "@app/lib/cn"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@/components/ui/popover"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { ListSitesResponse } from "@server/routers/site"; import { useEffect, useState } from "react"; @@ -45,25 +32,11 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import CustomDomainInput from "../CustomDomainInput"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas"; -import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; import { ListDomainsResponse } from "@server/routers/domain"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { - UpdateResourceResponse, - updateResourceRule -} from "@server/routers/resource"; +import { UpdateResourceResponse } from "@server/routers/resource"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; import { Checkbox } from "@app/components/ui/checkbox"; @@ -81,12 +54,6 @@ import DomainPicker from "@app/components/DomainPicker"; import { Globe } from "lucide-react"; import { build } from "@server/build"; -const TransferFormSchema = z.object({ - siteId: z.number() -}); - -type TransferFormValues = z.infer; - export default function GeneralForm() { const [formKey, setFormKey] = useState(0); const params = useParams(); @@ -127,7 +94,7 @@ export default function GeneralForm() { name: z.string().min(1).max(255), domainId: z.string().optional(), proxyPort: z.number().int().min(1).max(65535).optional(), - enableProxy: z.boolean().optional() + // enableProxy: z.boolean().optional() }) .refine( (data) => { @@ -156,18 +123,11 @@ export default function GeneralForm() { subdomain: resource.subdomain ? resource.subdomain : undefined, domainId: resource.domainId || undefined, proxyPort: resource.proxyPort || undefined, - enableProxy: resource.enableProxy || false + // enableProxy: resource.enableProxy || false }, mode: "onChange" }); - const transferForm = useForm({ - resolver: zodResolver(TransferFormSchema), - defaultValues: { - siteId: resource.siteId ? Number(resource.siteId) : undefined - } - }); - useEffect(() => { const fetchSites = async () => { const res = await api.get>( @@ -221,9 +181,9 @@ export default function GeneralForm() { subdomain: data.subdomain, domainId: data.domainId, proxyPort: data.proxyPort, - ...(!resource.http && { - enableProxy: data.enableProxy - }) + // ...(!resource.http && { + // enableProxy: data.enableProxy + // }) } ) .catch((e) => { @@ -251,9 +211,9 @@ export default function GeneralForm() { subdomain: data.subdomain, fullDomain: resource.fullDomain, proxyPort: data.proxyPort, - ...(!resource.http && { - enableProxy: data.enableProxy - }), + // ...(!resource.http && { + // enableProxy: data.enableProxy + // }) }); router.refresh(); @@ -261,40 +221,6 @@ export default function GeneralForm() { setSaveLoading(false); } - async function onTransfer(data: TransferFormValues) { - setTransferLoading(true); - - const res = await api - .post(`resource/${resource?.resourceId}/transfer`, { - siteId: data.siteId - }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorTransfer"), - description: formatAxiosError( - e, - t("resourceErrorTransferDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("resourceTransferred"), - description: t("resourceTransferredDescription") - }); - router.refresh(); - - updateResource({ - siteName: - sites.find((site) => site.siteId === data.siteId)?.name || - "" - }); - } - setTransferLoading(false); - } - return ( !loadingPage && ( <> @@ -410,7 +336,7 @@ export default function GeneralForm() { )} /> - {build == "oss" && ( + {/* {build == "oss" && ( )} /> - )} + )} */} )} {resource.http && (
- +
@@ -466,7 +394,9 @@ export default function GeneralForm() { ) } > - Edit Domain + {t( + "resourceEditDomain" + )}
@@ -490,140 +420,6 @@ export default function GeneralForm() { - - - - - {t("resourceTransfer")} - - - {t("resourceTransferDescription")} - - - - - -
- - ( - - - {t("siteDestination")} - - - - - - - - - - - - {t( - "sitesNotFound" - )} - - - {sites.map( - ( - site - ) => ( - { - transferForm.setValue( - "siteId", - site.siteId - ); - setOpen( - false - ); - }} - > - { - site.name - } - - - ) - )} - - - - - - - )} - /> - - -
-
- - - - -
>( `/resource/${params.resourceId}`, @@ -44,19 +43,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } - // Fetch site info - if (resource.siteId) { - try { - const res = await internal.get>( - `/site/${resource.siteId}`, - await authCookieHeader() - ); - site = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } - } - try { const res = await internal.get< AxiosResponse @@ -119,7 +105,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index 7ab02c7e..c6584219 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -3,7 +3,6 @@ import { useEffect, useState, use } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -34,12 +33,12 @@ import { getPaginationRowModel, getCoreRowModel, useReactTable, - flexRender + flexRender, + Row } from "@tanstack/react-table"; import { Table, TableBody, - TableCaption, TableCell, TableHead, TableHeader, @@ -51,7 +50,7 @@ import { ArrayElement } from "@server/types/ArrayElement"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; -import { GetSiteResponse } from "@server/routers/site"; +import { GetSiteResponse, ListSitesResponse } from "@server/routers/site"; import { SettingsContainer, SettingsSection, @@ -59,28 +58,48 @@ import { SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionGrid + SettingsSectionForm } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { useRouter } from "next/navigation"; import { isTargetValid } from "@server/lib/validators"; import { tlsNameSchema } from "@server/lib/schemas"; -import { ChevronsUpDown } from "lucide-react"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger -} from "@app/components/ui/collapsible"; + CheckIcon, + ChevronsUpDown, + Settings, + Heart, + Check, + CircleCheck, + CircleX +} from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import { DockerManager, DockerState } from "@app/lib/docker"; +import { Container } from "@server/routers/site"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { Badge } from "@app/components/ui/badge"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), - port: z.coerce.number().int().positive() + port: z.coerce.number().int().positive(), + siteId: z.number().int().positive() }); const targetsSettingsSchema = z.object({ @@ -91,12 +110,13 @@ type LocalTarget = Omit< ArrayElement & { new?: boolean; updated?: boolean; + siteType: string | null; }, "protocol" >; export default function ReverseProxyTargets(props: { - params: Promise<{ resourceId: number }>; + params: Promise<{ resourceId: number; orgId: string }>; }) { const params = use(props.params); const t = useTranslations(); @@ -106,15 +126,48 @@ export default function ReverseProxyTargets(props: { const api = createApiClient(useEnvContext()); const [targets, setTargets] = useState([]); - const [site, setSite] = useState(); const [targetsToRemove, setTargetsToRemove] = useState([]); + const [sites, setSites] = useState([]); + const [dockerStates, setDockerStates] = useState>(new Map()); + + const initializeDockerForSite = async (siteId: number) => { + if (dockerStates.has(siteId)) { + return; // Already initialized + } + + const dockerManager = new DockerManager(api, siteId); + const dockerState = await dockerManager.initializeDocker(); + + setDockerStates(prev => new Map(prev.set(siteId, dockerState))); + }; + + const refreshContainersForSite = async (siteId: number) => { + const dockerManager = new DockerManager(api, siteId); + const containers = await dockerManager.fetchContainers(); + + setDockerStates(prev => { + const newMap = new Map(prev); + const existingState = newMap.get(siteId); + if (existingState) { + newMap.set(siteId, { ...existingState, containers }); + } + return newMap; + }); + }; + + const getDockerStateForSite = (siteId: number): DockerState => { + return dockerStates.get(siteId) || { + isEnabled: false, + isAvailable: false, + containers: [] + }; + }; const [httpsTlsLoading, setHttpsTlsLoading] = useState(false); const [targetsLoading, setTargetsLoading] = useState(false); const [proxySettingsLoading, setProxySettingsLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); - const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const router = useRouter(); const proxySettingsSchema = z.object({ @@ -167,6 +220,14 @@ export default function ReverseProxyTargets(props: { const watchedIp = addTargetForm.watch("ip"); const watchedPort = addTargetForm.watch("port"); + const watchedSiteId = addTargetForm.watch("siteId"); + + const handleContainerSelect = (hostname: string, port?: number) => { + addTargetForm.setValue("ip", hostname); + if (port) { + addTargetForm.setValue("port", port); + } + }; const tlsSettingsForm = useForm({ resolver: zodResolver(tlsSettingsSchema), @@ -216,28 +277,64 @@ export default function ReverseProxyTargets(props: { }; fetchTargets(); - const fetchSite = async () => { - try { - const res = await api.get>( - `/site/${resource.siteId}` - ); - - if (res.status === 200) { - setSite(res.data.data); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("siteErrorFetch"), - description: formatAxiosError( - err, - t("siteErrorFetchDescription") - ) + const fetchSites = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${params.orgId}/sites`) + .catch((e) => { + toast({ + variant: "destructive", + title: t("sitesErrorFetch"), + description: formatAxiosError( + e, + t("sitesErrorFetchDescription") + ) + }); }); + + if (res?.status === 200) { + setSites(res.data.data.sites); + + // Initialize Docker for newt sites + const newtSites = res.data.data.sites.filter(site => site.type === "newt"); + for (const site of newtSites) { + initializeDockerForSite(site.siteId); + } + + // If there's only one site, set it as the default in the form + if (res.data.data.sites.length) { + addTargetForm.setValue( + "siteId", + res.data.data.sites[0].siteId + ); + } } }; - fetchSite(); + fetchSites(); + + // const fetchSite = async () => { + // try { + // const res = await api.get>( + // `/site/${resource.siteId}` + // ); + // + // if (res.status === 200) { + // setSite(res.data.data); + // } + // } catch (err) { + // console.error(err); + // toast({ + // variant: "destructive", + // title: t("siteErrorFetch"), + // description: formatAxiosError( + // err, + // t("siteErrorFetchDescription") + // ) + // }); + // } + // }; + // fetchSite(); }, []); async function addTarget(data: z.infer) { @@ -246,7 +343,8 @@ export default function ReverseProxyTargets(props: { (target) => target.ip === data.ip && target.port === data.port && - target.method === data.method + target.method === data.method && + target.siteId === data.siteId ); if (isDuplicate) { @@ -258,34 +356,37 @@ export default function ReverseProxyTargets(props: { return; } - if (site && site.type == "wireguard" && site.subnet) { - // make sure that the target IP is within the site subnet - const targetIp = data.ip; - const subnet = site.subnet; - try { - if (!isIPInSubnet(targetIp, subnet)) { - toast({ - variant: "destructive", - title: t("targetWireGuardErrorInvalidIp"), - description: t( - "targetWireGuardErrorInvalidIpDescription" - ) - }); - return; - } - } catch (error) { - console.error(error); - toast({ - variant: "destructive", - title: t("targetWireGuardErrorInvalidIp"), - description: t("targetWireGuardErrorInvalidIpDescription") - }); - return; - } - } + // if (site && site.type == "wireguard" && site.subnet) { + // // make sure that the target IP is within the site subnet + // const targetIp = data.ip; + // const subnet = site.subnet; + // try { + // if (!isIPInSubnet(targetIp, subnet)) { + // toast({ + // variant: "destructive", + // title: t("targetWireGuardErrorInvalidIp"), + // description: t( + // "targetWireGuardErrorInvalidIpDescription" + // ) + // }); + // return; + // } + // } catch (error) { + // console.error(error); + // toast({ + // variant: "destructive", + // title: t("targetWireGuardErrorInvalidIp"), + // description: t("targetWireGuardErrorInvalidIpDescription") + // }); + // return; + // } + // } + + const site = sites.find((site) => site.siteId === data.siteId); const newTarget: LocalTarget = { ...data, + siteType: site?.type || null, enabled: true, targetId: new Date().getTime(), new: true, @@ -311,10 +412,16 @@ export default function ReverseProxyTargets(props: { }; async function updateTarget(targetId: number, data: Partial) { + const site = sites.find((site) => site.siteId === data.siteId); setTargets( targets.map((target) => target.targetId === targetId - ? { ...target, ...data, updated: true } + ? { + ...target, + ...data, + updated: true, + siteType: site?.type || null + } : target ) ); @@ -332,7 +439,8 @@ export default function ReverseProxyTargets(props: { ip: target.ip, port: target.port, method: target.method, - enabled: target.enabled + enabled: target.enabled, + siteId: target.siteId }; if (target.new) { @@ -403,6 +511,135 @@ export default function ReverseProxyTargets(props: { } const columns: ColumnDef[] = [ + { + accessorKey: "siteId", + header: t("site"), + cell: ({ row }) => { + const selectedSite = sites.find( + (site) => site.siteId === row.original.siteId + ); + + const handleContainerSelectForTarget = ( + hostname: string, + port?: number + ) => { + updateTarget(row.original.targetId, { + ...row.original, + ip: hostname + }); + if (port) { + updateTarget(row.original.targetId, { + ...row.original, + port: port + }); + } + }; + + return ( +
+ + + + + + + + + + {t("siteNotFound")} + + + {sites.map((site) => ( + { + updateTarget( + row.original + .targetId, + { + siteId: site.siteId + } + ); + }} + > + + {site.name} + + ))} + + + + + + {selectedSite && selectedSite.type === "newt" && (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })()} +
+ ); + } + }, + ...(resource.http + ? [ + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] + : []), { accessorKey: "ip", header: t("targetAddr"), @@ -412,6 +649,7 @@ export default function ReverseProxyTargets(props: { className="min-w-[150px]" onBlur={(e) => updateTarget(row.original.targetId, { + ...row.original, ip: e.target.value }) } @@ -428,6 +666,7 @@ export default function ReverseProxyTargets(props: { className="min-w-[100px]" onBlur={(e) => updateTarget(row.original.targetId, { + ...row.original, port: parseInt(e.target.value, 10) }) } @@ -451,7 +690,7 @@ export default function ReverseProxyTargets(props: { // // // ), - // }, + // }, { accessorKey: "enabled", header: t("enabled"), @@ -459,7 +698,10 @@ export default function ReverseProxyTargets(props: { - updateTarget(row.original.targetId, { enabled: val }) + updateTarget(row.original.targetId, { + ...row.original, + enabled: val + }) } /> ) @@ -489,33 +731,6 @@ export default function ReverseProxyTargets(props: { } ]; - if (resource.http) { - const methodCol: ColumnDef = { - accessorKey: "method", - header: t("method"), - cell: ({ row }) => ( - - ) - }; - - // add this to the first column - columns.unshift(methodCol); - } - const table = useReactTable({ data: targets, columns, @@ -545,221 +760,355 @@ export default function ReverseProxyTargets(props: { - -
+
+ - {targets.length >= 2 && ( +
( - - - { - field.onChange(val); - }} - /> - + + + {t("site")} + +
+ + + + + + + + + + + + {t( + "siteNotFound" + )} + + + {sites.map( + ( + site + ) => ( + { + addTargetForm.setValue( + "siteId", + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + {field.value && + (() => { + const selectedSite = + sites.find( + (site) => + site.siteId === + field.value + ); + return selectedSite && + selectedSite.type === + "newt" ? (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })() : null; + })()} +
+
)} /> - )} - - - -
- -
- {resource.http && ( + {resource.http && ( + ( + + + {t("method")} + + + + + + + )} + /> + )} + ( - + - {t("method")} + {t("targetAddr")} - + )} /> - )} - - ( - - - {t("targetAddr")} - - - - - {site && site.type == "newt" && ( - { - addTargetForm.setValue( - "ip", - hostname - ); - if (port) { - addTargetForm.setValue( - "port", - port - ); - } - }} - /> - )} - - - )} - /> - ( - - - {t("targetPort")} - - - - - - - )} - /> - -
-
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - ( + + + {t("targetPort")} + + + + + + + )} + /> +
+ {t("targetSubmit")} + +
+ + +
+ + {targets.length > 0 ? ( + <> +
+ {t("targetsList")} +
+ +
+ + ( + + + { + field.onChange( + val + ); + }} + /> + + + )} + /> + + +
+
+ + + {table + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers.map( + (header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ))} + + + {table.getRowModel().rows?.length ? ( + table + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("targetNoOne")} + + + )} + + {/* */} + {/* {t('targetNoOneDescription')} */} + {/* */} +
+
+ + ) : ( +
+

+ {t("targetNoOne")} +

+
+ )}
@@ -885,7 +1234,7 @@ export default function ReverseProxyTargets(props: { proxySettingsLoading } > - {t("saveAllSettings")} + {t("saveSettings")} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index a8d926fe..438b8917 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -42,9 +42,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { subdomainSchema } from "@server/lib/schemas"; import { ListDomainsResponse } from "@server/routers/domain"; -import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; import { Command, CommandEmpty, @@ -66,10 +64,33 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import DomainPicker from "@app/components/DomainPicker"; import { build } from "@server/build"; +import { ContainersSelector } from "@app/components/ContainersSelector"; +import { + ColumnDef, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + getCoreRowModel, + useReactTable, + flexRender, + Row +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { Switch } from "@app/components/ui/switch"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { isTargetValid } from "@server/lib/validators"; +import { ListTargetsResponse } from "@server/routers/target"; +import { DockerManager, DockerState } from "@app/lib/docker"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), - siteId: z.number(), http: z.boolean() }); @@ -80,8 +101,15 @@ const httpResourceFormSchema = z.object({ const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), - proxyPort: z.number().int().min(1).max(65535), - enableProxy: z.boolean().default(false) + proxyPort: z.number().int().min(1).max(65535) + // enableProxy: z.boolean().default(false) +}); + +const addTargetSchema = z.object({ + ip: z.string().refine(isTargetValid), + method: z.string().nullable(), + port: z.coerce.number().int().positive(), + siteId: z.number().int().positive() }); type BaseResourceFormValues = z.infer; @@ -97,6 +125,15 @@ interface ResourceTypeOption { disabled?: boolean; } +type LocalTarget = Omit< + ArrayElement & { + new?: boolean; + updated?: boolean; + siteType: string | null; + }, + "protocol" +>; + export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -113,6 +150,11 @@ export default function Page() { const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); + // Target management state + const [targets, setTargets] = useState([]); + const [targetsToRemove, setTargetsToRemove] = useState([]); + const [dockerStates, setDockerStates] = useState>(new Map()); + const resourceTypes: ReadonlyArray = [ { id: "http", @@ -147,11 +189,128 @@ export default function Page() { resolver: zodResolver(tcpUdpResourceFormSchema), defaultValues: { protocol: "tcp", - proxyPort: undefined, - enableProxy: false + proxyPort: undefined + // enableProxy: false } }); + const addTargetForm = useForm({ + resolver: zodResolver(addTargetSchema), + defaultValues: { + ip: "", + method: baseForm.watch("http") ? "http" : null, + port: "" as any as number + } as z.infer + }); + + const watchedIp = addTargetForm.watch("ip"); + const watchedPort = addTargetForm.watch("port"); + const watchedSiteId = addTargetForm.watch("siteId"); + + const handleContainerSelect = (hostname: string, port?: number) => { + addTargetForm.setValue("ip", hostname); + if (port) { + addTargetForm.setValue("port", port); + } + }; + + const initializeDockerForSite = async (siteId: number) => { + if (dockerStates.has(siteId)) { + return; // Already initialized + } + + const dockerManager = new DockerManager(api, siteId); + const dockerState = await dockerManager.initializeDocker(); + + setDockerStates(prev => new Map(prev.set(siteId, dockerState))); + }; + + const refreshContainersForSite = async (siteId: number) => { + const dockerManager = new DockerManager(api, siteId); + const containers = await dockerManager.fetchContainers(); + + setDockerStates(prev => { + const newMap = new Map(prev); + const existingState = newMap.get(siteId); + if (existingState) { + newMap.set(siteId, { ...existingState, containers }); + } + return newMap; + }); + }; + + const getDockerStateForSite = (siteId: number): DockerState => { + return dockerStates.get(siteId) || { + isEnabled: false, + isAvailable: false, + containers: [] + }; + }; + + async function addTarget(data: z.infer) { + // Check if target with same IP, port and method already exists + const isDuplicate = targets.some( + (target) => + target.ip === data.ip && + target.port === data.port && + target.method === data.method && + target.siteId === data.siteId + ); + + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("targetErrorDuplicate"), + description: t("targetErrorDuplicateDescription") + }); + return; + } + + const site = sites.find((site) => site.siteId === data.siteId); + + const newTarget: LocalTarget = { + ...data, + siteType: site?.type || null, + enabled: true, + targetId: new Date().getTime(), + new: true, + resourceId: 0 // Will be set when resource is created + }; + + setTargets([...targets, newTarget]); + addTargetForm.reset({ + ip: "", + method: baseForm.watch("http") ? "http" : null, + port: "" as any as number + }); + } + + const removeTarget = (targetId: number) => { + setTargets([ + ...targets.filter((target) => target.targetId !== targetId) + ]); + + if (!targets.find((target) => target.targetId === targetId)?.new) { + setTargetsToRemove([...targetsToRemove, targetId]); + } + }; + + async function updateTarget(targetId: number, data: Partial) { + const site = sites.find((site) => site.siteId === data.siteId); + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...data, + updated: true, + siteType: site?.type || null + } + : target + ) + ); + } + async function onSubmit() { setCreateLoading(true); @@ -161,7 +320,6 @@ export default function Page() { try { const payload = { name: baseData.name, - siteId: baseData.siteId, http: baseData.http }; @@ -176,15 +334,15 @@ export default function Page() { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { protocol: tcpUdpData.protocol, - proxyPort: tcpUdpData.proxyPort, - enableProxy: tcpUdpData.enableProxy + proxyPort: tcpUdpData.proxyPort + // enableProxy: tcpUdpData.enableProxy }); } const res = await api .put< AxiosResponse - >(`/org/${orgId}/site/${baseData.siteId}/resource/`, payload) + >(`/org/${orgId}/resource/`, payload) .catch((e) => { toast({ variant: "destructive", @@ -200,18 +358,45 @@ export default function Page() { const id = res.data.data.resourceId; setResourceId(id); + // Create targets if any exist + if (targets.length > 0) { + try { + for (const target of targets) { + const data = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId + }; + + await api.put(`/resource/${id}/target`, data); + } + } catch (targetError) { + console.error("Error creating targets:", targetError); + toast({ + variant: "destructive", + title: t("targetErrorCreate"), + description: formatAxiosError( + targetError, + t("targetErrorCreateDescription") + ) + }); + } + } + if (isHttp) { router.push(`/${orgId}/settings/resources/${id}`); } else { const tcpUdpData = tcpUdpForm.getValues(); // Only show config snippets if enableProxy is explicitly true - if (tcpUdpData.enableProxy === true) { - setShowSnippets(true); - router.refresh(); - } else { - // If enableProxy is false or undefined, go directly to resource page - router.push(`/${orgId}/settings/resources/${id}`); - } + // if (tcpUdpData.enableProxy === true) { + setShowSnippets(true); + router.refresh(); + // } else { + // // If enableProxy is false or undefined, go directly to resource page + // router.push(`/${orgId}/settings/resources/${id}`); + // } } } } catch (e) { @@ -249,8 +434,16 @@ export default function Page() { if (res?.status === 200) { setSites(res.data.data.sites); - if (res.data.data.sites.length > 0) { - baseForm.setValue( + // Initialize Docker for newt sites + for (const site of res.data.data.sites) { + if (site.type === "newt") { + initializeDockerForSite(site.siteId); + } + } + + // If there's only one site, set it as the default in the form + if (res.data.data.sites.length) { + addTargetForm.setValue( "siteId", res.data.data.sites[0].siteId ); @@ -292,6 +485,216 @@ export default function Page() { load(); }, []); + const columns: ColumnDef[] = [ + { + accessorKey: "siteId", + header: t("site"), + cell: ({ row }) => { + const selectedSite = sites.find( + (site) => site.siteId === row.original.siteId + ); + + const handleContainerSelectForTarget = ( + hostname: string, + port?: number + ) => { + updateTarget(row.original.targetId, { + ...row.original, + ip: hostname + }); + if (port) { + updateTarget(row.original.targetId, { + ...row.original, + port: port + }); + } + }; + + return ( +
+ + + + + + + + + + {t("siteNotFound")} + + + {sites.map((site) => ( + { + updateTarget( + row.original + .targetId, + { + siteId: site.siteId + } + ); + }} + > + + {site.name} + + ))} + + + + + + {selectedSite && selectedSite.type === "newt" && (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })()} +
+ ); + } + }, + ...(baseForm.watch("http") + ? [ + { + accessorKey: "method", + header: t("method"), + cell: ({ row }: { row: Row }) => ( + + ) + } + ] + : []), + { + accessorKey: "ip", + header: t("targetAddr"), + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { + ...row.original, + ip: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "port", + header: t("targetPort"), + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { + ...row.original, + port: parseInt(e.target.value, 10) + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: t("enabled"), + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { + ...row.original, + enabled: val + }) + } + /> + ) + }, + { + id: "actions", + cell: ({ row }) => ( + <> +
+ +
+ + ) + } + ]; + + const table = useReactTable({ + data: targets, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize: 1000 + } + } + }); + return ( <>
@@ -348,104 +751,6 @@ export default function Page() { )} /> - - ( - - - {t("site")} - - - - - - - - - - - - - {t( - "siteNotFound" - )} - - - {sites.map( - ( - site - ) => ( - { - baseForm.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - {t( - "siteSelectionDescription" - )} - - - )} - /> @@ -471,6 +776,13 @@ export default function Page() { "http", value === "http" ); + // Update method default when switching resource type + addTargetForm.setValue( + "method", + value === "http" + ? "http" + : null + ); }} cols={2} /> @@ -616,7 +928,7 @@ export default function Page() { )} /> - {build == "oss" && ( + {/* {build == "oss" && ( )} /> - )} + )} */} @@ -662,6 +974,379 @@ export default function Page() { )} + + + + {t("targets")} + + + {t("targetsDescription")} + + + +
+
+ +
+ ( + + + {t("site")} + +
+ + + + + + + + + + + + {t( + "siteNotFound" + )} + + + {sites.map( + ( + site + ) => ( + { + addTargetForm.setValue( + "siteId", + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + {field.value && + (() => { + const selectedSite = + sites.find( + ( + site + ) => + site.siteId === + field.value + ); + return selectedSite && + selectedSite.type === + "newt" ? (() => { + const dockerState = getDockerStateForSite(selectedSite.siteId); + return ( + refreshContainersForSite(selectedSite.siteId)} + /> + ); + })() : null; + })()} +
+ +
+ )} + /> + + {baseForm.watch("http") && ( + ( + + + {t( + "method" + )} + + + + + + + )} + /> + )} + + ( + + + {t( + "targetAddr" + )} + + + + + + + )} + /> + ( + + + {t( + "targetPort" + )} + + + + + + + )} + /> + +
+
+ +
+ + {targets.length > 0 ? ( + <> +
+ {t("targetsList")} +
+
+ + + {table + .getHeaderGroups() + .map( + ( + headerGroup + ) => ( + + {headerGroup.headers.map( + ( + header + ) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ) + )} + + + {table.getRowModel() + .rows?.length ? ( + table + .getRowModel() + .rows.map( + (row) => ( + + {row + .getVisibleCells() + .map( + ( + cell + ) => ( + + {flexRender( + cell + .column + .columnDef + .cell, + cell.getContext() + )} + + ) + )} + + ) + ) + ) : ( + + + {t( + "targetNoOne" + )} + + + )} + +
+
+ + ) : ( +
+

+ {t("targetNoOne")} +

+
+ )} +
+
+
diff --git a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx index 6094f167..36ab1727 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/SiteInfoCard.tsx @@ -33,9 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { return ( - - {t("siteInfo")} - + {(site.type == "newt" || site.type == "wireguard") && ( <> diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index f92a5090..8bd8dc4b 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -38,12 +38,14 @@ import { Tag, TagInput } from "@app/components/tags/tag-input"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), dockerSocketEnabled: z.boolean().optional(), - remoteSubnets: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional() + remoteSubnets: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional() }); type GeneralFormValues = z.infer; @@ -55,7 +57,9 @@ export default function GeneralPage() { const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); - const [activeCidrTagIndex, setActiveCidrTagIndex] = useState(null); + const [activeCidrTagIndex, setActiveCidrTagIndex] = useState( + null + ); const router = useRouter(); const t = useTranslations(); @@ -66,10 +70,10 @@ export default function GeneralPage() { name: site?.name, dockerSocketEnabled: site?.dockerSocketEnabled ?? false, remoteSubnets: site?.remoteSubnets - ? site.remoteSubnets.split(',').map((subnet, index) => ({ - id: subnet.trim(), - text: subnet.trim() - })) + ? site.remoteSubnets.split(",").map((subnet, index) => ({ + id: subnet.trim(), + text: subnet.trim() + })) : [] }, mode: "onChange" @@ -82,7 +86,10 @@ export default function GeneralPage() { .post(`/site/${site?.siteId}`, { name: data.name, dockerSocketEnabled: data.dockerSocketEnabled, - remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' + remoteSubnets: + data.remoteSubnets + ?.map((subnet) => subnet.text) + .join(",") || "" }) .catch((e) => { toast({ @@ -98,7 +105,8 @@ export default function GeneralPage() { updateSite({ name: data.name, dockerSocketEnabled: data.dockerSocketEnabled, - remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' + remoteSubnets: + data.remoteSubnets?.map((subnet) => subnet.text).join(",") || "" }); toast({ @@ -145,42 +153,64 @@ export default function GeneralPage() { )} /> - ( - - {t("remoteSubnets")} - - { - form.setValue( - "remoteSubnets", - newSubnets as Tag[] - ); - }} - validateTag={(tag) => { - // Basic CIDR validation regex - const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; - return cidrRegex.test(tag); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("remoteSubnetsDescription")} - - - - )} - /> + {env.flags.enableClients && + site.type === "newt" ? ( + ( + + + {t("remoteSubnets")} + + + { + form.setValue( + "remoteSubnets", + newSubnets as Tag[] + ); + }} + validateTag={(tag) => { + // Basic CIDR validation regex + const cidrRegex = + /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + return cidrRegex.test( + tag + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t( + "remoteSubnetsDescription" + )} + + + + )} + /> + ) : null} {site && site.type === "newt" && ( {t("siteConfiguration")}

-
+
{t("setupToken")} - + + + {t("setupTokenDescription")} + )} diff --git a/src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx b/src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx new file mode 100644 index 00000000..c489a759 --- /dev/null +++ b/src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { GenerateOidcUrlResponse } from "@server/routers/idp"; +import { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { + Card, + CardHeader, + CardTitle, + CardContent, + CardDescription +} from "@app/components/ui/card"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type AutoLoginHandlerProps = { + resourceId: number; + skipToIdpId: number; + redirectUrl: string; +}; + +export default function AutoLoginHandler({ + resourceId, + skipToIdpId, + redirectUrl +}: AutoLoginHandlerProps) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function initiateAutoLogin() { + setLoading(true); + + try { + const res = await api.post< + AxiosResponse + >(`/auth/idp/${skipToIdpId}/oidc/generate-url`, { + redirectUrl + }); + + if (res.data.data.redirectUrl) { + // Redirect to the IDP for authentication + window.location.href = res.data.data.redirectUrl; + } else { + setError(t("autoLoginErrorNoRedirectUrl")); + } + } catch (e) { + console.error("Failed to generate OIDC URL:", e); + setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl"))); + } finally { + setLoading(false); + } + } + + initiateAutoLogin(); + }, []); + + return ( +
+ + + {t("autoLoginTitle")} + {t("autoLoginDescription")} + + + {loading && ( +
+ + {t("autoLoginProcessing")} +
+ )} + {!loading && !error && ( +
+ + {t("autoLoginRedirecting")} +
+ )} + {error && ( + + + + {t("autoLoginError")} + {error} + + + )} +
+
+
+ ); +} diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 9032ae18..347d3586 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -15,6 +15,7 @@ import AccessToken from "./AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; import { LoginFormIDP } from "@app/components/LoginForm"; import { ListIdpsResponse } from "@server/routers/idp"; +import AutoLoginHandler from "./AutoLoginHandler"; export const dynamic = "force-dynamic"; @@ -30,11 +31,13 @@ export default async function ResourceAuthPage(props: { const env = pullEnv(); + const authHeader = await authCookieHeader(); + let authInfo: GetResourceAuthInfoResponse | undefined; try { const res = await internal.get< AxiosResponse - >(`/resource/${params.resourceId}/auth`, await authCookieHeader()); + >(`/resource/${params.resourceId}/auth`, authHeader); if (res && res.status === 200) { authInfo = res.data.data; @@ -62,10 +65,9 @@ export default async function ResourceAuthPage(props: { const redirectPort = new URL(searchParams.redirect).port; const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`; - if (serverResourceHost === redirectHost) { redirectUrl = searchParams.redirect; - } else if ( serverResourceHostWithPort === redirectHost ) { + } else if (serverResourceHostWithPort === redirectHost) { redirectUrl = searchParams.redirect; } } catch (e) {} @@ -144,6 +146,19 @@ export default async function ResourceAuthPage(props: { name: idp.name })) as LoginFormIDP[]; + if (authInfo.skipToIdpId && authInfo.skipToIdpId !== null) { + const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId); + if (idp) { + return ( + + ); + } + } + return ( <> {userIsUnauthorized && isSSOOnly ? ( diff --git a/src/components/ContainersSelector.tsx b/src/components/ContainersSelector.tsx index 0f09fb5a..7ed31b62 100644 --- a/src/components/ContainersSelector.tsx +++ b/src/components/ContainersSelector.tsx @@ -43,35 +43,30 @@ import { } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Search, RefreshCw, Filter, Columns } from "lucide-react"; -import { GetSiteResponse, Container } from "@server/routers/site"; -import { useDockerSocket } from "@app/hooks/useDockerSocket"; +import { Container } from "@server/routers/site"; import { useTranslations } from "next-intl"; - -// Type definitions based on the JSON structure +import { FaDocker } from "react-icons/fa"; interface ContainerSelectorProps { - site: GetSiteResponse; + site: { siteId: number; name: string; type: string }; + containers: Container[]; + isAvailable: boolean; onContainerSelect?: (hostname: string, port?: number) => void; + onRefresh?: () => void; } export const ContainersSelector: FC = ({ site, - onContainerSelect + containers, + isAvailable, + onContainerSelect, + onRefresh }) => { const [open, setOpen] = useState(false); const t = useTranslations(); - const { isAvailable, containers, fetchContainers } = useDockerSocket(site); - - useEffect(() => { - console.log("DockerSocket isAvailable:", isAvailable); - if (isAvailable) { - fetchContainers(); - } - }, [isAvailable]); - - if (!site || !isAvailable) { + if (!site || !isAvailable || site.type !== "newt") { return null; } @@ -84,13 +79,14 @@ export const ContainersSelector: FC = ({ return ( <> - setOpen(true)} + title={t("viewDockerContainers")} > - {t("viewDockerContainers")} - + + @@ -106,7 +102,7 @@ export const ContainersSelector: FC = ({ fetchContainers()} + onRefresh={onRefresh || (() => {})} />
@@ -263,7 +259,9 @@ const DockerContainersTable: FC<{ size="sm" className="h-6 px-2 text-xs hover:bg-muted" > - {t("containerLabelsCount", { count: labelEntries.length })} + {t("containerLabelsCount", { + count: labelEntries.length + })} @@ -279,7 +277,10 @@ const DockerContainersTable: FC<{ {key}
- {value || t("containerLabelEmpty")} + {value || + t( + "containerLabelEmpty" + )}
))} @@ -316,7 +317,9 @@ const DockerContainersTable: FC<{ onContainerSelect(row.original, ports[0])} + onClick={() => + onContainerSelect(row.original, ports[0]) + } disabled={row.original.state !== "running"} > {t("select")} @@ -415,9 +420,7 @@ const DockerContainersTable: FC<{ hideStoppedContainers) && containers.length > 0 ? ( <> -

- {t("noContainersMatchingFilters")} -

+

{t("noContainersMatchingFilters")}

{hideContainersWithoutPorts && (
@@ -463,7 +464,9 @@ const DockerContainersTable: FC<{
setSearchInput(event.target.value) @@ -473,7 +476,10 @@ const DockerContainersTable: FC<{ {searchInput && table.getFilteredRowModel().rows.length > 0 && (
- {t("searchResultsCount", { count: table.getFilteredRowModel().rows.length })} + {t("searchResultsCount", { + count: table.getFilteredRowModel().rows + .length + })}
)}
@@ -644,7 +650,9 @@ const DockerContainersTable: FC<{ {t("searching")} ) : ( - t("noContainersFoundMatching", { filter: globalFilter }) + t("noContainersFoundMatching", { + filter: globalFilter + }) )} diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx new file mode 100644 index 00000000..3c4841d7 --- /dev/null +++ b/src/components/CreateInternalResourceDialog.tsx @@ -0,0 +1,422 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { toast } from "@app/hooks/useToast"; +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 { cn } from "@app/lib/cn"; + +type Site = ListSitesResponse["sites"][0]; + +type CreateInternalResourceDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; + orgId: string; + sites: Site[]; + onSuccess?: () => void; +}; + +export default function CreateInternalResourceDialog({ + open, + setOpen, + orgId, + sites, + onSuccess +}: CreateInternalResourceDialogProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isSubmitting, setIsSubmitting] = useState(false); + + const formSchema = z.object({ + name: z + .string() + .min(1, t("createInternalResourceDialogNameRequired")) + .max(255, t("createInternalResourceDialogNameMaxLength")), + siteId: z.number().int().positive(t("createInternalResourceDialogPleaseSelectSite")), + protocol: z.enum(["tcp", "udp"]), + proxyPort: z + .number() + .int() + .positive() + .min(1, t("createInternalResourceDialogProxyPortMin")) + .max(65535, t("createInternalResourceDialogProxyPortMax")), + destinationIp: z.string().ip(t("createInternalResourceDialogInvalidIPAddressFormat")), + destinationPort: z + .number() + .int() + .positive() + .min(1, t("createInternalResourceDialogDestinationPortMin")) + .max(65535, t("createInternalResourceDialogDestinationPortMax")) + }); + + type FormData = z.infer; + + const availableSites = sites.filter( + (site) => site.type === "newt" && site.subnet + ); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + siteId: availableSites[0]?.siteId || 0, + protocol: "tcp", + proxyPort: undefined, + destinationIp: "", + destinationPort: undefined + } + }); + + useEffect(() => { + if (open && availableSites.length > 0) { + form.reset({ + name: "", + siteId: availableSites[0].siteId, + protocol: "tcp", + proxyPort: undefined, + destinationIp: "", + destinationPort: undefined + }); + } + }, [open]); + + 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 + }); + + toast({ + title: t("createInternalResourceDialogSuccess"), + description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), + variant: "default" + }); + + onSuccess?.(); + setOpen(false); + } catch (error) { + console.error("Error creating internal resource:", error); + toast({ + title: t("createInternalResourceDialogError"), + description: formatAxiosError( + error, + t("createInternalResourceDialogFailedToCreateInternalResource") + ), + variant: "destructive" + }); + } finally { + setIsSubmitting(false); + } + }; + + if (availableSites.length === 0) { + return ( + + + + {t("createInternalResourceDialogNoSitesAvailable")} + + {t("createInternalResourceDialogNoSitesAvailableDescription")} + + + + + + + + ); + } + + return ( + + + + {t("createInternalResourceDialogCreateClientResource")} + + {t("createInternalResourceDialogCreateClientResourceDescription")} + + + +
+ + {/* Resource Properties Form */} +
+

+ {t("createInternalResourceDialogResourceProperties")} +

+
+ ( + + {t("createInternalResourceDialogName")} + + + + + + )} + /> + +
+ ( + + {t("createInternalResourceDialogSite")} + + + + + + + + + + + {t("createInternalResourceDialogNoSitesFound")} + + {availableSites.map((site) => ( + { + field.onChange(site.siteId); + }} + > + + {site.name} + + ))} + + + + + + + + )} + /> + + ( + + + {t("createInternalResourceDialogProtocol")} + + + + + )} + /> +
+ + ( + + {t("createInternalResourceDialogSitePort")} + + + field.onChange( + e.target.value === "" ? undefined : parseInt(e.target.value) + ) + } + /> + + + {t("createInternalResourceDialogSitePortDescription")} + + + + )} + /> +
+
+ + {/* Target Configuration Form */} +
+

+ {t("createInternalResourceDialogTargetConfiguration")} +

+
+
+ ( + + + {t("createInternalResourceDialogDestinationIP")} + + + + + + {t("createInternalResourceDialogDestinationIPDescription")} + + + + )} + /> + + ( + + + {t("createInternalResourceDialogDestinationPort")} + + + + field.onChange( + e.target.value === "" ? undefined : parseInt(e.target.value) + ) + } + /> + + + {t("createInternalResourceDialogDestinationPortDescription")} + + + + )} + /> +
+
+
+
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 5f4104ea..1fc856c9 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -3,13 +3,28 @@ import { useState, useEffect, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; import { AlertCircle, CheckCircle2, Building2, Zap, + Check, + ChevronsUpDown, ArrowUpDown } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -19,9 +34,9 @@ import { toast } from "@/hooks/useToast"; import { ListDomainsResponse } from "@server/routers/domain/listDomains"; import { AxiosResponse } from "axios"; import { cn } from "@/lib/cn"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; type OrganizationDomain = { domainId: string; @@ -39,17 +54,15 @@ type AvailableOption = { type DomainOption = { id: string; domain: string; - type: "organization" | "provided"; + type: "organization" | "provided" | "provided-search"; verified?: boolean; domainType?: "ns" | "cname" | "wildcard"; domainId?: string; domainNamespaceId?: string; - subdomain?: string; }; -interface DomainPickerProps { +interface DomainPicker2Props { orgId: string; - cols?: number; onDomainChange?: (domainInfo: { domainId: string; domainNamespaceId?: string; @@ -58,34 +71,37 @@ interface DomainPickerProps { fullDomain: string; baseDomain: string; }) => void; + cols?: number; } -export default function DomainPicker({ +export default function DomainPicker2({ orgId, - cols, - onDomainChange -}: DomainPickerProps) { + onDomainChange, + cols = 2 +}: DomainPicker2Props) { const { env } = useEnvContext(); const api = createApiClient({ env }); const t = useTranslations(); - const [userInput, setUserInput] = useState(""); - const [selectedOption, setSelectedOption] = useState( - null - ); + const [subdomainInput, setSubdomainInput] = useState(""); + const [selectedBaseDomain, setSelectedBaseDomain] = + useState(null); const [availableOptions, setAvailableOptions] = useState( [] ); - const [isChecking, setIsChecking] = useState(false); const [organizationDomains, setOrganizationDomains] = useState< OrganizationDomain[] >([]); const [loadingDomains, setLoadingDomains] = useState(false); + const [open, setOpen] = useState(false); + + // Provided domain search states + const [userInput, setUserInput] = useState(""); + const [isChecking, setIsChecking] = useState(false); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [activeTab, setActiveTab] = useState< - "all" | "organization" | "provided" - >("all"); const [providedDomainsShown, setProvidedDomainsShown] = useState(3); + const [selectedProvidedDomain, setSelectedProvidedDomain] = + useState(null); useEffect(() => { const loadOrganizationDomains = async () => { @@ -107,6 +123,41 @@ export default function DomainPicker({ type: domain.type as "ns" | "cname" | "wildcard" })); setOrganizationDomains(domains); + + // Auto-select first available domain + if (domains.length > 0) { + // Select the first organization domain + const firstOrgDomain = domains[0]; + const domainOption: DomainOption = { + id: `org-${firstOrgDomain.domainId}`, + domain: firstOrgDomain.baseDomain, + type: "organization", + verified: firstOrgDomain.verified, + domainType: firstOrgDomain.type, + domainId: firstOrgDomain.domainId + }; + setSelectedBaseDomain(domainOption); + + onDomainChange?.({ + domainId: firstOrgDomain.domainId, + type: "organization", + subdomain: undefined, + fullDomain: firstOrgDomain.baseDomain, + baseDomain: firstOrgDomain.baseDomain + }); + } else if (build === "saas" || build === "enterprise") { + // If no organization domains, select the provided domain option + const domainOptionText = + build === "enterprise" + ? "Provided Domain" + : "Free Provided Domain"; + const freeDomainOption: DomainOption = { + id: "provided-search", + domain: domainOptionText, + type: "provided-search" + }; + setSelectedBaseDomain(freeDomainOption); + } } } catch (error) { console.error("Failed to load organization domains:", error); @@ -123,135 +174,131 @@ export default function DomainPicker({ loadOrganizationDomains(); }, [orgId, api]); - // Generate domain options based on user input - const generateDomainOptions = (): DomainOption[] => { + const checkAvailability = useCallback( + async (input: string) => { + if (!input.trim()) { + setAvailableOptions([]); + setIsChecking(false); + return; + } + + setIsChecking(true); + try { + const checkSubdomain = input + .toLowerCase() + .replace(/\./g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-"); + } catch (error) { + console.error("Failed to check domain availability:", error); + setAvailableOptions([]); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to check domain availability" + }); + } finally { + setIsChecking(false); + } + }, + [api] + ); + + const debouncedCheckAvailability = useCallback( + debounce(checkAvailability, 500), + [checkAvailability] + ); + + useEffect(() => { + if (selectedBaseDomain?.type === "provided-search") { + setProvidedDomainsShown(3); + setSelectedProvidedDomain(null); + + if (userInput.trim()) { + setIsChecking(true); + debouncedCheckAvailability(userInput); + } else { + setAvailableOptions([]); + setIsChecking(false); + } + } + }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); + + const generateDropdownOptions = (): DomainOption[] => { const options: DomainOption[] = []; - if (!userInput.trim()) return options; - - // Add organization domain options organizationDomains.forEach((orgDomain) => { - if (orgDomain.type === "cname") { - // For CNAME domains, check if the user input matches exactly - if ( - orgDomain.baseDomain.toLowerCase() === - userInput.toLowerCase() - ) { - options.push({ - id: `org-${orgDomain.domainId}`, - domain: orgDomain.baseDomain, - type: "organization", - verified: orgDomain.verified, - domainType: "cname", - domainId: orgDomain.domainId - }); - } - } else if (orgDomain.type === "ns") { - // For NS domains, check if the user input could be a subdomain - const userInputLower = userInput.toLowerCase(); - const baseDomainLower = orgDomain.baseDomain.toLowerCase(); - - // Check if user input ends with the base domain - if (userInputLower.endsWith(`.${baseDomainLower}`)) { - const subdomain = userInputLower.slice( - 0, - -(baseDomainLower.length + 1) - ); - options.push({ - id: `org-${orgDomain.domainId}`, - domain: userInput, - type: "organization", - verified: orgDomain.verified, - domainType: "ns", - domainId: orgDomain.domainId, - subdomain: subdomain - }); - } else if (userInputLower === baseDomainLower) { - // Exact match for base domain - options.push({ - id: `org-${orgDomain.domainId}`, - domain: orgDomain.baseDomain, - type: "organization", - verified: orgDomain.verified, - domainType: "ns", - domainId: orgDomain.domainId - }); - } - } else if (orgDomain.type === "wildcard") { - // For wildcard domains, allow the base domain or multiple levels up - const userInputLower = userInput.toLowerCase(); - const baseDomainLower = orgDomain.baseDomain.toLowerCase(); - - // Check if user input is exactly the base domain - if (userInputLower === baseDomainLower) { - options.push({ - id: `org-${orgDomain.domainId}`, - domain: orgDomain.baseDomain, - type: "organization", - verified: orgDomain.verified, - domainType: "wildcard", - domainId: orgDomain.domainId - }); - } - // Check if user input ends with the base domain (allows multiple level subdomains) - else if (userInputLower.endsWith(`.${baseDomainLower}`)) { - const subdomain = userInputLower.slice( - 0, - -(baseDomainLower.length + 1) - ); - // Allow multiple levels (subdomain can contain dots) - options.push({ - id: `org-${orgDomain.domainId}`, - domain: userInput, - type: "organization", - verified: orgDomain.verified, - domainType: "wildcard", - domainId: orgDomain.domainId, - subdomain: subdomain - }); - } - } - }); - - // Add provided domain options (always try to match provided domains) - availableOptions.forEach((option) => { options.push({ - id: `provided-${option.domainNamespaceId}`, - domain: option.fullDomain, - type: "provided", - domainNamespaceId: option.domainNamespaceId, - domainId: option.domainId + id: `org-${orgDomain.domainId}`, + domain: orgDomain.baseDomain, + type: "organization", + verified: orgDomain.verified, + domainType: orgDomain.type, + domainId: orgDomain.domainId }); }); - // Sort options - return options.sort((a, b) => { - const comparison = a.domain.localeCompare(b.domain); - return sortOrder === "asc" ? comparison : -comparison; - }); + if (build === "saas" || build === "enterprise") { + const domainOptionText = + build === "enterprise" + ? "Provided Domain" + : "Free Provided Domain"; + options.push({ + id: "provided-search", + domain: domainOptionText, + type: "provided-search" + }); + } + + return options; }; - const domainOptions = generateDomainOptions(); + const dropdownOptions = generateDropdownOptions(); - // Filter options based on active tab - const filteredOptions = domainOptions.filter((option) => { - if (activeTab === "all") return true; - return option.type === activeTab; - }); + const validateSubdomain = ( + subdomain: string, + baseDomain: DomainOption + ): boolean => { + if (!baseDomain) return false; - // Separate organization and provided options for pagination - const organizationOptions = filteredOptions.filter( - (opt) => opt.type === "organization" - ); - const allProvidedOptions = filteredOptions.filter( - (opt) => opt.type === "provided" - ); - const providedOptions = allProvidedOptions.slice(0, providedDomainsShown); - const hasMoreProvided = allProvidedOptions.length > providedDomainsShown; + if (baseDomain.type === "provided-search") { + return /^[a-zA-Z0-9-]+$/.test(subdomain); + } - // Handle option selection - const handleOptionSelect = (option: DomainOption) => { - setSelectedOption(option); + if (baseDomain.type === "organization") { + if (baseDomain.domainType === "cname") { + return subdomain === ""; + } else if (baseDomain.domainType === "ns") { + return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); + } else if (baseDomain.domainType === "wildcard") { + return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain); + } + } + + return false; + }; + + // Handle base domain selection + const handleBaseDomainSelect = (option: DomainOption) => { + setSelectedBaseDomain(option); + setOpen(false); + + if (option.domainType === "cname") { + setSubdomainInput(""); + } + + if (option.type === "provided-search") { + setUserInput(""); + setAvailableOptions([]); + setSelectedProvidedDomain(null); + onDomainChange?.({ + domainId: option.domainId!, + type: "organization", + subdomain: undefined, + fullDomain: option.domain, + baseDomain: option.domain + }); + } if (option.type === "organization") { if (option.domainType === "cname") { @@ -262,258 +309,413 @@ export default function DomainPicker({ fullDomain: option.domain, baseDomain: option.domain }); - } else if (option.domainType === "ns") { - const subdomain = option.subdomain || ""; + } else { onDomainChange?.({ domainId: option.domainId!, type: "organization", - subdomain: subdomain || undefined, + subdomain: undefined, fullDomain: option.domain, baseDomain: option.domain }); - } else if (option.domainType === "wildcard") { + } + } + }; + + const handleSubdomainChange = (value: string) => { + const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); + setSubdomainInput(validInput); + + setSelectedProvidedDomain(null); + + if (selectedBaseDomain && selectedBaseDomain.type === "organization") { + const isValid = validateSubdomain(validInput, selectedBaseDomain); + if (isValid) { + const fullDomain = validInput + ? `${validInput}.${selectedBaseDomain.domain}` + : selectedBaseDomain.domain; onDomainChange?.({ - domainId: option.domainId!, + domainId: selectedBaseDomain.domainId!, type: "organization", - subdomain: option.subdomain || undefined, - fullDomain: option.domain, - baseDomain: option.subdomain - ? option.domain.split(".").slice(1).join(".") - : option.domain + subdomain: validInput || undefined, + fullDomain: fullDomain, + baseDomain: selectedBaseDomain.domain + }); + } else if (validInput === "") { + onDomainChange?.({ + domainId: selectedBaseDomain.domainId!, + type: "organization", + subdomain: undefined, + fullDomain: selectedBaseDomain.domain, + baseDomain: selectedBaseDomain.domain }); } - } else if (option.type === "provided") { - // Extract subdomain from full domain - const parts = option.domain.split("."); - const subdomain = parts[0]; - const baseDomain = parts.slice(1).join("."); + } + }; + + const handleProvidedDomainInputChange = (value: string) => { + const validInput = value.replace(/[^a-zA-Z0-9.-]/g, ""); + setUserInput(validInput); + + // Clear selected domain when user types + if (selectedProvidedDomain) { + setSelectedProvidedDomain(null); onDomainChange?.({ - domainId: option.domainId!, - domainNamespaceId: option.domainNamespaceId, + domainId: "", type: "provided", - subdomain: subdomain, - fullDomain: option.domain, - baseDomain: baseDomain + subdomain: undefined, + fullDomain: "", + baseDomain: "" }); } }; + const handleProvidedDomainSelect = (option: AvailableOption) => { + setSelectedProvidedDomain(option); + + const parts = option.fullDomain.split("."); + const subdomain = parts[0]; + const baseDomain = parts.slice(1).join("."); + + onDomainChange?.({ + domainId: option.domainId, + domainNamespaceId: option.domainNamespaceId, + type: "provided", + subdomain: subdomain, + fullDomain: option.fullDomain, + baseDomain: baseDomain + }); + }; + + const isSubdomainValid = selectedBaseDomain + ? validateSubdomain(subdomainInput, selectedBaseDomain) + : true; + const showSubdomainInput = + selectedBaseDomain && + selectedBaseDomain.type === "organization" && + selectedBaseDomain.domainType !== "cname"; + const showProvidedDomainSearch = + selectedBaseDomain?.type === "provided-search"; + + const sortedAvailableOptions = availableOptions.sort((a, b) => { + const comparison = a.fullDomain.localeCompare(b.fullDomain); + return sortOrder === "asc" ? comparison : -comparison; + }); + + const displayedProvidedOptions = sortedAvailableOptions.slice( + 0, + providedDomainsShown + ); + const hasMoreProvided = + sortedAvailableOptions.length > providedDomainsShown; + return ( -
- {/* Domain Input */} -
- - { - // Only allow letters, numbers, hyphens, and periods - const validInput = e.target.value.replace( - /[^a-zA-Z0-9.-]/g, - "" - ); - setUserInput(validInput); - // Clear selection when input changes - setSelectedOption(null); - }} - /> -

- {build === "saas" - ? t("domainPickerDescriptionSaas") - : t("domainPickerDescription")} -

+
+
+
+ + { + if (showProvidedDomainSearch) { + handleProvidedDomainInputChange(e.target.value); + } else { + handleSubdomainChange(e.target.value); + } + }} + /> + {showSubdomainInput && !subdomainInput && ( +

+ {t("domainPickerEnterSubdomainOrLeaveBlank")} +

+ )} + {showProvidedDomainSearch && !userInput && ( +

+ {t("domainPickerEnterSubdomainToSearch")} +

+ )} +
+ +
+ + + + + + + + + +
+ {t("domainPickerNoDomainsFound")} +
+
+ + {organizationDomains.length > 0 && ( + <> + + + {organizationDomains.map( + (orgDomain) => ( + + handleBaseDomainSelect( + { + id: `org-${orgDomain.domainId}`, + domain: orgDomain.baseDomain, + type: "organization", + verified: + orgDomain.verified, + domainType: + orgDomain.type, + domainId: + orgDomain.domainId + } + ) + } + className="mx-2 rounded-md" + disabled={ + !orgDomain.verified + } + > +
+ +
+
+ + { + orgDomain.baseDomain + } + + + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? "Verified" + : "Unverified"} + +
+ +
+ ) + )} +
+
+ {(build === "saas" || + build === "enterprise") && ( + + )} + + )} + + {(build === "saas" || + build === "enterprise") && ( + + + + handleBaseDomainSelect({ + id: "provided-search", + domain: + build === + "enterprise" + ? "Provided Domain" + : "Free Provided Domain", + type: "provided-search" + }) + } + className="mx-2 rounded-md" + > +
+ +
+
+ + {build === "enterprise" + ? "Provided Domain" + : "Free Provided Domain"} + + + {t( + "domainPickerSearchForAvailableDomains" + )} + +
+ +
+
+
+ )} +
+
+
+
- {/* Tabs and Sort Toggle */} - {build === "saas" && ( -
- - setActiveTab( - value as "all" | "organization" | "provided" - ) - } - > - - - {t("domainPickerTabAll")} - - - {t("domainPickerTabOrganization")} - - {build == "saas" && ( - - {t("domainPickerTabProvided")} - - )} - - - -
- )} - - {/* Loading State */} - {isChecking && ( -
-
-
- {t("domainPickerCheckingAvailability")} -
-
- )} - - {/* No Options */} - {!isChecking && - filteredOptions.length === 0 && - userInput.trim() && ( - - - - {t("domainPickerNoMatchingDomains")} - - - )} - - {/* Domain Options */} - {!isChecking && filteredOptions.length > 0 && ( + {showProvidedDomainSearch && (
- {/* Organization Domains */} - {organizationOptions.length > 0 && ( -
- {build !== "oss" && ( -
- -

- {t("domainPickerOrganizationDomains")} -

-
- )} -
- {organizationOptions.map((option) => ( -
- option.verified && - handleOptionSelect(option) - } - > -
-
-
-

- {option.domain} -

- {/* */} - {/* {option.domainType} */} - {/* */} - {option.verified ? ( - - ) : ( - - )} -
- {option.subdomain && ( -

- {t( - "domainPickerSubdomain", - { - subdomain: - option.subdomain - } - )} -

- )} - {!option.verified && ( -

- Domain is unverified -

- )} -
-
-
- ))} + {isChecking && ( +
+
+
+ + {t("domainPickerCheckingAvailability")} +
)} - {/* Provided Domains */} - {providedOptions.length > 0 && ( + {!isChecking && + sortedAvailableOptions.length === 0 && + userInput.trim() && ( + + + + {t("domainPickerNoMatchingDomains")} + + + )} + + {!isChecking && sortedAvailableOptions.length > 0 && (
-
- -
- {t("domainPickerProvidedDomains")} -
-
-
- {providedOptions.map((option) => ( -
- handleOptionSelect(option) + { + const option = + displayedProvidedOptions.find( + (opt) => + opt.domainNamespaceId === value + ); + if (option) { + handleProvidedDomainSelect(option); + } + }} + className={`grid gap-2 grid-cols-1 sm:grid-cols-${cols}`} + > + {displayedProvidedOptions.map((option) => ( + ))} -
+ {hasMoreProvided && (
); } diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx new file mode 100644 index 00000000..5d594d02 --- /dev/null +++ b/src/components/EditInternalResourceDialog.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { toast } from "@app/hooks/useToast"; +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"; + +type InternalResourceData = { + id: number; + name: string; + orgId: string; + siteName: string; + protocol: string; + proxyPort: number | null; + siteId: number; + destinationIp?: string; + destinationPort?: number; +}; + +type EditInternalResourceDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; + resource: InternalResourceData; + orgId: string; + onSuccess?: () => void; +}; + +export default function EditInternalResourceDialog({ + open, + setOpen, + resource, + orgId, + onSuccess +}: EditInternalResourceDialogProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [isSubmitting, setIsSubmitting] = useState(false); + + const formSchema = z.object({ + name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")), + protocol: z.enum(["tcp", "udp"]), + proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")), + destinationIp: z.string().ip(t("editInternalResourceDialogInvalidIPAddressFormat")), + destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")) + }); + + type FormData = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: resource.name, + protocol: resource.protocol as "tcp" | "udp", + proxyPort: resource.proxyPort || undefined, + destinationIp: resource.destinationIp || "", + destinationPort: resource.destinationPort || undefined + } + }); + + useEffect(() => { + if (open) { + form.reset({ + name: resource.name, + protocol: resource.protocol as "tcp" | "udp", + proxyPort: resource.proxyPort || undefined, + destinationIp: resource.destinationIp || "", + destinationPort: resource.destinationPort || undefined + }); + } + }, [open, resource, form]); + + const handleSubmit = async (data: FormData) => { + setIsSubmitting(true); + try { + // Update the site resource + await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, { + name: data.name, + protocol: data.protocol, + proxyPort: data.proxyPort, + destinationIp: data.destinationIp, + destinationPort: data.destinationPort + }); + + toast({ + title: t("editInternalResourceDialogSuccess"), + description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"), + variant: "default" + }); + + onSuccess?.(); + setOpen(false); + } catch (error) { + console.error("Error updating internal resource:", error); + toast({ + title: t("editInternalResourceDialogError"), + description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")), + variant: "destructive" + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + {t("editInternalResourceDialogEditClientResource")} + + {t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })} + + + +
+ + {/* Resource Properties Form */} +
+

{t("editInternalResourceDialogResourceProperties")}

+
+ ( + + {t("editInternalResourceDialogName")} + + + + + + )} + /> + +
+ ( + + {t("editInternalResourceDialogProtocol")} + + + + )} + /> + + ( + + {t("editInternalResourceDialogSitePort")} + + field.onChange(parseInt(e.target.value) || 0)} + /> + + + + )} + /> +
+
+
+ + {/* Target Configuration Form */} +
+

{t("editInternalResourceDialogTargetConfiguration")}

+
+
+ ( + + {t("editInternalResourceDialogDestinationIP")} + + + + + + )} + /> + + ( + + {t("editInternalResourceDialogDestinationPort")} + + field.onChange(parseInt(e.target.value) || 0)} + /> + + + + )} + /> +
+
+
+
+ +
+ + + + +
+
+ ); +} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index ce001f09..d309c11f 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -70,7 +70,7 @@ export function LayoutSidebar({ isCollapsed={isSidebarCollapsed} />
-
+
{!isAdminPage && user.serverAdmin && (
diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 7e8ad336..13bd87d3 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -150,7 +150,7 @@ export function SidebarNav({ {section.heading}
)} -
+
{section.items.map((item) => { const hydratedHref = hydrateHref(item.href); const isActive = pathname.startsWith(hydratedHref); diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index e6fad743..2c30ee73 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -9,7 +9,7 @@ const alertVariants = cva( variants: { variant: { default: "bg-card border text-foreground", - neutral: "bg-card border text-foreground", + neutral: "bg-card bg-muted border text-foreground", destructive: "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", success: diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 6b22ddfe..fde1f12b 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -30,7 +30,15 @@ import { CardHeader, CardTitle } from "@app/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; import { useTranslations } from "next-intl"; +import { useMemo } from "react"; + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; type DataTableProps = { columns: ColumnDef[]; @@ -46,6 +54,8 @@ type DataTableProps = { id: string; desc: boolean; }; + tabs?: TabFilter[]; + defaultTab?: string; }; export function DataTable({ @@ -58,17 +68,36 @@ export function DataTable({ isRefreshing, searchPlaceholder = "Search...", searchColumn = "name", - defaultSort + defaultSort, + tabs, + defaultTab }: DataTableProps) { const [sorting, setSorting] = useState( defaultSort ? [defaultSort] : [] ); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState([]); + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); const t = useTranslations(); + // Apply tab filter to data + const filteredData = useMemo(() => { + if (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab]); + const table = useReactTable({ - data, + data: filteredData, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), @@ -90,20 +119,49 @@ export function DataTable({ } }); + const handleTabChange = (value: string) => { + setActiveTab(value); + // Reset to first page when changing tabs + table.setPageIndex(0); + }; + return (
-
- - table.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - +
+
+ + table.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+ {tabs && tabs.length > 0 && ( + + + {tabs.map((tab) => ( + + {tab.label} ( + {data.filter(tab.filterFn).length}) + + ))} + + + )}
{onRefresh && ( diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx index 9293541d..2afda77d 100644 --- a/src/components/ui/input-otp.tsx +++ b/src/components/ui/input-otp.tsx @@ -55,7 +55,7 @@ function InputOTPSlot({ data-slot="input-otp-slot" data-active={isActive} className={cn( - "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]", + "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-2xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]", className )} {...props} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 880a44b7..eacaa12e 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -16,7 +16,7 @@ const Input = React.forwardRef( type={showPassword ? "text" : "password"} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className @@ -43,7 +43,7 @@ const Input = React.forwardRef( type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index db231e17..03dd3d26 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -36,7 +36,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full", className )} {...props} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 7fa26a9e..94050ae2 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef< ) => void; updateAuthInfo: ( diff --git a/src/hooks/useDockerSocket.ts b/src/hooks/useDockerSocket.ts deleted file mode 100644 index dc4f08f4..00000000 --- a/src/hooks/useDockerSocket.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useCallback, useEffect, useState } from "react"; -import { useEnvContext } from "./useEnvContext"; -import { - Container, - GetDockerStatusResponse, - ListContainersResponse, - TriggerFetchResponse -} from "@server/routers/site"; -import { AxiosResponse } from "axios"; -import { toast } from "./useToast"; -import { Site } from "@server/db"; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export function useDockerSocket(site: Site) { - console.log(`useDockerSocket initialized for site ID: ${site.siteId}`); - - const [dockerSocket, setDockerSocket] = useState(); - const [containers, setContainers] = useState([]); - - const api = createApiClient(useEnvContext()); - - const { dockerSocketEnabled: rawIsEnabled = true, type: siteType } = site || {}; - const isEnabled = rawIsEnabled && siteType === "newt"; - const { isAvailable = false, socketPath } = dockerSocket || {}; - - const checkDockerSocket = useCallback(async () => { - if (!isEnabled) { - console.warn("Docker socket is not enabled for this site."); - return; - } - try { - const res = await api.post(`/site/${site.siteId}/docker/check`); - console.log("Docker socket check response:", res); - } catch (error) { - console.error("Failed to check Docker socket:", error); - } - }, [api, site.siteId, isEnabled]); - - const getDockerSocketStatus = useCallback(async () => { - if (!isEnabled) { - console.warn("Docker socket is not enabled for this site."); - return; - } - - try { - const res = await api.get>( - `/site/${site.siteId}/docker/status` - ); - - if (res.status === 200) { - setDockerSocket(res.data.data); - } else { - console.error("Failed to get Docker status:", res); - toast({ - variant: "destructive", - title: "Failed to get Docker status", - description: - "An error occurred while fetching Docker status." - }); - } - } catch (error) { - console.error("Failed to get Docker status:", error); - toast({ - variant: "destructive", - title: "Failed to get Docker status", - description: "An error occurred while fetching Docker status." - }); - } - }, [api, site.siteId, isEnabled]); - - const getContainers = useCallback( - async (maxRetries: number = 3) => { - if (!isEnabled || !isAvailable) { - console.warn("Docker socket is not enabled or available."); - return; - } - - const fetchContainerList = async () => { - if (!isEnabled || !isAvailable) { - return; - } - - let attempt = 0; - while (attempt < maxRetries) { - try { - const res = await api.get< - AxiosResponse - >(`/site/${site.siteId}/docker/containers`); - setContainers(res.data.data); - return res.data.data; - } catch (error: any) { - attempt++; - - // Check if the error is a 425 (Too Early) status - if (error?.response?.status === 425) { - if (attempt < maxRetries) { - console.log( - `Containers not ready yet (attempt ${attempt}/${maxRetries}). Retrying in 250ms...` - ); - await sleep(250); - continue; - } else { - console.warn( - "Max retry attempts reached. Containers may still be loading." - ); - // toast({ - // variant: "destructive", - // title: "Containers not ready", - // description: - // "Containers are still loading. Please try again in a moment." - // }); - } - } else { - console.error( - "Failed to fetch Docker containers:", - error - ); - toast({ - variant: "destructive", - title: "Failed to fetch containers", - description: formatAxiosError( - error, - "An error occurred while fetching containers" - ) - }); - } - break; - } - } - }; - - try { - const res = await api.post>( - `/site/${site.siteId}/docker/trigger` - ); - // TODO: identify a way to poll the server for latest container list periodically? - await fetchContainerList(); - return res.data.data; - } catch (error) { - console.error("Failed to trigger Docker containers:", error); - } - }, - [api, site.siteId, isEnabled, isAvailable] - ); - - // 2. Docker socket status monitoring - useEffect(() => { - if (!isEnabled || isAvailable) { - return; - } - - checkDockerSocket(); - getDockerSocketStatus(); - - }, [isEnabled, isAvailable, checkDockerSocket, getDockerSocketStatus]); - - return { - isEnabled, - isAvailable: isEnabled && isAvailable, - socketPath, - containers, - check: checkDockerSocket, - status: getDockerSocketStatus, - fetchContainers: getContainers - }; -} diff --git a/src/lib/docker.ts b/src/lib/docker.ts new file mode 100644 index 00000000..d463237b --- /dev/null +++ b/src/lib/docker.ts @@ -0,0 +1,136 @@ +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + Container, + GetDockerStatusResponse, + ListContainersResponse, + TriggerFetchResponse +} from "@server/routers/site"; +import { AxiosResponse } from "axios"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export interface DockerState { + isEnabled: boolean; + isAvailable: boolean; + socketPath?: string; + containers: Container[]; +} + +export class DockerManager { + private api: any; + private siteId: number; + + constructor(api: any, siteId: number) { + this.api = api; + this.siteId = siteId; + } + + async checkDockerSocket(): Promise { + try { + const res = await this.api.post(`/site/${this.siteId}/docker/check`); + console.log("Docker socket check response:", res); + } catch (error) { + console.error("Failed to check Docker socket:", error); + } + } + + async getDockerSocketStatus(): Promise { + try { + const res = await this.api.get( + `/site/${this.siteId}/docker/status` + ); + + if (res.status === 200) { + return res.data.data as GetDockerStatusResponse; + } else { + console.error("Failed to get Docker status:", res); + return null; + } + } catch (error) { + console.error("Failed to get Docker status:", error); + return null; + } + } + + async fetchContainers(maxRetries: number = 3): Promise { + const fetchContainerList = async (): Promise => { + let attempt = 0; + while (attempt < maxRetries) { + try { + const res = await this.api.get( + `/site/${this.siteId}/docker/containers` + ); + return res.data.data as Container[]; + } catch (error: any) { + attempt++; + + // Check if the error is a 425 (Too Early) status + if (error?.response?.status === 425) { + if (attempt < maxRetries) { + console.log( + `Containers not ready yet (attempt ${attempt}/${maxRetries}). Retrying in 250ms...` + ); + await sleep(250); + continue; + } else { + console.warn( + "Max retry attempts reached. Containers may still be loading." + ); + } + } else { + console.error( + "Failed to fetch Docker containers:", + error + ); + throw error; + } + break; + } + } + return []; + }; + + try { + await this.api.post( + `/site/${this.siteId}/docker/trigger` + ); + return await fetchContainerList(); + } catch (error) { + console.error("Failed to trigger Docker containers:", error); + return []; + } + } + + async initializeDocker(): Promise { + console.log(`Initializing Docker for site ID: ${this.siteId}`); + + // For now, assume Docker is enabled for newt sites + const isEnabled = true; + + if (!isEnabled) { + return { + isEnabled: false, + isAvailable: false, + containers: [] + }; + } + + // Check and get Docker socket status + await this.checkDockerSocket(); + const dockerStatus = await this.getDockerSocketStatus(); + + const isAvailable = dockerStatus?.isAvailable || false; + let containers: Container[] = []; + + if (isAvailable) { + containers = await this.fetchContainers(); + } + + return { + isEnabled, + isAvailable, + socketPath: dockerStatus?.socketPath, + containers + }; + } +} diff --git a/src/providers/ResourceProvider.tsx b/src/providers/ResourceProvider.tsx index 4541035a..da6aca87 100644 --- a/src/providers/ResourceProvider.tsx +++ b/src/providers/ResourceProvider.tsx @@ -3,20 +3,17 @@ import ResourceContext from "@app/contexts/resourceContext"; import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; -import { GetSiteResponse } from "@server/routers/site"; import { useState } from "react"; import { useTranslations } from "next-intl"; interface ResourceProviderProps { children: React.ReactNode; resource: GetResourceResponse; - site: GetSiteResponse | null; authInfo: GetResourceAuthInfoResponse; } export function ResourceProvider({ children, - site, resource: serverResource, authInfo: serverAuthInfo }: ResourceProviderProps) { @@ -66,7 +63,7 @@ export function ResourceProvider({ return ( {children}