add site targets, client resources, and auto login

This commit is contained in:
miloschwartz 2025-08-14 18:24:21 -07:00
parent 67ba225003
commit 5c04b1e14a
No known key found for this signature in database
80 changed files with 5651 additions and 2385 deletions

View file

@ -2,47 +2,27 @@
# https://docs.digpangolin.com/self-host/advanced/config-file # https://docs.digpangolin.com/self-host/advanced/config-file
app: app:
dashboard_url: "http://localhost:3002" dashboard_url: http://localhost:3002
log_level: "info" log_level: debug
save_logs: false
domains: domains:
domain1: domain1:
base_domain: "example.com" base_domain: example.com
cert_resolver: "letsencrypt"
server: server:
external_port: 3000 secret: my_secret_key
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"
gerbil: gerbil:
start_port: 51820 base_endpoint: example.com
base_endpoint: "localhost"
block_size: 24
site_block_size: 30
subnet_group: 100.89.137.0/20
use_subdomain: true
rate_limits: orgs:
global: block_size: 24
window_minutes: 1 subnet_group: 100.90.137.0/20
max_requests: 500
flags: flags:
require_email_verification: false require_email_verification: false
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: true disable_user_create_org: true
allow_raw_resources: true allow_raw_resources: true
enable_integration_api: true
enable_clients: true

View file

@ -166,7 +166,7 @@
"siteSelect": "Select site", "siteSelect": "Select site",
"siteSearch": "Search site", "siteSearch": "Search site",
"siteNotFound": "No site found.", "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", "resourceType": "Resource Type",
"resourceTypeDescription": "Determine how you want to access your resource", "resourceTypeDescription": "Determine how you want to access your resource",
"resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettings": "HTTPS Settings",
@ -197,6 +197,7 @@
"general": "General", "general": "General",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"proxy": "Proxy", "proxy": "Proxy",
"internal": "Internal",
"rules": "Rules", "rules": "Rules",
"resourceSettingDescription": "Configure the settings on your resource", "resourceSettingDescription": "Configure the settings on your resource",
"resourceSetting": "{resourceName} Settings", "resourceSetting": "{resourceName} Settings",
@ -490,7 +491,7 @@
"targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.",
"targetTlsSubmit": "Save Settings", "targetTlsSubmit": "Save Settings",
"targets": "Targets Configuration", "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", "targetStickySessions": "Enable Sticky Sessions",
"targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.",
"methodSelect": "Select method", "methodSelect": "Select method",
@ -986,7 +987,7 @@
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"setupToken": "Setup Token", "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", "setupTokenRequired": "Setup token is required",
"actionUpdateSite": "Update Site", "actionUpdateSite": "Update Site",
"actionListSiteRoles": "List Allowed Site Roles", "actionListSiteRoles": "List Allowed Site Roles",
@ -1345,9 +1346,106 @@
"resourceEnableProxy": "Enable Public Proxy", "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.", "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", "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", "siteConfiguration": "Configuration",
"siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnections": "Accept Client Connections",
"siteAcceptClientConnectionsDescription": "Allow other devices to connect through this Newt instance as a gateway using clients.", "siteAcceptClientConnectionsDescription": "Allow other devices to connect through this Newt instance as a gateway using clients.",
"siteAddress": "Site Address", "siteAddress": "Site Address",
"siteAddressDescription": "Specify the IP address of the host for clients to connect to." "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."
} }

View file

@ -69,6 +69,11 @@ export enum ActionsEnum {
deleteResourceRule = "deleteResourceRule", deleteResourceRule = "deleteResourceRule",
listResourceRules = "listResourceRules", listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule", updateResourceRule = "updateResourceRule",
createSiteResource = "createSiteResource",
deleteSiteResource = "deleteSiteResource",
getSiteResource = "getSiteResource",
listSiteResources = "listSiteResources",
updateSiteResource = "updateSiteResource",
createClient = "createClient", createClient = "createClient",
deleteClient = "deleteClient", deleteClient = "deleteClient",
updateClient = "updateClient", updateClient = "updateClient",

View file

@ -66,11 +66,6 @@ export const sites = pgTable("sites", {
export const resources = pgTable("resources", { export const resources = pgTable("resources", {
resourceId: serial("resourceId").primaryKey(), resourceId: serial("resourceId").primaryKey(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
orgId: varchar("orgId") orgId: varchar("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
onDelete: "cascade" onDelete: "cascade"
@ -97,6 +92,9 @@ export const resources = pgTable("resources", {
tlsServerName: varchar("tlsServerName"), tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader"), setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").default(true), enableProxy: boolean("enableProxy").default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade"
}),
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {
@ -106,6 +104,11 @@ export const targets = pgTable("targets", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
ip: varchar("ip").notNull(), ip: varchar("ip").notNull(),
method: varchar("method"), method: varchar("method"),
port: integer("port").notNull(), port: integer("port").notNull(),
@ -124,6 +127,22 @@ export const exitNodes = pgTable("exitNodes", {
maxConnections: integer("maxConnections") 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", { export const users = pgTable("user", {
userId: varchar("id").primaryKey(), userId: varchar("id").primaryKey(),
email: varchar("email"), email: varchar("email"),
@ -647,4 +666,5 @@ export type OlmSession = InferSelectModel<typeof olmSessions>;
export type UserClient = InferSelectModel<typeof userClients>; export type UserClient = InferSelectModel<typeof userClients>;
export type RoleClient = InferSelectModel<typeof roleClients>; export type RoleClient = InferSelectModel<typeof roleClients>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type SetupToken = InferSelectModel<typeof setupTokens>; export type SetupToken = InferSelectModel<typeof setupTokens>;

View file

@ -67,16 +67,11 @@ export const sites = sqliteTable("sites", {
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .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", { export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
orgId: text("orgId") orgId: text("orgId")
.references(() => orgs.orgId, { .references(() => orgs.orgId, {
onDelete: "cascade" onDelete: "cascade"
@ -109,6 +104,9 @@ export const resources = sqliteTable("resources", {
tlsServerName: text("tlsServerName"), tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader"), setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade"
}),
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {
@ -118,6 +116,11 @@ export const targets = sqliteTable("targets", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
ip: text("ip").notNull(), ip: text("ip").notNull(),
method: text("method"), method: text("method"),
port: integer("port").notNull(), port: integer("port").notNull(),
@ -136,6 +139,22 @@ export const exitNodes = sqliteTable("exitNodes", {
maxConnections: integer("maxConnections") 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", { export const users = sqliteTable("user", {
userId: text("id").primaryKey(), userId: text("id").primaryKey(),
email: text("email"), email: text("email"),
@ -166,7 +185,9 @@ export const users = sqliteTable("user", {
export const securityKeys = sqliteTable("webauthnCredentials", { export const securityKeys = sqliteTable("webauthnCredentials", {
credentialId: text("credentialId").primaryKey(), credentialId: text("credentialId").primaryKey(),
userId: text("userId").notNull().references(() => users.userId, { userId: text("userId")
.notNull()
.references(() => users.userId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
publicKey: text("publicKey").notNull(), publicKey: text("publicKey").notNull(),
@ -688,6 +709,7 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>; export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>; export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>; export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>; export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>; export type HostMeta = InferSelectModel<typeof hostMeta>;

View file

@ -27,3 +27,4 @@ export * from "./verifyApiKeyAccess";
export * from "./verifyDomainAccess"; export * from "./verifyDomainAccess";
export * from "./verifyClientsEnabled"; export * from "./verifyClientsEnabled";
export * from "./verifyUserIsOrgOwner"; export * from "./verifyUserIsOrgOwner";
export * from "./verifySiteResourceAccess";

View file

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

View file

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

View file

@ -9,6 +9,7 @@ import * as user from "./user";
import * as auth from "./auth"; import * as auth from "./auth";
import * as role from "./role"; import * as role from "./role";
import * as client from "./client"; import * as client from "./client";
import * as siteResource from "./siteResource";
import * as supporterKey from "./supporterKey"; import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken"; import * as accessToken from "./accessToken";
import * as idp from "./idp"; import * as idp from "./idp";
@ -34,7 +35,8 @@ import {
verifyDomainAccess, verifyDomainAccess,
verifyClientsEnabled, verifyClientsEnabled,
verifyUserHasAction, verifyUserHasAction,
verifyUserIsOrgOwner verifyUserIsOrgOwner,
verifySiteResourceAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { createStore } from "@server/lib/rateLimitStore"; import { createStore } from "@server/lib/rateLimitStore";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
@ -213,9 +215,60 @@ authenticated.get(
site.listContainers site.listContainers
); );
// Site Resource endpoints
authenticated.put( authenticated.put(
"/org/:orgId/site/:siteId/resource", "/org/:orgId/site/:siteId/resource",
verifyOrgAccess, 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), verifyUserHasAction(ActionsEnum.createResource),
resource.createResource resource.createResource
); );
@ -397,28 +450,6 @@ authenticated.post(
user.addUserRole 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( authenticated.post(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",
verifyResourceAccess, verifyResourceAccess,
@ -463,13 +494,6 @@ authenticated.get(
resource.getResourceWhitelist resource.getResourceWhitelist
); );
authenticated.post(
`/resource/:resourceId/transfer`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResource),
resource.transferResource
);
authenticated.post( authenticated.post(
`/resource/:resourceId/access-token`, `/resource/:resourceId/access-token`,
verifyResourceAccess, verifyResourceAccess,

View file

@ -341,13 +341,6 @@ authenticated.get(
resource.getResourceWhitelist resource.getResourceWhitelist
); );
authenticated.post(
`/resource/:resourceId/transfer`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.updateResource),
resource.transferResource
);
authenticated.post( authenticated.post(
`/resource/:resourceId/access-token`, `/resource/:resourceId/access-token`,
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,

View file

@ -220,30 +220,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
// Filter out any null values from peers that didn't have an olm // Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null); const validPeers = peers.filter((peer) => peer !== null);
// Improved version // Get all enabled targets with their resource protocol information
const allResources = await db.transaction(async (tx) => { const allTargets = await db
// 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 for these resources in a single query
const resourceIds = resourcesList.map((r) => r.resourceId);
const allTargets =
resourceIds.length > 0
? await tx
.select({ .select({
resourceId: targets.resourceId, resourceId: targets.resourceId,
targetId: targets.targetId, targetId: targets.targetId,
@ -252,46 +230,27 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled, enabled: targets.enabled,
protocol: resources.protocol
}) })
.from(targets) .from(targets)
.where( .innerJoin(resources, eq(targets.resourceId, resources.resourceId))
and( .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
inArray(targets.resourceId, resourceIds),
eq(targets.enabled, true)
)
)
: [];
// Combine the data in JS instead of using SQL for the JSON const { tcpTargets, udpTargets } = allTargets.reduce(
return resourcesList.map((resource) => ({ (acc, target) => {
...resource, // Filter out invalid targets
targets: allTargets.filter( if (!target.internalPort || !target.ip || !target.port) {
(target) => target.resourceId === resource.resourceId return acc;
) }
}));
});
const { tcpTargets, udpTargets } = allResources.reduce( // Format target into string
(acc, resource) => { const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
// 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}`
);
// Add to the appropriate protocol array // Add to the appropriate protocol array
if (resource.protocol === "tcp") { if (target.protocol === "tcp") {
acc.tcpTargets.push(...formattedTargets); acc.tcpTargets.push(formattedTarget);
} else { } else {
acc.udpTargets.push(...formattedTargets); acc.udpTargets.push(formattedTarget);
} }
return acc; return acc;

View file

@ -105,7 +105,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.limit(1); .limit(1);
const blockSize = config.getRawConfig().gerbil.site_block_size; 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}`)); subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr( const newSubnet = findNextAvailableCidr(
subnets, subnets,
@ -160,30 +162,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
allowedIps: [siteSubnet] allowedIps: [siteSubnet]
}); });
// Improved version // Get all enabled targets with their resource protocol information
const allResources = await db.transaction(async (tx) => { const allTargets = await db
// 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 for these resources in a single query
const resourceIds = resourcesList.map((r) => r.resourceId);
const allTargets =
resourceIds.length > 0
? await tx
.select({ .select({
resourceId: targets.resourceId, resourceId: targets.resourceId,
targetId: targets.targetId, targetId: targets.targetId,
@ -191,47 +171,28 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
method: targets.method, method: targets.method,
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled enabled: targets.enabled,
protocol: resources.protocol
}) })
.from(targets) .from(targets)
.where( .innerJoin(resources, eq(targets.resourceId, resources.resourceId))
and( .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
inArray(targets.resourceId, resourceIds),
eq(targets.enabled, true)
)
)
: [];
// Combine the data in JS instead of using SQL for the JSON const { tcpTargets, udpTargets } = allTargets.reduce(
return resourcesList.map((resource) => ({ (acc, target) => {
...resource, // Filter out invalid targets
targets: allTargets.filter( if (!target.internalPort || !target.ip || !target.port) {
(target) => target.resourceId === resource.resourceId return acc;
) }
}));
});
const { tcpTargets, udpTargets } = allResources.reduce( // Format target into string
(acc, resource) => { const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
// 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}`
);
// Add to the appropriate protocol array // Add to the appropriate protocol array
if (resource.protocol === "tcp") { if (target.protocol === "tcp") {
acc.tcpTargets.push(...formattedTargets); acc.tcpTargets.push(formattedTarget);
} else { } else {
acc.udpTargets.push(...formattedTargets); acc.udpTargets.push(formattedTarget);
} }
return acc; return acc;

View file

@ -1,7 +1,8 @@
import { Target } from "@server/db"; import { Target } from "@server/db";
import { sendToClient } from "../ws"; import { sendToClient } from "../ws";
import logger from "@server/logger";
export function addTargets( export async function addTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
protocol: string, protocol: string,
@ -20,22 +21,9 @@ export function addTargets(
targets: payloadTargets 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, newtId: string,
targets: Target[], targets: Target[],
protocol: string, protocol: string,
@ -48,23 +36,10 @@ export function removeTargets(
}:${target.port}`; }:${target.port}`;
}); });
sendToClient(newtId, { await sendToClient(newtId, {
type: `newt/${protocol}/remove`, type: `newt/${protocol}/remove`,
data: { data: {
targets: payloadTargets 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
}
});
} }

View file

@ -15,7 +15,6 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
@ -25,7 +24,6 @@ import { build } from "@server/build";
const createResourceParamsSchema = z const createResourceParamsSchema = z
.object({ .object({
siteId: z.string().transform(stoi).pipe(z.number().int().positive()),
orgId: z.string() orgId: z.string()
}) })
.strict(); .strict();
@ -34,7 +32,6 @@ const createHttpResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
subdomain: z.string().nullable().optional(), subdomain: z.string().nullable().optional(),
siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.enum(["tcp", "udp"]), protocol: z.enum(["tcp", "udp"]),
domainId: z.string() domainId: z.string()
@ -53,11 +50,10 @@ const createHttpResourceSchema = z
const createRawResourceSchema = z const createRawResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.enum(["tcp", "udp"]), protocol: z.enum(["tcp", "udp"]),
proxyPort: z.number().int().min(1).max(65535), proxyPort: z.number().int().min(1).max(65535),
enableProxy: z.boolean().default(true) // enableProxy: z.boolean().default(true) // always true now
}) })
.strict() .strict()
.refine( .refine(
@ -78,7 +74,7 @@ export type CreateResourceResponse = Resource;
registry.registerPath({ registry.registerPath({
method: "put", method: "put",
path: "/org/{orgId}/site/{siteId}/resource", path: "/org/{orgId}/resource",
description: "Create a resource.", description: "Create a resource.",
tags: [OpenAPITags.Org, OpenAPITags.Resource], tags: [OpenAPITags.Org, OpenAPITags.Resource],
request: { request: {
@ -111,7 +107,7 @@ export async function createResource(
); );
} }
const { siteId, orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && !req.userOrgRoleId) {
return next( return next(
@ -146,7 +142,7 @@ export async function createResource(
if (http) { if (http) {
return await createHttpResource( return await createHttpResource(
{ req, res, next }, { req, res, next },
{ siteId, orgId } { orgId }
); );
} else { } else {
if ( if (
@ -162,7 +158,7 @@ export async function createResource(
} }
return await createRawResource( return await createRawResource(
{ req, res, next }, { req, res, next },
{ siteId, orgId } { orgId }
); );
} }
} catch (error) { } catch (error) {
@ -180,12 +176,11 @@ async function createHttpResource(
next: NextFunction; next: NextFunction;
}, },
meta: { meta: {
siteId: number;
orgId: string; orgId: string;
} }
) { ) {
const { req, res, next } = route; const { req, res, next } = route;
const { siteId, orgId } = meta; const { orgId } = meta;
const parsedBody = createHttpResourceSchema.safeParse(req.body); const parsedBody = createHttpResourceSchema.safeParse(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
@ -292,7 +287,6 @@ async function createHttpResource(
const newResource = await trx const newResource = await trx
.insert(resources) .insert(resources)
.values({ .values({
siteId,
fullDomain, fullDomain,
domainId, domainId,
orgId, orgId,
@ -357,12 +351,11 @@ async function createRawResource(
next: NextFunction; next: NextFunction;
}, },
meta: { meta: {
siteId: number;
orgId: string; orgId: string;
} }
) { ) {
const { req, res, next } = route; const { req, res, next } = route;
const { siteId, orgId } = meta; const { orgId } = meta;
const parsedBody = createRawResourceSchema.safeParse(req.body); const parsedBody = createRawResourceSchema.safeParse(req.body);
if (!parsedBody.success) { 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 // if http is false check to see if there is already a resource with the same port and protocol
const existingResource = await db const existingResource = await db
@ -402,13 +395,12 @@ async function createRawResource(
const newResource = await trx const newResource = await trx
.insert(resources) .insert(resources)
.values({ .values({
siteId,
orgId, orgId,
name, name,
http, http,
protocol, protocol,
proxyPort, proxyPort,
enableProxy // enableProxy
}) })
.returning(); .returning();

View file

@ -71,44 +71,44 @@ export async function deleteResource(
); );
} }
const [site] = await db // const [site] = await db
.select() // .select()
.from(sites) // .from(sites)
.where(eq(sites.siteId, deletedResource.siteId!)) // .where(eq(sites.siteId, deletedResource.siteId!))
.limit(1); // .limit(1);
//
if (!site) { // if (!site) {
return next( // return next(
createHttpError( // createHttpError(
HttpCode.NOT_FOUND, // HttpCode.NOT_FOUND,
`Site with ID ${deletedResource.siteId} not found` // `Site with ID ${deletedResource.siteId} not found`
) // )
); // );
} // }
//
if (site.pubKey) { // if (site.pubKey) {
if (site.type == "wireguard") { // if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, { // await addPeer(site.exitNodeId!, {
publicKey: site.pubKey, // publicKey: site.pubKey,
allowedIps: await getAllowedIps(site.siteId) // allowedIps: await getAllowedIps(site.siteId)
}); // });
} else if (site.type == "newt") { // } else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId // // get the newt on the site by querying the newt table for siteId
const [newt] = await db // const [newt] = await db
.select() // .select()
.from(newts) // .from(newts)
.where(eq(newts.siteId, site.siteId)) // .where(eq(newts.siteId, site.siteId))
.limit(1); // .limit(1);
//
removeTargets( // removeTargets(
newt.newtId, // newt.newtId,
targetsToBeRemoved, // targetsToBeRemoved,
deletedResource.protocol, // deletedResource.protocol,
deletedResource.proxyPort // deletedResource.proxyPort
); // );
} // }
} // }
//
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,

View file

@ -19,9 +19,7 @@ const getResourceSchema = z
}) })
.strict(); .strict();
export type GetResourceResponse = Resource & { export type GetResourceResponse = Resource;
siteName: string;
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@ -56,11 +54,9 @@ export async function getResource(
.select() .select()
.from(resources) .from(resources)
.where(eq(resources.resourceId, resourceId)) .where(eq(resources.resourceId, resourceId))
.leftJoin(sites, eq(sites.siteId, resources.siteId))
.limit(1); .limit(1);
const resource = resp.resources; const resource = resp;
const site = resp.sites;
if (!resource) { if (!resource) {
return next( return next(
@ -73,8 +69,7 @@ export async function getResource(
return response(res, { return response(res, {
data: { data: {
...resource, ...resource
siteName: site?.name
}, },
success: true, success: true,
error: false, error: false,

View file

@ -31,6 +31,7 @@ export type GetResourceAuthInfoResponse = {
blockAccess: boolean; blockAccess: boolean;
url: string; url: string;
whitelist: boolean; whitelist: boolean;
skipToIdpId: number | null;
}; };
export async function getResourceAuthInfo( export async function getResourceAuthInfo(
@ -86,7 +87,8 @@ export async function getResourceAuthInfo(
sso: resource.sso, sso: resource.sso,
blockAccess: resource.blockAccess, blockAccess: resource.blockAccess,
url, url,
whitelist: resource.emailWhitelistEnabled whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId
}, },
success: true, success: true,
error: false, error: false,

View file

@ -6,11 +6,9 @@ import {
userResources, userResources,
roleResources, roleResources,
userOrgs, userOrgs,
roles,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
resourceWhitelist, resourceWhitelist
sites
} from "@server/db"; } from "@server/db";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -37,12 +35,7 @@ export async function getUserResources(
roleId: userOrgs.roleId roleId: userOrgs.roleId
}) })
.from(userOrgs) .from(userOrgs)
.where( .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1); .limit(1);
if (userOrgResult.length === 0) { if (userOrgResult.length === 0) {
@ -71,8 +64,8 @@ export async function getUserResources(
// Combine all accessible resource IDs // Combine all accessible resource IDs
const accessibleResourceIds = [ const accessibleResourceIds = [
...directResources.map(r => r.resourceId), ...directResources.map((r) => r.resourceId),
...roleResourceResults.map(r => r.resourceId) ...roleResourceResults.map((r) => r.resourceId)
]; ];
if (accessibleResourceIds.length === 0) { if (accessibleResourceIds.length === 0) {
@ -95,11 +88,9 @@ export async function getUserResources(
enabled: resources.enabled, enabled: resources.enabled,
sso: resources.sso, sso: resources.sso,
protocol: resources.protocol, protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled, emailWhitelistEnabled: resources.emailWhitelistEnabled
siteName: sites.name
}) })
.from(resources) .from(resources)
.leftJoin(sites, eq(sites.siteId, resources.siteId))
.where( .where(
and( and(
inArray(resources.resourceId, accessibleResourceIds), inArray(resources.resourceId, accessibleResourceIds),
@ -111,28 +102,61 @@ export async function getUserResources(
// Check for password, pincode, and whitelist protection for each resource // Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all( const resourcesWithAuth = await Promise.all(
resourcesData.map(async (resource) => { resourcesData.map(async (resource) => {
const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ const [passwordCheck, pincodeCheck, whitelistCheck] =
db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1), await Promise.all([
db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1), db
db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1) .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 hasPassword = passwordCheck.length > 0;
const hasPincode = pincodeCheck.length > 0; const hasPincode = pincodeCheck.length > 0;
const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; const hasWhitelist =
whitelistCheck.length > 0 || resource.emailWhitelistEnabled;
return { return {
resourceId: resource.resourceId, resourceId: resource.resourceId,
name: resource.name, name: resource.name,
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
enabled: resource.enabled, enabled: resource.enabled,
protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), protected: !!(
resource.sso ||
hasPassword ||
hasPincode ||
hasWhitelist
),
protocol: resource.protocol, protocol: resource.protocol,
sso: resource.sso, sso: resource.sso,
password: hasPassword, password: hasPassword,
pincode: hasPincode, pincode: hasPincode,
whitelist: hasWhitelist, whitelist: hasWhitelist
siteName: resource.siteName
}; };
}) })
); );
@ -144,11 +168,13 @@ export async function getUserResources(
message: "User resources retrieved successfully", message: "User resources retrieved successfully",
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
console.error("Error fetching user resources:", error); console.error("Error fetching user resources:", error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
); );
} }
} }

View file

@ -16,7 +16,6 @@ export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist"; export * from "./getResourceWhitelist";
export * from "./authWithWhitelist"; export * from "./authWithWhitelist";
export * from "./authWithAccessToken"; export * from "./authWithAccessToken";
export * from "./transferResource";
export * from "./getExchangeToken"; export * from "./getExchangeToken";
export * from "./createResourceRule"; export * from "./createResourceRule";
export * from "./deleteResourceRule"; export * from "./deleteResourceRule";

View file

@ -3,7 +3,6 @@ import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { import {
resources, resources,
sites,
userResources, userResources,
roleResources, roleResources,
resourcePassword, resourcePassword,
@ -20,17 +19,9 @@ import { OpenAPITags, registry } from "@server/openApi";
const listResourcesParamsSchema = z const listResourcesParamsSchema = z
.object({ .object({
siteId: z orgId: z.string()
.string()
.optional()
.transform(stoi)
.pipe(z.number().int().positive().optional()),
orgId: z.string().optional()
}) })
.strict() .strict();
.refine((data) => !!data.siteId !== !!data.orgId, {
message: "Either siteId or orgId must be provided, but not both"
});
const listResourcesSchema = z.object({ const listResourcesSchema = z.object({
limit: z limit: z
@ -48,55 +39,13 @@ const listResourcesSchema = z.object({
.pipe(z.number().int().nonnegative()) .pipe(z.number().int().nonnegative())
}); });
function queryResources( function queryResources(accessibleResourceIds: number[], orgId: string) {
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)
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.siteId, siteId)
)
);
} else if (orgId) {
return db return db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
name: resources.name, name: resources.name,
ssl: resources.ssl, ssl: resources.ssl,
fullDomain: resources.fullDomain, fullDomain: resources.fullDomain,
siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId, passwordId: resourcePassword.passwordId,
sso: resources.sso, sso: resources.sso,
pincodeId: resourcePincode.pincodeId, pincodeId: resourcePincode.pincodeId,
@ -108,7 +57,6 @@ function queryResources(
domainId: resources.domainId domainId: resources.domainId
}) })
.from(resources) .from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin( .leftJoin(
resourcePassword, resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId) eq(resourcePassword.resourceId, resources.resourceId)
@ -124,27 +72,12 @@ function queryResources(
) )
); );
} }
}
export type ListResourcesResponse = { export type ListResourcesResponse = {
resources: NonNullable<Awaited<ReturnType<typeof queryResources>>>; resources: NonNullable<Awaited<ReturnType<typeof queryResources>>>;
pagination: { total: number; limit: number; offset: number }; 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({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/resources", 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) { if (!orgId) {
return next( return next(
@ -222,9 +157,12 @@ export async function listResources(
) )
); );
} else { } else {
accessibleResources = await db.select({ accessibleResources = await db
.select({
resourceId: resources.resourceId resourceId: resources.resourceId
}).from(resources).where(eq(resources.orgId, orgId)); })
.from(resources)
.where(eq(resources.orgId, orgId));
} }
const accessibleResourceIds = accessibleResources.map( const accessibleResourceIds = accessibleResources.map(
@ -236,7 +174,7 @@ export async function listResources(
.from(resources) .from(resources)
.where(inArray(resources.resourceId, accessibleResourceIds)); .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 resourcesList = await baseQuery!.limit(limit).offset(offset);
const totalCountResult = await countQuery; const totalCountResult = await countQuery;

View file

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

View file

@ -20,7 +20,6 @@ import { tlsNameSchema } from "@server/lib/schemas";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { build } from "@server/build";
const updateResourceParamsSchema = z const updateResourceParamsSchema = z
.object({ .object({
@ -44,7 +43,8 @@ const updateHttpResourceBodySchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
tlsServerName: z.string().nullable().optional(), tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional() setHostHeader: z.string().nullable().optional(),
skipToIdpId: z.number().int().positive().nullable().optional()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
@ -91,8 +91,8 @@ const updateRawResourceBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
proxyPort: z.number().int().min(1).max(65535).optional(), proxyPort: z.number().int().min(1).max(65535).optional(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional()
enableProxy: z.boolean().optional() // enableProxy: z.boolean().optional() // always true now
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {

View file

@ -60,18 +60,18 @@ export async function addRoleSite(
}) })
.returning(); .returning();
const siteResources = await db // const siteResources = await db
.select() // .select()
.from(resources) // .from(resources)
.where(eq(resources.siteId, siteId)); // .where(eq(resources.siteId, siteId));
//
for (const resource of siteResources) { // for (const resource of siteResources) {
await trx.insert(roleResources).values({ // await trx.insert(roleResources).values({
roleId, // roleId,
resourceId: resource.resourceId // resourceId: resource.resourceId
}); // });
} // }
//
return response(res, { return response(res, {
data: newRoleSite[0], data: newRoleSite[0],
success: true, success: true,

View file

@ -1,6 +1,5 @@
export * from "./addRoleAction"; export * from "./addRoleAction";
export * from "../resource/setResourceRoles"; export * from "../resource/setResourceRoles";
export * from "./addRoleSite";
export * from "./createRole"; export * from "./createRole";
export * from "./deleteRole"; export * from "./deleteRole";
export * from "./getRole"; export * from "./getRole";
@ -11,5 +10,4 @@ export * from "./listRoles";
export * from "./listRoleSites"; export * from "./listRoleSites";
export * from "./removeRoleAction"; export * from "./removeRoleAction";
export * from "./removeRoleResource"; export * from "./removeRoleResource";
export * from "./removeRoleSite";
export * from "./updateRole"; export * from "./updateRole";

View file

@ -71,22 +71,22 @@ export async function removeRoleSite(
); );
} }
const siteResources = await db // const siteResources = await db
.select() // .select()
.from(resources) // .from(resources)
.where(eq(resources.siteId, siteId)); // .where(eq(resources.siteId, siteId));
//
for (const resource of siteResources) { // for (const resource of siteResources) {
await trx // await trx
.delete(roleResources) // .delete(roleResources)
.where( // .where(
and( // and(
eq(roleResources.roleId, roleId), // eq(roleResources.roleId, roleId),
eq(roleResources.resourceId, resource.resourceId) // eq(roleResources.resourceId, resource.resourceId)
) // )
) // )
.returning(); // .returning();
} // }
}); });
return response(res, { return response(res, {

View file

@ -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<typeof createSiteResourceSchema>;
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<any> {
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"
)
);
}
}

View file

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

View file

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

View file

@ -0,0 +1,6 @@
export * from "./createSiteResource";
export * from "./deleteSiteResource";
export * from "./getSiteResource";
export * from "./updateSiteResource";
export * from "./listSiteResources";
export * from "./listAllSiteResourcesByOrg";

View file

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

View file

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

View file

@ -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<typeof updateSiteResourceSchema>;
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<any> {
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"
)
);
}
}

View file

@ -26,6 +26,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z const createTargetSchema = z
.object({ .object({
siteId: z.number().int().positive(),
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
method: z.string().optional().nullable(), method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
@ -98,17 +99,41 @@ export async function createTarget(
); );
} }
const siteId = targetData.siteId;
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, resource.siteId!)) .where(eq(sites.siteId, siteId))
.limit(1); .limit(1);
if (!site) { if (!site) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, 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)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort); await addTargets(
newt.newtId,
newTarget,
resource.protocol,
resource.proxyPort
);
} }
} }
} }

View file

@ -76,38 +76,38 @@ export async function deleteTarget(
); );
} }
const [site] = await db // const [site] = await db
.select() // .select()
.from(sites) // .from(sites)
.where(eq(sites.siteId, resource.siteId!)) // .where(eq(sites.siteId, resource.siteId!))
.limit(1); // .limit(1);
//
if (!site) { // if (!site) {
return next( // return next(
createHttpError( // createHttpError(
HttpCode.NOT_FOUND, // HttpCode.NOT_FOUND,
`Site with ID ${resource.siteId} not found` // `Site with ID ${resource.siteId} not found`
) // )
); // );
} // }
//
if (site.pubKey) { // if (site.pubKey) {
if (site.type == "wireguard") { // if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, { // await addPeer(site.exitNodeId!, {
publicKey: site.pubKey, // publicKey: site.pubKey,
allowedIps: await getAllowedIps(site.siteId) // allowedIps: await getAllowedIps(site.siteId)
}); // });
} else if (site.type == "newt") { // } else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId // // get the newt on the site by querying the newt table for siteId
const [newt] = await db // const [newt] = await db
.select() // .select()
.from(newts) // .from(newts)
.where(eq(newts.siteId, site.siteId)) // .where(eq(newts.siteId, site.siteId))
.limit(1); // .limit(1);
//
removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); // removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
} // }
} // }
return response(res, { return response(res, {
data: null, data: null,

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db, Target } from "@server/db";
import { targets } from "@server/db"; import { targets } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@ -16,6 +16,8 @@ const getTargetSchema = z
}) })
.strict(); .strict();
type GetTargetResponse = Target;
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/target/{targetId}", path: "/target/{targetId}",
@ -60,7 +62,7 @@ export async function getTarget(
); );
} }
return response(res, { return response<GetTargetResponse>(res, {
data: target[0], data: target[0],
success: true, success: true,
error: false, error: false,

View file

@ -8,29 +8,21 @@ export async function pickPort(siteId: number): Promise<{
internalPort: number; internalPort: number;
targetIps: string[]; 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 // Fetch targets for all resources of this site
const targetIps: string[] = []; const targetIps: string[] = [];
const targetInternalPorts: number[] = []; const targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db const targetsRes = await db
.select() .select()
.from(targets) .from(targets)
.where(eq(targets.resourceId, resource.resourceId)); .where(eq(targets.siteId, siteId));
targetsRes.forEach((target) => { targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`); targetIps.push(`${target.ip}/32`);
if (target.internalPort) { if (target.internalPort) {
targetInternalPorts.push(target.internalPort); targetInternalPorts.push(target.internalPort);
} }
}); });
})
);
let internalPort!: number; let internalPort!: number;
// pick a port random port from 40000 to 65535 that is not in use // 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; break;
} }
} }
currentBannedPorts.push(internalPort); currentBannedPorts.push(internalPort);
return { internalPort, targetIps }; return { internalPort, targetIps };
} }
export async function getAllowedIps(siteId: number) { 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 // Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db const targetsRes = await db
.select() .select()
.from(targets) .from(targets)
.where(eq(targets.resourceId, resource.resourceId)); .where(eq(targets.siteId, siteId));
return targetsRes.map((target) => `${target.ip}/32`);
}) const targetIps = targetsRes.map((target) => `${target.ip}/32`);
);
return targetIps.flat(); return targetIps.flat();
} }

View file

@ -1,4 +1,4 @@
import { db } from "@server/db"; import { db, sites } from "@server/db";
import { targets } from "@server/db"; import { targets } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
@ -42,11 +42,12 @@ function queryTargets(resourceId: number) {
method: targets.method, method: targets.method,
port: targets.port, port: targets.port,
enabled: targets.enabled, enabled: targets.enabled,
resourceId: targets.resourceId resourceId: targets.resourceId,
// resourceName: resources.name, siteId: targets.siteId,
siteType: sites.type
}) })
.from(targets) .from(targets)
// .leftJoin(resources, eq(targets.resourceId, resources.resourceId)) .leftJoin(sites, eq(sites.siteId, targets.siteId))
.where(eq(targets.resourceId, resourceId)); .where(eq(targets.resourceId, resourceId));
return baseQuery; return baseQuery;

View file

@ -22,6 +22,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .object({
siteId: z.number().int().positive(),
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
method: z.string().min(1).max(10).optional().nullable(), method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
@ -77,6 +78,7 @@ export async function updateTarget(
} }
const { targetId } = parsedParams.data; const { targetId } = parsedParams.data;
const { siteId } = parsedBody.data;
const [target] = await db const [target] = await db
.select() .select()
@ -111,14 +113,42 @@ export async function updateTarget(
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, resource.siteId!)) .where(eq(sites.siteId, siteId))
.limit(1); .limit(1);
if (!site) { if (!site) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, 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)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort); await addTargets(
newt.newtId,
[updatedTarget],
resource.protocol,
resource.proxyPort
);
} }
} }
return response(res, { return response(res, {

View file

@ -1,11 +1,21 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import { db, exitNodes } from "@server/db"; 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 logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db"; 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; let currentExitNodeId: number;
export async function traefikConfigProvider( export async function traefikConfigProvider(
@ -44,8 +54,9 @@ export async function traefikConfigProvider(
} }
} }
// Get the site(s) on this exit node // Get resources with their targets and sites in a single optimized query
const resourcesWithRelations = await tx // Start from sites on this exit node, then join to targets and resources
const resourcesWithTargetsAndSites = await tx
.select({ .select({
// Resource fields // Resource fields
resourceId: resources.resourceId, resourceId: resources.resourceId,
@ -56,67 +67,82 @@ export async function traefikConfigProvider(
protocol: resources.protocol, protocol: resources.protocol,
subdomain: resources.subdomain, subdomain: resources.subdomain,
domainId: resources.domainId, domainId: resources.domainId,
// Site fields
site: {
siteId: sites.siteId,
type: sites.type,
subnet: sites.subnet,
exitNodeId: sites.exitNodeId
},
enabled: resources.enabled, enabled: resources.enabled,
stickySession: resources.stickySession, stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName, tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader, setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy enableProxy: resources.enableProxy,
}) // Target fields
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.where(
or(
eq(sites.exitNodeId, currentExitNodeId),
isNull(sites.exitNodeId)
)
);
// Get all resource IDs from the first query
const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
// Second query to get all enabled targets for these resources
const allTargets =
resourceIds.length > 0
? await tx
.select({
resourceId: targets.resourceId,
targetId: targets.targetId, targetId: targets.targetId,
targetEnabled: targets.enabled,
ip: targets.ip, ip: targets.ip,
method: targets.method, method: targets.method,
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled // Site fields
siteId: sites.siteId,
siteType: sites.type,
subnet: sites.subnet,
exitNodeId: sites.exitNodeId
}) })
.from(targets) .from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.where( .where(
and( and(
inArray(targets.resourceId, resourceIds), eq(targets.enabled, true),
eq(targets.enabled, true) eq(resources.enabled, true),
or(
eq(sites.exitNodeId, currentExitNodeId),
isNull(sites.exitNodeId)
) )
) )
: []; );
// Create a map for fast target lookup by resourceId // Group by resource and include targets with their unique site data
const targetsMap = allTargets.reduce((map, target) => { const resourcesMap = new Map();
if (!map.has(target.resourceId)) {
map.set(target.resourceId, []); resourcesWithTargetsAndSites.forEach((row) => {
const resourceId = row.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 // Add target with its associated site data
return resourcesWithRelations.map((resource) => ({ resourcesMap.get(resourceId).targets.push({
...resource, resourceId: row.resourceId,
targets: targetsMap.get(resource.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) { if (!allResources.length) {
@ -167,8 +193,7 @@ export async function traefikConfigProvider(
}; };
for (const resource of allResources) { for (const resource of allResources) {
const targets = resource.targets as Target[]; const targets = resource.targets as TargetWithSite[];
const site = resource.site;
const routerName = `${resource.resourceId}-router`; const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`; const serviceName = `${resource.resourceId}-service`;
@ -272,13 +297,13 @@ export async function traefikConfigProvider(
config_output.http.services![serviceName] = { config_output.http.services![serviceName] = {
loadBalancer: { loadBalancer: {
servers: targets servers: targets
.filter((target: Target) => { .filter((target: TargetWithSite) => {
if (!target.enabled) { if (!target.enabled) {
return false; return false;
} }
if ( if (
site.type === "local" || target.site.type === "local" ||
site.type === "wireguard" target.site.type === "wireguard"
) { ) {
if ( if (
!target.ip || !target.ip ||
@ -287,27 +312,27 @@ export async function traefikConfigProvider(
) { ) {
return false; return false;
} }
} else if (site.type === "newt") { } else if (target.site.type === "newt") {
if ( if (
!target.internalPort || !target.internalPort ||
!target.method || !target.method ||
!site.subnet !target.site.subnet
) { ) {
return false; return false;
} }
} }
return true; return true;
}) })
.map((target: Target) => { .map((target: TargetWithSite) => {
if ( if (
site.type === "local" || target.site.type === "local" ||
site.type === "wireguard" target.site.type === "wireguard"
) { ) {
return { return {
url: `${target.method}://${target.ip}:${target.port}` url: `${target.method}://${target.ip}:${target.port}`
}; };
} else if (site.type === "newt") { } else if (target.site.type === "newt") {
const ip = site.subnet!.split("/")[0]; const ip = target.site.subnet!.split("/")[0];
return { return {
url: `${target.method}://${ip}:${target.internalPort}` url: `${target.method}://${ip}:${target.internalPort}`
}; };
@ -393,34 +418,34 @@ export async function traefikConfigProvider(
config_output[protocol].services[serviceName] = { config_output[protocol].services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: targets servers: targets
.filter((target: Target) => { .filter((target: TargetWithSite) => {
if (!target.enabled) { if (!target.enabled) {
return false; return false;
} }
if ( if (
site.type === "local" || target.site.type === "local" ||
site.type === "wireguard" target.site.type === "wireguard"
) { ) {
if (!target.ip || !target.port) { if (!target.ip || !target.port) {
return false; return false;
} }
} else if (site.type === "newt") { } else if (target.site.type === "newt") {
if (!target.internalPort || !site.subnet) { if (!target.internalPort || !target.site.subnet) {
return false; return false;
} }
} }
return true; return true;
}) })
.map((target: Target) => { .map((target: TargetWithSite) => {
if ( if (
site.type === "local" || target.site.type === "local" ||
site.type === "wireguard" target.site.type === "wireguard"
) { ) {
return { return {
address: `${target.ip}:${target.port}` address: `${target.ip}:${target.port}`
}; };
} else if (site.type === "newt") { } else if (target.site.type === "newt") {
const ip = site.subnet!.split("/")[0]; const ip = target.site.subnet!.split("/")[0];
return { return {
address: `${ip}:${target.internalPort}` address: `${ip}:${target.internalPort}`
}; };

View file

@ -43,17 +43,17 @@ export async function addUserSite(
}) })
.returning(); .returning();
const siteResources = await trx // const siteResources = await trx
.select() // .select()
.from(resources) // .from(resources)
.where(eq(resources.siteId, siteId)); // .where(eq(resources.siteId, siteId));
//
for (const resource of siteResources) { // for (const resource of siteResources) {
await trx.insert(userResources).values({ // await trx.insert(userResources).values({
userId, // userId,
resourceId: resource.resourceId // resourceId: resource.resourceId
}); // });
} // }
return response(res, { return response(res, {
data: newUserSite[0], data: newUserSite[0],

View file

@ -71,22 +71,22 @@ export async function removeUserSite(
); );
} }
const siteResources = await trx // const siteResources = await trx
.select() // .select()
.from(resources) // .from(resources)
.where(eq(resources.siteId, siteId)); // .where(eq(resources.siteId, siteId));
//
for (const resource of siteResources) { // for (const resource of siteResources) {
await trx // await trx
.delete(userResources) // .delete(userResources)
.where( // .where(
and( // and(
eq(userResources.userId, userId), // eq(userResources.userId, userId),
eq(userResources.resourceId, resource.resourceId) // eq(userResources.resourceId, resource.resourceId)
) // )
) // )
.returning(); // .returning();
} // }
}); });
return response(res, { return response(res, {

View file

@ -23,7 +23,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"newt/socket/status": handleDockerStatusMessage, "newt/socket/status": handleDockerStatusMessage,
"newt/socket/containers": handleDockerContainersMessage, "newt/socket/containers": handleDockerContainersMessage,
"newt/ping/request": handleNewtPingRequestMessage, "newt/ping/request": handleNewtPingRequestMessage
}; };
startOfflineChecker(); // this is to handle the offline check for olms startOfflineChecker(); // this is to handle the offline check for olms

View file

@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createResource?: () => void;
}
export function ResourcesDataTable<TData, TValue>({
columns,
data,
createResource
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t('resources')}
searchPlaceholder={t('resourcesSearch')}
searchColumn="name"
onAdd={createResource}
addButtonText={t('resourceAdd')}
defaultSort={{
id: "name",
desc: false
}}
/>
);
}

View file

@ -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 (
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 p-2"
aria-label={t('dismiss')}
>
<X className="w-5 h-5" />
</button>
<CardContent className="grid gap-6 p-6">
<div className="space-y-4">
<h3 className="text-xl font-semibold flex items-center gap-2">
<Server className="text-blue-500" />
{t('resources')}
</h3>
<p className="text-sm">
{t('resourcesDescription')}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-center gap-2">
<Lock className="text-green-500 w-4 h-4" />
{t('resourcesWireGuardConnect')}
</li>
<li className="flex items-center gap-2">
<Key className="text-yellow-500 w-4 h-4" />
{t('resourcesMultipleAuthenticationMethods')}
</li>
<li className="flex items-center gap-2">
<Users className="text-purple-500 w-4 h-4" />
{t('resourcesUsersRolesAccess')}
</li>
</ul>
</div>
</CardContent>
</Card>
);
};
export default ResourcesSplashCard;

View file

@ -1,7 +1,16 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; import {
import { ResourcesDataTable } from "./ResourcesDataTable"; ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -10,18 +19,16 @@ import {
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
Copy,
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
MoreHorizontal, MoreHorizontal,
Check,
ArrowUpRight, ArrowUpRight,
ShieldOff, ShieldOff,
ShieldCheck ShieldCheck
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
@ -31,17 +38,37 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource"; import { UpdateResourceResponse } from "@server/routers/resource";
import { ListSitesResponse } from "@server/routers/site";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { InfoPopup } from "@app/components/ui/info-popup"; 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 = { export type ResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
domain: string; domain: string;
site: string;
siteId: string;
authState: string; authState: string;
http: boolean; http: boolean;
protocol: string; protocol: string;
@ -50,20 +77,147 @@ export type ResourceRow = {
domainId?: string; domainId?: string;
}; };
type ResourcesTableProps = { export type InternalResourceRow = {
resources: ResourceRow[]; id: number;
name: string;
orgId: 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 router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] = const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>(); useState<ResourceRow | null>();
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [sites, setSites] = useState<Site[]>([]);
const [proxySorting, setProxySorting] = useState<SortingState>([]);
const [proxyColumnFilters, setProxyColumnFilters] =
useState<ColumnFiltersState>([]);
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const [internalColumnFilters, setInternalColumnFilters] =
useState<ColumnFiltersState>([]);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
const currentView = searchParams.get("view") || defaultView;
useEffect(() => {
const fetchSites = async () => {
try {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/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 (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={internalGlobalFilter ?? ""}
onChange={(e) =>
internalTable.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
}
return (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={t("resourcesSearch")}
value={proxyGlobalFilter ?? ""}
onChange={(e) =>
proxyTable.setGlobalFilter(String(e.target.value))
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
);
};
const getActionButton = () => {
if (currentView === "internal") {
return (
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("resourceAdd")}
</Button>
);
}
return (
<Button
onClick={() =>
router.push(`/${orgId}/settings/resources/create`)
}
>
<Plus className="mr-2 h-4 w-4" />
{t("resourceAdd")}
</Button>
);
};
const deleteResource = (resourceId: number) => { const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`) 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) { async function toggleResourceEnabled(val: boolean, resourceId: number) {
const res = await api const res = await api
.post<AxiosResponse<UpdateResourceResponse>>( .post<AxiosResponse<UpdateResourceResponse>>(
@ -101,7 +275,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
}); });
} }
const columns: ColumnDef<ResourceRow>[] = [ const proxyColumns: ColumnDef<ResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -118,35 +292,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
); );
} }
}, },
{
accessorKey: "site",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("site")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
>
<Button variant="outline" size="sm">
{resourceRow.site}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{ {
accessorKey: "protocol", accessorKey: "protocol",
header: t("protocol"), header: t("protocol"),
@ -225,10 +370,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Switch <Switch
defaultChecked={ defaultChecked={
row.original.http row.original.http
? (!!row.original.domainId && row.original.enabled) ? !!row.original.domainId && row.original.enabled
: row.original.enabled : row.original.enabled
} }
disabled={row.original.http ? !row.original.domainId : false} disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) => onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id) toggleResourceEnabled(val, row.original.id)
} }
@ -289,6 +436,163 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
} }
]; ];
const internalColumns: ColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "siteName",
header: t("siteName"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline" size="sm">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{
accessorKey: "protocol",
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
}
},
{
accessorKey: "proxyPort",
header: t("proxyPort"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
);
}
},
{
accessorKey: "destination",
header: t("resourcesTableDestination"),
cell: ({ row }) => {
const resourceRow = row.original;
const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`;
return <CopyToClipboard text={destination} isLink={false} />;
}
},
{
id: "actions",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
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 ( return (
<> <>
{selectedResource && ( {selectedResource && (
@ -320,11 +624,271 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
/> />
)} )}
<ResourcesDataTable {selectedInternalResource && (
columns={columns} <ConfirmDeleteDialog
data={resources} open={isDeleteModalOpen}
createResource={() => { setOpen={(val) => {
router.push(`/${orgId}/settings/resources/create`); setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div>
<p className="mb-2">
{t("resourceQuestionRemove", {
selectedResource:
selectedInternalResource?.name ||
selectedInternalResource?.id
})}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteId
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<div className="container mx-auto max-w-12xl">
<Card>
<Tabs
defaultValue={defaultView}
className="w-full"
onValueChange={handleTabChange}
>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
{getSearchInput()}
{env.flags.enableClients && (
<TabsList className="grid grid-cols-2">
<TabsTrigger value="proxy">
{t("resourcesTableProxyResources")}
</TabsTrigger>
<TabsTrigger value="internal">
{t("resourcesTableClientResources")}
</TabsTrigger>
</TabsList>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end">
{getActionButton()}
</div>
</CardHeader>
<CardContent>
<TabsContent value="proxy">
<Table>
<TableHeader>
{proxyTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{proxyTable.getRowModel().rows
?.length ? (
proxyTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
proxyColumns.length
}
className="h-24 text-center"
>
{t(
"resourcesTableNoProxyResourcesFound"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination table={proxyTable} />
</div>
</TabsContent>
<TabsContent value="internal">
<div className="mb-4">
<Alert variant="neutral">
<AlertDescription>
{t(
"resourcesTableTheseResourcesForUseWith"
)}{" "}
<Link
href={`/${orgId}/settings/clients`}
className="font-medium underline hover:opacity-80 inline-flex items-center"
>
{t("resourcesTableClients")}
<ArrowUpRight className="ml-1 h-3 w-3" />
</Link>{" "}
{t(
"resourcesTableAndOnlyAccessibleInternally"
)}
</AlertDescription>
</Alert>
</div>
<Table>
<TableHeader>
{internalTable
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{internalTable.getRowModel().rows
?.length ? (
internalTable
.getRowModel()
.rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
internalColumns.length
}
className="h-24 text-center"
>
{t(
"resourcesTableNoInternalResourcesFound"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={internalTable}
/>
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
{editingResource && (
<EditInternalResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
router.refresh();
setEditingResource(null);
}}
/>
)}
<CreateInternalResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
}} }}
/> />
</> </>

View file

@ -10,35 +10,22 @@ import {
InfoSections, InfoSections,
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { useTranslations } from "next-intl"; 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"; import { build } from "@server/build";
type ResourceInfoBoxType = {}; type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) { export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const { resource, authInfo, site } = useResourceContext(); const { resource, authInfo } = useResourceContext();
const api = createApiClient(useEnvContext());
const { isEnabled, isAvailable } = useDockerSocket(site!);
const t = useTranslations(); const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return ( return (
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <AlertDescription>
<AlertTitle className="font-semibold"> <InfoSections cols={3}>
{t("resourceInfo")}
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={4}>
{resource.http ? ( {resource.http ? (
<> <>
<InfoSection> <InfoSection>
@ -71,12 +58,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/> />
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection>
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
</InfoSection>
{/* {isEnabled && ( {/* {isEnabled && (
<InfoSection> <InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle> <InfoSectionTitle>Socket</InfoSectionTitle>
@ -117,7 +98,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/> />
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{build == "oss" && ( {/* {build == "oss" && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("externalProxyEnabled")} {t("externalProxyEnabled")}
@ -130,7 +111,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</span> </span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
)} )} */}
</> </>
)} )}
<InfoSection> <InfoSection>

View file

@ -49,6 +49,15 @@ import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl"; 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({ const UsersRolesFormSchema = z.object({
roles: z.array( roles: z.array(
@ -110,6 +119,14 @@ export default function ResourceAuthenticationPage() {
resource.emailWhitelistEnabled resource.emailWhitelistEnabled
); );
const [autoLoginEnabled, setAutoLoginEnabled] = useState(
resource.skipToIdpId !== null && resource.skipToIdpId !== undefined
);
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
resource.skipToIdpId || null
);
const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]);
const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false);
const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false); const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false);
@ -139,7 +156,8 @@ export default function ResourceAuthenticationPage() {
resourceRolesResponse, resourceRolesResponse,
usersResponse, usersResponse,
resourceUsersResponse, resourceUsersResponse,
whitelist whitelist,
idpsResponse
] = await Promise.all([ ] = await Promise.all([
api.get<AxiosResponse<ListRolesResponse>>( api.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles` `/org/${org?.org.orgId}/roles`
@ -155,7 +173,12 @@ export default function ResourceAuthenticationPage() {
), ),
api.get<AxiosResponse<GetResourceWhitelistResponse>>( api.get<AxiosResponse<GetResourceWhitelistResponse>>(
`/resource/${resource.resourceId}/whitelist` `/resource/${resource.resourceId}/whitelist`
) ),
api.get<
AxiosResponse<{
idps: { idpId: number; name: string }[];
}>
>("/idp")
]); ]);
setAllRoles( 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); setPageLoading(false);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -260,6 +298,16 @@ export default function ResourceAuthenticationPage() {
try { try {
setLoadingSaveUsersRoles(true); 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 = [ const jobs = [
api.post(`/resource/${resource.resourceId}/roles`, { api.post(`/resource/${resource.resourceId}/roles`, {
roleIds: data.roles.map((i) => parseInt(i.id)) roleIds: data.roles.map((i) => parseInt(i.id))
@ -268,14 +316,16 @@ export default function ResourceAuthenticationPage() {
userIds: data.users.map((i) => i.id) userIds: data.users.map((i) => i.id)
}), }),
api.post(`/resource/${resource.resourceId}`, { api.post(`/resource/${resource.resourceId}`, {
sso: ssoEnabled sso: ssoEnabled,
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
}) })
]; ];
await Promise.all(jobs); await Promise.all(jobs);
updateResource({ updateResource({
sso: ssoEnabled sso: ssoEnabled,
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
}); });
updateAuthInfo({ updateAuthInfo({
@ -542,6 +592,89 @@ export default function ResourceAuthenticationPage() {
/> />
</> </>
)} )}
{ssoEnabled && allIdps.length > 0 && (
<div className="mt-8">
<div className="space-y-2 mb-3">
<CheckboxWithLabel
label={t(
"autoLoginExternalIdp"
)}
checked={autoLoginEnabled}
onCheckedChange={(
checked
) => {
setAutoLoginEnabled(
checked as boolean
);
if (
checked &&
allIdps.length > 0
) {
setSelectedIdpId(
allIdps[0].id
);
} else {
setSelectedIdpId(
null
);
}
}}
/>
<p className="text-sm text-muted-foreground">
{t(
"autoLoginExternalIdpDescription"
)}
</p>
</div>
{autoLoginEnabled && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t("selectIdp")}
</label>
<Select
onValueChange={(
value
) =>
setSelectedIdpId(
parseInt(value)
)
}
value={
selectedIdpId
? selectedIdpId.toString()
: undefined
}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectIdpPlaceholder"
)}
/>
</SelectTrigger>
<SelectContent>
{allIdps.map(
(idp) => (
<SelectItem
key={
idp.id
}
value={idp.id.toString()}
>
{
idp.text
}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>

View file

@ -14,19 +14,6 @@ import {
FormMessage FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; 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 { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -45,25 +32,11 @@ import {
SettingsSectionFooter SettingsSectionFooter
} from "@app/components/Settings"; } from "@app/components/Settings";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; 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 { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain"; import { ListDomainsResponse } from "@server/routers/domain";
import { import { UpdateResourceResponse } from "@server/routers/resource";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import {
UpdateResourceResponse,
updateResourceRule
} from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
@ -81,12 +54,6 @@ import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { build } from "@server/build"; import { build } from "@server/build";
const TransferFormSchema = z.object({
siteId: z.number()
});
type TransferFormValues = z.infer<typeof TransferFormSchema>;
export default function GeneralForm() { export default function GeneralForm() {
const [formKey, setFormKey] = useState(0); const [formKey, setFormKey] = useState(0);
const params = useParams(); const params = useParams();
@ -127,7 +94,7 @@ export default function GeneralForm() {
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
domainId: z.string().optional(), domainId: z.string().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(), proxyPort: z.number().int().min(1).max(65535).optional(),
enableProxy: z.boolean().optional() // enableProxy: z.boolean().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@ -156,18 +123,11 @@ export default function GeneralForm() {
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined, domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined, proxyPort: resource.proxyPort || undefined,
enableProxy: resource.enableProxy || false // enableProxy: resource.enableProxy || false
}, },
mode: "onChange" mode: "onChange"
}); });
const transferForm = useForm<TransferFormValues>({
resolver: zodResolver(TransferFormSchema),
defaultValues: {
siteId: resource.siteId ? Number(resource.siteId) : undefined
}
});
useEffect(() => { useEffect(() => {
const fetchSites = async () => { const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>( const res = await api.get<AxiosResponse<ListSitesResponse>>(
@ -221,9 +181,9 @@ export default function GeneralForm() {
subdomain: data.subdomain, subdomain: data.subdomain,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
...(!resource.http && { // ...(!resource.http && {
enableProxy: data.enableProxy // enableProxy: data.enableProxy
}) // })
} }
) )
.catch((e) => { .catch((e) => {
@ -251,9 +211,9 @@ export default function GeneralForm() {
subdomain: data.subdomain, subdomain: data.subdomain,
fullDomain: resource.fullDomain, fullDomain: resource.fullDomain,
proxyPort: data.proxyPort, proxyPort: data.proxyPort,
...(!resource.http && { // ...(!resource.http && {
enableProxy: data.enableProxy // enableProxy: data.enableProxy
}), // })
}); });
router.refresh(); router.refresh();
@ -261,40 +221,6 @@ export default function GeneralForm() {
setSaveLoading(false); 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 ( return (
!loadingPage && ( !loadingPage && (
<> <>
@ -410,7 +336,7 @@ export default function GeneralForm() {
)} )}
/> />
{build == "oss" && ( {/* {build == "oss" && (
<FormField <FormField
control={form.control} control={form.control}
name="enableProxy" name="enableProxy"
@ -444,13 +370,15 @@ export default function GeneralForm() {
</FormItem> </FormItem>
)} )}
/> />
)} )} */}
</> </>
)} )}
{resource.http && ( {resource.http && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Domain</Label> <Label>
{t("resourceDomain")}
</Label>
<div className="border p-2 rounded-md flex items-center justify-between"> <div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2"> <span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" /> <Globe size="14" />
@ -466,7 +394,9 @@ export default function GeneralForm() {
) )
} }
> >
Edit Domain {t(
"resourceEditDomain"
)}
</Button> </Button>
</div> </div>
</div> </div>
@ -490,140 +420,6 @@ export default function GeneralForm() {
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceTransfer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTransferDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("siteDestination")}
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandEmpty>
{t(
"sitesNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
{t("resourceTransferSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer> </SettingsContainer>
<Credenza <Credenza

View file

@ -29,7 +29,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
let authInfo = null; let authInfo = null;
let resource = null; let resource = null;
let site = null;
try { try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>( const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`, `/resource/${params.resourceId}`,
@ -44,19 +43,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
redirect(`/${params.orgId}/settings/resources`); redirect(`/${params.orgId}/settings/resources`);
} }
// Fetch site info
if (resource.siteId) {
try {
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
`/site/${resource.siteId}`,
await authCookieHeader()
);
site = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
}
try { try {
const res = await internal.get< const res = await internal.get<
AxiosResponse<GetResourceAuthInfoResponse> AxiosResponse<GetResourceAuthInfoResponse>
@ -119,7 +105,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
<OrgProvider org={org}> <OrgProvider org={org}>
<ResourceProvider <ResourceProvider
site={site}
resource={resource} resource={resource}
authInfo={authInfo} authInfo={authInfo}
> >

View file

@ -3,7 +3,6 @@
import { useEffect, useState, use } from "react"; import { useEffect, useState, use } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -34,12 +33,12 @@ import {
getPaginationRowModel, getPaginationRowModel,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
flexRender flexRender,
Row
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
@ -51,7 +50,7 @@ import { ArrayElement } from "@server/types/ArrayElement";
import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { GetSiteResponse } from "@server/routers/site"; import { GetSiteResponse, ListSitesResponse } from "@server/routers/site";
import { import {
SettingsContainer, SettingsContainer,
SettingsSection, SettingsSection,
@ -59,28 +58,48 @@ import {
SettingsSectionTitle, SettingsSectionTitle,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionFooter, SettingsSectionForm
SettingsSectionForm,
SettingsSectionGrid
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { tlsNameSchema } from "@server/lib/schemas"; import { tlsNameSchema } from "@server/lib/schemas";
import { ChevronsUpDown } from "lucide-react";
import { import {
Collapsible, CheckIcon,
CollapsibleContent, ChevronsUpDown,
CollapsibleTrigger Settings,
} from "@app/components/ui/collapsible"; Heart,
Check,
CircleCheck,
CircleX
} from "lucide-react";
import { ContainersSelector } from "@app/components/ContainersSelector"; import { ContainersSelector } from "@app/components/ContainersSelector";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; 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({ const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
method: z.string().nullable(), 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({ const targetsSettingsSchema = z.object({
@ -91,12 +110,13 @@ type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & { ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean; new?: boolean;
updated?: boolean; updated?: boolean;
siteType: string | null;
}, },
"protocol" "protocol"
>; >;
export default function ReverseProxyTargets(props: { export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>; params: Promise<{ resourceId: number; orgId: string }>;
}) { }) {
const params = use(props.params); const params = use(props.params);
const t = useTranslations(); const t = useTranslations();
@ -106,15 +126,48 @@ export default function ReverseProxyTargets(props: {
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [targets, setTargets] = useState<LocalTarget[]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
const [site, setSite] = useState<GetSiteResponse>();
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]); const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(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 [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
const [targetsLoading, setTargetsLoading] = useState(false); const [targetsLoading, setTargetsLoading] = useState(false);
const [proxySettingsLoading, setProxySettingsLoading] = useState(false); const [proxySettingsLoading, setProxySettingsLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const proxySettingsSchema = z.object({ const proxySettingsSchema = z.object({
@ -167,6 +220,14 @@ export default function ReverseProxyTargets(props: {
const watchedIp = addTargetForm.watch("ip"); const watchedIp = addTargetForm.watch("ip");
const watchedPort = addTargetForm.watch("port"); 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<TlsSettingsValues>({ const tlsSettingsForm = useForm<TlsSettingsValues>({
resolver: zodResolver(tlsSettingsSchema), resolver: zodResolver(tlsSettingsSchema),
@ -216,28 +277,64 @@ export default function ReverseProxyTargets(props: {
}; };
fetchTargets(); fetchTargets();
const fetchSite = async () => { const fetchSites = async () => {
try { const res = await api
const res = await api.get<AxiosResponse<GetSiteResponse>>( .get<
`/site/${resource.siteId}` AxiosResponse<ListSitesResponse>
); >(`/org/${params.orgId}/sites`)
.catch((e) => {
if (res.status === 200) {
setSite(res.data.data);
}
} catch (err) {
console.error(err);
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("siteErrorFetch"), title: t("sitesErrorFetch"),
description: formatAxiosError( description: formatAxiosError(
err, e,
t("siteErrorFetchDescription") 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<AxiosResponse<GetSiteResponse>>(
// `/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<typeof addTargetSchema>) { async function addTarget(data: z.infer<typeof addTargetSchema>) {
@ -246,7 +343,8 @@ export default function ReverseProxyTargets(props: {
(target) => (target) =>
target.ip === data.ip && target.ip === data.ip &&
target.port === data.port && target.port === data.port &&
target.method === data.method target.method === data.method &&
target.siteId === data.siteId
); );
if (isDuplicate) { if (isDuplicate) {
@ -258,34 +356,37 @@ export default function ReverseProxyTargets(props: {
return; return;
} }
if (site && site.type == "wireguard" && site.subnet) { // if (site && site.type == "wireguard" && site.subnet) {
// make sure that the target IP is within the site subnet // // make sure that the target IP is within the site subnet
const targetIp = data.ip; // const targetIp = data.ip;
const subnet = site.subnet; // const subnet = site.subnet;
try { // try {
if (!isIPInSubnet(targetIp, subnet)) { // if (!isIPInSubnet(targetIp, subnet)) {
toast({ // toast({
variant: "destructive", // variant: "destructive",
title: t("targetWireGuardErrorInvalidIp"), // title: t("targetWireGuardErrorInvalidIp"),
description: t( // description: t(
"targetWireGuardErrorInvalidIpDescription" // "targetWireGuardErrorInvalidIpDescription"
) // )
}); // });
return; // return;
} // }
} catch (error) { // } catch (error) {
console.error(error); // console.error(error);
toast({ // toast({
variant: "destructive", // variant: "destructive",
title: t("targetWireGuardErrorInvalidIp"), // title: t("targetWireGuardErrorInvalidIp"),
description: t("targetWireGuardErrorInvalidIpDescription") // description: t("targetWireGuardErrorInvalidIpDescription")
}); // });
return; // return;
} // }
} // }
const site = sites.find((site) => site.siteId === data.siteId);
const newTarget: LocalTarget = { const newTarget: LocalTarget = {
...data, ...data,
siteType: site?.type || null,
enabled: true, enabled: true,
targetId: new Date().getTime(), targetId: new Date().getTime(),
new: true, new: true,
@ -311,10 +412,16 @@ export default function ReverseProxyTargets(props: {
}; };
async function updateTarget(targetId: number, data: Partial<LocalTarget>) { async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets( setTargets(
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ...target, ...data, updated: true } ? {
...target,
...data,
updated: true,
siteType: site?.type || null
}
: target : target
) )
); );
@ -332,7 +439,8 @@ export default function ReverseProxyTargets(props: {
ip: target.ip, ip: target.ip,
port: target.port, port: target.port,
method: target.method, method: target.method,
enabled: target.enabled enabled: target.enabled,
siteId: target.siteId
}; };
if (target.new) { if (target.new) {
@ -403,6 +511,135 @@ export default function ReverseProxyTargets(props: {
} }
const columns: ColumnDef<LocalTarget>[] = [ const columns: ColumnDef<LocalTarget>[] = [
{
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 (
<div className="flex gap-2 items-center">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between flex-1",
!row.original.siteId &&
"text-muted-foreground"
)}
>
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={site.siteId}
onSelect={() => {
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedSite && selectedSite.type === "newt" && (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelectForTarget}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/>
);
})()}
</div>
);
}
},
...(resource.http
? [
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
: []),
{ {
accessorKey: "ip", accessorKey: "ip",
header: t("targetAddr"), header: t("targetAddr"),
@ -412,6 +649,7 @@ export default function ReverseProxyTargets(props: {
className="min-w-[150px]" className="min-w-[150px]"
onBlur={(e) => onBlur={(e) =>
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original,
ip: e.target.value ip: e.target.value
}) })
} }
@ -428,6 +666,7 @@ export default function ReverseProxyTargets(props: {
className="min-w-[100px]" className="min-w-[100px]"
onBlur={(e) => onBlur={(e) =>
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original,
port: parseInt(e.target.value, 10) port: parseInt(e.target.value, 10)
}) })
} }
@ -459,7 +698,10 @@ export default function ReverseProxyTargets(props: {
<Switch <Switch
defaultChecked={row.original.enabled} defaultChecked={row.original.enabled}
onCheckedChange={(val) => onCheckedChange={(val) =>
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<LocalTarget> = {
accessorKey: "method",
header: t("method"),
cell: ({ row }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, { method: value })
}
>
<SelectTrigger className="min-w-[100px]">
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
};
// add this to the first column
columns.unshift(methodCol);
}
const table = useReactTable({ const table = useReactTable({
data: targets, data: targets,
columns, columns,
@ -545,54 +760,130 @@ export default function ReverseProxyTargets(props: {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <div className="p-4 border rounded-md">
<Form {...targetsSettingsForm}>
<form
onSubmit={targetsSettingsForm.handleSubmit(
saveAllSettings
)}
className="space-y-4"
id="targets-settings-form"
>
{targets.length >= 2 && (
<FormField
control={targetsSettingsForm.control}
name="stickySession"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="sticky-toggle"
label={t(
"targetStickySessions"
)}
description={t(
"targetStickySessionsDescription"
)}
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
<Form {...addTargetForm}> <Form {...addTargetForm}>
<form <form
onSubmit={addTargetForm.handleSubmit(addTarget)} onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4" className="space-y-4"
> >
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4 items-start">
<FormField
control={addTargetForm.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("site")}
</FormLabel>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between flex-1",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput
placeholder={t(
"siteSearch"
)}
/>
<CommandList>
<CommandEmpty>
{t(
"siteNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
onSelect={() => {
addTargetForm.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{field.value &&
(() => {
const selectedSite =
sites.find(
(site) =>
site.siteId ===
field.value
);
return selectedSite &&
selectedSite.type ===
"newt" ? (() => {
const dockerState = getDockerStateForSite(selectedSite.siteId);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={dockerState.isAvailable}
onContainerSelect={handleContainerSelect}
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
/>
);
})() : null;
})()}
</div>
<FormMessage />
</FormItem>
)}
/>
{resource.http && ( {resource.http && (
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
@ -657,26 +948,6 @@ export default function ReverseProxyTargets(props: {
<FormControl> <FormControl>
<Input id="ip" {...field} /> <Input id="ip" {...field} />
</FormControl> </FormControl>
{site && site.type == "newt" && (
<ContainersSelector
site={site}
onContainerSelect={(
hostname,
port
) => {
addTargetForm.setValue(
"ip",
hostname
);
if (port) {
addTargetForm.setValue(
"port",
port
);
}
}}
/>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -712,33 +983,102 @@ export default function ReverseProxyTargets(props: {
</div> </div>
</form> </form>
</Form> </Form>
</div>
{targets.length > 0 ? (
<>
<h6 className="font-semibold">
{t("targetsList")}
</h6>
<SettingsSectionForm>
<Form {...targetsSettingsForm}>
<form
onSubmit={targetsSettingsForm.handleSubmit(
saveAllSettings
)}
className="space-y-4"
id="targets-settings-form"
>
<FormField
control={
targetsSettingsForm.control
}
name="stickySession"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="sticky-toggle"
label={t(
"targetStickySessions"
)}
description={t(
"targetStickySessionsDescription"
)}
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(
val
);
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
<div className="">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map(
<TableHead key={header.id}> (header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef header
.column
.columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
))} )
)}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}> <TableRow key={row.id}>
{row.getVisibleCells().map((cell) => ( {row
<TableCell key={cell.id}> .getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell
.column
.columnDef
.cell,
cell.getContext() cell.getContext()
)} )}
</TableCell> </TableCell>
@ -760,6 +1100,15 @@ export default function ReverseProxyTargets(props: {
{/* {t('targetNoOneDescription')} */} {/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */} {/* </TableCaption> */}
</Table> </Table>
</div>
</>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">
{t("targetNoOne")}
</p>
</div>
)}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@ -885,7 +1234,7 @@ export default function ReverseProxyTargets(props: {
proxySettingsLoading proxySettingsLoading
} }
> >
{t("saveAllSettings")} {t("saveSettings")}
</Button> </Button>
</div> </div>
</SettingsContainer> </SettingsContainer>

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,40 @@
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import ResourcesTable, { ResourceRow } from "./ResourcesTable"; import ResourcesTable, {
ResourceRow,
InternalResourceRow
} from "./ResourcesTable";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource"; import { ListResourcesResponse } from "@server/routers/resource";
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import ResourcesSplashCard from "./ResourcesSplashCard";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
type ResourcesPageProps = { type ResourcesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ResourcesPage(props: ResourcesPageProps) { export default async function ResourcesPage(props: ResourcesPageProps) {
const params = await props.params; const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslations(); const t = await getTranslations();
const env = pullEnv();
// Default to 'proxy' view, or use the query param if provided
let defaultView: "proxy" | "internal" = "proxy";
if (env.flags.enableClients) {
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
}
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];
try { try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>( const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
@ -30,6 +44,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
resources = res.data.data.resources; resources = res.data.data.resources;
} catch (e) {} } catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
let org = null; let org = null;
try { try {
const getOrg = cache(async () => const getOrg = cache(async () =>
@ -54,8 +76,6 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
name: resource.name, name: resource.name,
orgId: params.orgId, orgId: params.orgId,
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
site: resource.siteName || t('none'),
siteId: resource.siteId || t('unknown'),
protocol: resource.protocol, protocol: resource.protocol,
proxyPort: resource.proxyPort, proxyPort: resource.proxyPort,
http: resource.http, http: resource.http,
@ -72,17 +92,39 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
}; };
}); });
const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => {
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
protocol: siteResource.protocol,
proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
destinationIp: siteResource.destinationIp,
destinationPort: siteResource.destinationPort,
siteNiceId: siteResource.siteNiceId
};
}
);
return ( return (
<> <>
{/* <ResourcesSplashCard /> */}
<SettingsSectionTitle <SettingsSectionTitle
title={t('resourceTitle')} title={t("resourceTitle")}
description={t('resourceDescription')} description={t("resourceDescription")}
/> />
<OrgProvider org={org}> <OrgProvider org={org}>
<ResourcesTable resources={resourceRows} orgId={params.orgId} /> <ResourcesTable
resources={resourceRows}
internalResources={internalResourceRows}
orgId={params.orgId}
defaultView={
env.flags.enableClients ? defaultView : "proxy"
}
/>
</OrgProvider> </OrgProvider>
</> </>
); );

View file

@ -98,7 +98,6 @@ export default function CreateShareLinkForm({
resourceId: number; resourceId: number;
name: string; name: string;
resourceUrl: string; resourceUrl: string;
siteName: string | null;
}[] }[]
>([]); >([]);
@ -160,8 +159,7 @@ export default function CreateShareLinkForm({
.map((r) => ({ .map((r) => ({
resourceId: r.resourceId, resourceId: r.resourceId,
name: r.name, name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`, resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
siteName: r.siteName
})) }))
); );
} }
@ -236,8 +234,7 @@ export default function CreateShareLinkForm({
resourceName: values.resourceName, resourceName: values.resourceName,
title: token.title, title: token.title,
createdAt: token.createdAt, createdAt: token.createdAt,
expiresAt: token.expiresAt, expiresAt: token.expiresAt
siteName: resource?.siteName || null
}); });
} }
@ -246,7 +243,7 @@ export default function CreateShareLinkForm({
function getSelectedResourceName(id: number) { function getSelectedResourceName(id: number) {
const resource = resources.find((r) => r.resourceId === id); const resource = resources.find((r) => r.resourceId === id);
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`; return `${resource?.name}`;
} }
return ( return (
@ -346,7 +343,7 @@ export default function CreateShareLinkForm({
: "opacity-0" : "opacity-0"
)} )}
/> />
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`} {`${r.name}`}
</CommandItem> </CommandItem>
) )
)} )}

View file

@ -42,7 +42,6 @@ export type ShareLinkRow = {
title: string | null; title: string | null;
createdAt: number; createdAt: number;
expiresAt: number | null; expiresAt: number | null;
siteName: string | null;
}; };
type ShareLinksTableProps = { type ShareLinksTableProps = {
@ -104,8 +103,7 @@ export default function ShareLinksTable({
return ( return (
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}> <Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
{r.resourceName}{" "} {r.resourceName}
{r.siteName ? `(${r.siteName})` : ""}
<ArrowUpRight className="ml-2 h-4 w-4" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link> </Link>

View file

@ -33,9 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
return ( return (
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <AlertDescription>
<AlertTitle className="font-semibold">{t("siteInfo")}</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={env.flags.enableClients ? 3 : 2}> <InfoSections cols={env.flags.enableClients ? 3 : 2}>
{(site.type == "newt" || site.type == "wireguard") && ( {(site.type == "newt" || site.type == "wireguard") && (
<> <>

View file

@ -38,12 +38,14 @@ import { Tag, TagInput } from "@app/components/tags/tag-input";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"), name: z.string().nonempty("Name is required"),
dockerSocketEnabled: z.boolean().optional(), dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z.array( remoteSubnets: z
.array(
z.object({ z.object({
id: z.string(), id: z.string(),
text: z.string() text: z.string()
}) })
).optional() )
.optional()
}); });
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -55,7 +57,9 @@ export default function GeneralPage() {
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null); const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
null
);
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@ -66,7 +70,7 @@ export default function GeneralPage() {
name: site?.name, name: site?.name,
dockerSocketEnabled: site?.dockerSocketEnabled ?? false, dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
remoteSubnets: site?.remoteSubnets remoteSubnets: site?.remoteSubnets
? site.remoteSubnets.split(',').map((subnet, index) => ({ ? site.remoteSubnets.split(",").map((subnet, index) => ({
id: subnet.trim(), id: subnet.trim(),
text: subnet.trim() text: subnet.trim()
})) }))
@ -82,7 +86,10 @@ export default function GeneralPage() {
.post(`/site/${site?.siteId}`, { .post(`/site/${site?.siteId}`, {
name: data.name, name: data.name,
dockerSocketEnabled: data.dockerSocketEnabled, dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' remoteSubnets:
data.remoteSubnets
?.map((subnet) => subnet.text)
.join(",") || ""
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@ -98,7 +105,8 @@ export default function GeneralPage() {
updateSite({ updateSite({
name: data.name, name: data.name,
dockerSocketEnabled: data.dockerSocketEnabled, dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' remoteSubnets:
data.remoteSubnets?.map((subnet) => subnet.text).join(",") || ""
}); });
toast({ toast({
@ -145,21 +153,37 @@ export default function GeneralPage() {
)} )}
/> />
{env.flags.enableClients &&
site.type === "newt" ? (
<FormField <FormField
control={form.control} control={form.control}
name="remoteSubnets" name="remoteSubnets"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("remoteSubnets")}</FormLabel> <FormLabel>
{t("remoteSubnets")}
</FormLabel>
<FormControl> <FormControl>
<TagInput <TagInput
{...field} {...field}
activeTagIndex={activeCidrTagIndex} activeTagIndex={
setActiveTagIndex={setActiveCidrTagIndex} activeCidrTagIndex
placeholder={t("enterCidrRange")} }
setActiveTagIndex={
setActiveCidrTagIndex
}
placeholder={t(
"enterCidrRange"
)}
size="sm" size="sm"
tags={form.getValues().remoteSubnets || []} tags={
setTags={(newSubnets) => { form.getValues()
.remoteSubnets ||
[]
}
setTags={(
newSubnets
) => {
form.setValue( form.setValue(
"remoteSubnets", "remoteSubnets",
newSubnets as Tag[] newSubnets as Tag[]
@ -167,20 +191,26 @@ export default function GeneralPage() {
}} }}
validateTag={(tag) => { validateTag={(tag) => {
// Basic CIDR validation regex // Basic CIDR validation regex
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; const cidrRegex =
return cidrRegex.test(tag); /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
return cidrRegex.test(
tag
);
}} }}
allowDuplicates={false} allowDuplicates={false}
sortTags={true} sortTags={true}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t("remoteSubnetsDescription")} {t(
"remoteSubnetsDescription"
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
) : null}
{site && site.type === "newt" && ( {site && site.type === "newt" && (
<FormField <FormField

View file

@ -877,7 +877,7 @@ WantedBy=default.target`
<p className="font-bold mb-3"> <p className="font-bold mb-3">
{t("siteConfiguration")} {t("siteConfiguration")}
</p> </p>
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel <CheckboxWithLabel
id="acceptClients" id="acceptClients"
aria-describedby="acceptClients-desc" aria-describedby="acceptClients-desc"

View file

@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -112,12 +113,11 @@ export default function InitialSetupPage() {
<FormItem> <FormItem>
<FormLabel>{t("setupToken")}</FormLabel> <FormLabel>{t("setupToken")}</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} autoComplete="off" />
{...field}
placeholder={t("setupTokenPlaceholder")}
autoComplete="off"
/>
</FormControl> </FormControl>
<FormDescription>
{t("setupTokenDescription")}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View file

@ -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<string | null>(null);
useEffect(() => {
async function initiateAutoLogin() {
setLoading(true);
try {
const res = await api.post<
AxiosResponse<GenerateOidcUrlResponse>
>(`/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 (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("autoLoginTitle")}</CardTitle>
<CardDescription>{t("autoLoginDescription")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>{t("autoLoginProcessing")}</span>
</div>
)}
{!loading && !error && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span>{t("autoLoginRedirecting")}</span>
</div>
)}
{error && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>{t("autoLoginError")}</span>
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -15,6 +15,7 @@ import AccessToken from "./AccessToken";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { LoginFormIDP } from "@app/components/LoginForm"; import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp"; import { ListIdpsResponse } from "@server/routers/idp";
import AutoLoginHandler from "./AutoLoginHandler";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -30,11 +31,13 @@ export default async function ResourceAuthPage(props: {
const env = pullEnv(); const env = pullEnv();
const authHeader = await authCookieHeader();
let authInfo: GetResourceAuthInfoResponse | undefined; let authInfo: GetResourceAuthInfoResponse | undefined;
try { try {
const res = await internal.get< const res = await internal.get<
AxiosResponse<GetResourceAuthInfoResponse> AxiosResponse<GetResourceAuthInfoResponse>
>(`/resource/${params.resourceId}/auth`, await authCookieHeader()); >(`/resource/${params.resourceId}/auth`, authHeader);
if (res && res.status === 200) { if (res && res.status === 200) {
authInfo = res.data.data; authInfo = res.data.data;
@ -62,7 +65,6 @@ export default async function ResourceAuthPage(props: {
const redirectPort = new URL(searchParams.redirect).port; const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`; const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
if (serverResourceHost === redirectHost) { if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect; redirectUrl = searchParams.redirect;
} else if (serverResourceHostWithPort === redirectHost) { } else if (serverResourceHostWithPort === redirectHost) {
@ -144,6 +146,19 @@ export default async function ResourceAuthPage(props: {
name: idp.name name: idp.name
})) as LoginFormIDP[]; })) as LoginFormIDP[];
if (authInfo.skipToIdpId && authInfo.skipToIdpId !== null) {
const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId);
if (idp) {
return (
<AutoLoginHandler
resourceId={authInfo.resourceId}
skipToIdpId={authInfo.skipToIdpId}
redirectUrl={redirectUrl}
/>
);
}
}
return ( return (
<> <>
{userIsUnauthorized && isSSOOnly ? ( {userIsUnauthorized && isSSOOnly ? (

View file

@ -43,35 +43,30 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Search, RefreshCw, Filter, Columns } from "lucide-react"; import { Search, RefreshCw, Filter, Columns } from "lucide-react";
import { GetSiteResponse, Container } from "@server/routers/site"; import { Container } from "@server/routers/site";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { FaDocker } from "react-icons/fa";
// Type definitions based on the JSON structure
interface ContainerSelectorProps { interface ContainerSelectorProps {
site: GetSiteResponse; site: { siteId: number; name: string; type: string };
containers: Container[];
isAvailable: boolean;
onContainerSelect?: (hostname: string, port?: number) => void; onContainerSelect?: (hostname: string, port?: number) => void;
onRefresh?: () => void;
} }
export const ContainersSelector: FC<ContainerSelectorProps> = ({ export const ContainersSelector: FC<ContainerSelectorProps> = ({
site, site,
onContainerSelect containers,
isAvailable,
onContainerSelect,
onRefresh
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
const { isAvailable, containers, fetchContainers } = useDockerSocket(site); if (!site || !isAvailable || site.type !== "newt") {
useEffect(() => {
console.log("DockerSocket isAvailable:", isAvailable);
if (isAvailable) {
fetchContainers();
}
}, [isAvailable]);
if (!site || !isAvailable) {
return null; return null;
} }
@ -84,13 +79,14 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
return ( return (
<> <>
<a <Button
type="button" type="button"
className="text-sm text-primary hover:underline cursor-pointer" variant="outline"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
title={t("viewDockerContainers")}
> >
{t("viewDockerContainers")} <FaDocker size={15} />
</a> </Button>
<Credenza open={open} onOpenChange={setOpen}> <Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-[75vw] max-h-[75vh] flex flex-col"> <CredenzaContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
<CredenzaHeader> <CredenzaHeader>
@ -106,7 +102,7 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
<DockerContainersTable <DockerContainersTable
containers={containers} containers={containers}
onContainerSelect={handleContainerSelect} onContainerSelect={handleContainerSelect}
onRefresh={() => fetchContainers()} onRefresh={onRefresh || (() => {})}
/> />
</div> </div>
</CredenzaBody> </CredenzaBody>
@ -263,7 +259,9 @@ const DockerContainersTable: FC<{
size="sm" size="sm"
className="h-6 px-2 text-xs hover:bg-muted" className="h-6 px-2 text-xs hover:bg-muted"
> >
{t("containerLabelsCount", { count: labelEntries.length })} {t("containerLabelsCount", {
count: labelEntries.length
})}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent side="top" align="start"> <PopoverContent side="top" align="start">
@ -279,7 +277,10 @@ const DockerContainersTable: FC<{
{key} {key}
</div> </div>
<div className="font-mono text-muted-foreground pl-2 break-all"> <div className="font-mono text-muted-foreground pl-2 break-all">
{value || t("containerLabelEmpty")} {value ||
t(
"containerLabelEmpty"
)}
</div> </div>
</div> </div>
))} ))}
@ -316,7 +317,9 @@ const DockerContainersTable: FC<{
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="link" size="sm"> <Button variant="link" size="sm">
{t("containerPortsMore", { count: ports.length - 2 })} {t("containerPortsMore", {
count: ports.length - 2
})}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@ -356,7 +359,9 @@ const DockerContainersTable: FC<{
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
onClick={() => onContainerSelect(row.original, ports[0])} onClick={() =>
onContainerSelect(row.original, ports[0])
}
disabled={row.original.state !== "running"} disabled={row.original.state !== "running"}
> >
{t("select")} {t("select")}
@ -415,9 +420,7 @@ const DockerContainersTable: FC<{
hideStoppedContainers) && hideStoppedContainers) &&
containers.length > 0 ? ( containers.length > 0 ? (
<> <>
<p> <p>{t("noContainersMatchingFilters")}</p>
{t("noContainersMatchingFilters")}
</p>
<div className="space-x-2"> <div className="space-x-2">
{hideContainersWithoutPorts && ( {hideContainersWithoutPorts && (
<Button <Button
@ -446,9 +449,7 @@ const DockerContainersTable: FC<{
</div> </div>
</> </>
) : ( ) : (
<p> <p>{t("noContainersFound")}</p>
{t("noContainersFound")}
</p>
)} )}
</div> </div>
</div> </div>
@ -463,7 +464,9 @@ const DockerContainersTable: FC<{
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder={t("searchContainersPlaceholder", { count: initialFilters.length })} placeholder={t("searchContainersPlaceholder", {
count: initialFilters.length
})}
value={searchInput} value={searchInput}
onChange={(event) => onChange={(event) =>
setSearchInput(event.target.value) setSearchInput(event.target.value)
@ -473,7 +476,10 @@ const DockerContainersTable: FC<{
{searchInput && {searchInput &&
table.getFilteredRowModel().rows.length > 0 && ( table.getFilteredRowModel().rows.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> <div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
{t("searchResultsCount", { count: table.getFilteredRowModel().rows.length })} {t("searchResultsCount", {
count: table.getFilteredRowModel().rows
.length
})}
</div> </div>
)} )}
</div> </div>
@ -644,7 +650,9 @@ const DockerContainersTable: FC<{
{t("searching")} {t("searching")}
</div> </div>
) : ( ) : (
t("noContainersFoundMatching", { filter: globalFilter }) t("noContainersFoundMatching", {
filter: globalFilter
})
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>

View file

@ -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<typeof formSchema>;
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
);
const form = useForm<FormData>({
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 (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-md">
<CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
<CredenzaDescription>
{t("createInternalResourceDialogNoSitesAvailableDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaFooter>
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
<CredenzaDescription>
{t("createInternalResourceDialogCreateClientResourceDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
id="create-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("createInternalResourceDialogResourceProperties")}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(site) => site.siteId === field.value
)?.name
: t("createInternalResourceDialogSelectSite")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
<CommandList>
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
<CommandGroup>
{availableSites.map((site) => (
<CommandItem
key={site.siteId}
value={site.name}
onSelect={() => {
field.onChange(site.siteId);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogProtocol")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
{t("createInternalResourceDialogTcp")}
</SelectItem>
<SelectItem value="udp">
{t("createInternalResourceDialogUdp")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogSitePortDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("createInternalResourceDialogTargetConfiguration")}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogDestinationIP")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationIPDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogDestinationPort")}
</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationPortDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -3,13 +3,28 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { import {
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
Building2, Building2,
Zap, Zap,
Check,
ChevronsUpDown,
ArrowUpDown ArrowUpDown
} from "lucide-react"; } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
@ -19,9 +34,9 @@ import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains"; import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { build } from "@server/build"; import { build } from "@server/build";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type OrganizationDomain = { type OrganizationDomain = {
domainId: string; domainId: string;
@ -39,17 +54,15 @@ type AvailableOption = {
type DomainOption = { type DomainOption = {
id: string; id: string;
domain: string; domain: string;
type: "organization" | "provided"; type: "organization" | "provided" | "provided-search";
verified?: boolean; verified?: boolean;
domainType?: "ns" | "cname" | "wildcard"; domainType?: "ns" | "cname" | "wildcard";
domainId?: string; domainId?: string;
domainNamespaceId?: string; domainNamespaceId?: string;
subdomain?: string;
}; };
interface DomainPickerProps { interface DomainPicker2Props {
orgId: string; orgId: string;
cols?: number;
onDomainChange?: (domainInfo: { onDomainChange?: (domainInfo: {
domainId: string; domainId: string;
domainNamespaceId?: string; domainNamespaceId?: string;
@ -58,34 +71,37 @@ interface DomainPickerProps {
fullDomain: string; fullDomain: string;
baseDomain: string; baseDomain: string;
}) => void; }) => void;
cols?: number;
} }
export default function DomainPicker({ export default function DomainPicker2({
orgId, orgId,
cols, onDomainChange,
onDomainChange cols = 2
}: DomainPickerProps) { }: DomainPicker2Props) {
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
const t = useTranslations(); const t = useTranslations();
const [userInput, setUserInput] = useState<string>(""); const [subdomainInput, setSubdomainInput] = useState<string>("");
const [selectedOption, setSelectedOption] = useState<DomainOption | null>( const [selectedBaseDomain, setSelectedBaseDomain] =
null useState<DomainOption | null>(null);
);
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>( const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
[] []
); );
const [isChecking, setIsChecking] = useState(false);
const [organizationDomains, setOrganizationDomains] = useState< const [organizationDomains, setOrganizationDomains] = useState<
OrganizationDomain[] OrganizationDomain[]
>([]); >([]);
const [loadingDomains, setLoadingDomains] = useState(false); const [loadingDomains, setLoadingDomains] = useState(false);
const [open, setOpen] = useState(false);
// Provided domain search states
const [userInput, setUserInput] = useState<string>("");
const [isChecking, setIsChecking] = useState(false);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<
"all" | "organization" | "provided"
>("all");
const [providedDomainsShown, setProvidedDomainsShown] = useState(3); const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
const [selectedProvidedDomain, setSelectedProvidedDomain] =
useState<AvailableOption | null>(null);
useEffect(() => { useEffect(() => {
const loadOrganizationDomains = async () => { const loadOrganizationDomains = async () => {
@ -107,6 +123,41 @@ export default function DomainPicker({
type: domain.type as "ns" | "cname" | "wildcard" type: domain.type as "ns" | "cname" | "wildcard"
})); }));
setOrganizationDomains(domains); 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) { } catch (error) {
console.error("Failed to load organization domains:", error); console.error("Failed to load organization domains:", error);
@ -123,135 +174,131 @@ export default function DomainPicker({
loadOrganizationDomains(); loadOrganizationDomains();
}, [orgId, api]); }, [orgId, api]);
// Generate domain options based on user input const checkAvailability = useCallback(
const generateDomainOptions = (): DomainOption[] => { 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[] = []; const options: DomainOption[] = [];
if (!userInput.trim()) return options;
// Add organization domain options
organizationDomains.forEach((orgDomain) => { 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({ options.push({
id: `org-${orgDomain.domainId}`, id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain, domain: orgDomain.baseDomain,
type: "organization", type: "organization",
verified: orgDomain.verified, verified: orgDomain.verified,
domainType: "cname", domainType: orgDomain.type,
domainId: orgDomain.domainId 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) if (build === "saas" || build === "enterprise") {
availableOptions.forEach((option) => { const domainOptionText =
build === "enterprise"
? "Provided Domain"
: "Free Provided Domain";
options.push({ options.push({
id: `provided-${option.domainNamespaceId}`, id: "provided-search",
domain: option.fullDomain, domain: domainOptionText,
type: "provided", type: "provided-search"
domainNamespaceId: option.domainNamespaceId,
domainId: option.domainId
});
}); });
}
// Sort options return options;
return options.sort((a, b) => {
const comparison = a.domain.localeCompare(b.domain);
return sortOrder === "asc" ? comparison : -comparison;
});
}; };
const domainOptions = generateDomainOptions(); const dropdownOptions = generateDropdownOptions();
// Filter options based on active tab const validateSubdomain = (
const filteredOptions = domainOptions.filter((option) => { subdomain: string,
if (activeTab === "all") return true; baseDomain: DomainOption
return option.type === activeTab; ): boolean => {
if (!baseDomain) return false;
if (baseDomain.type === "provided-search") {
return /^[a-zA-Z0-9-]+$/.test(subdomain);
}
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
}); });
}
// 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;
// Handle option selection
const handleOptionSelect = (option: DomainOption) => {
setSelectedOption(option);
if (option.type === "organization") { if (option.type === "organization") {
if (option.domainType === "cname") { if (option.domainType === "cname") {
@ -262,124 +309,343 @@ export default function DomainPicker({
fullDomain: option.domain, fullDomain: option.domain,
baseDomain: option.domain baseDomain: option.domain
}); });
} else if (option.domainType === "ns") { } else {
const subdomain = option.subdomain || "";
onDomainChange?.({ onDomainChange?.({
domainId: option.domainId!, domainId: option.domainId!,
type: "organization", type: "organization",
subdomain: subdomain || undefined, subdomain: undefined,
fullDomain: option.domain, fullDomain: option.domain,
baseDomain: 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?.({ onDomainChange?.({
domainId: option.domainId!, domainId: selectedBaseDomain.domainId!,
type: "organization", type: "organization",
subdomain: option.subdomain || undefined, subdomain: validInput || undefined,
fullDomain: option.domain, fullDomain: fullDomain,
baseDomain: option.subdomain baseDomain: selectedBaseDomain.domain
? option.domain.split(".").slice(1).join(".") });
: option.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 handleProvidedDomainInputChange = (value: string) => {
const baseDomain = parts.slice(1).join("."); const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
setUserInput(validInput);
// Clear selected domain when user types
if (selectedProvidedDomain) {
setSelectedProvidedDomain(null);
onDomainChange?.({ onDomainChange?.({
domainId: option.domainId!, domainId: "",
domainNamespaceId: option.domainNamespaceId,
type: "provided", type: "provided",
subdomain: subdomain, subdomain: undefined,
fullDomain: option.domain, fullDomain: "",
baseDomain: baseDomain 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 ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Domain Input */} <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="domain-input"> <Label htmlFor="subdomain-input">
{t("domainPickerEnterDomain")} {t("domainPickerSubdomainLabel")}
</Label> </Label>
<Input <Input
id="domain-input" id="subdomain-input"
value={userInput} value={
className="max-w-xl" selectedBaseDomain?.type === "provided-search"
? userInput
: subdomainInput
}
placeholder={
showProvidedDomainSearch
? ""
: showSubdomainInput
? ""
: t("domainPickerNotAvailableForCname")
}
disabled={
!showSubdomainInput && !showProvidedDomainSearch
}
className={cn(
!isSubdomainValid &&
subdomainInput &&
"border-red-500"
)}
onChange={(e) => { onChange={(e) => {
// Only allow letters, numbers, hyphens, and periods if (showProvidedDomainSearch) {
const validInput = e.target.value.replace( handleProvidedDomainInputChange(e.target.value);
/[^a-zA-Z0-9.-]/g, } else {
"" handleSubdomainChange(e.target.value);
); }
setUserInput(validInput);
// Clear selection when input changes
setSelectedOption(null);
}} }}
/> />
{showSubdomainInput && !subdomainInput && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{build === "saas" {t("domainPickerEnterSubdomainOrLeaveBlank")}
? t("domainPickerDescriptionSaas")
: t("domainPickerDescription")}
</p> </p>
)}
{showProvidedDomainSearch && !userInput && (
<p className="text-sm text-muted-foreground">
{t("domainPickerEnterSubdomainToSearch")}
</p>
)}
</div> </div>
{/* Tabs and Sort Toggle */} <div className="space-y-2">
{build === "saas" && ( <Label>{t("domainPickerBaseDomainLabel")}</Label>
<div className="flex justify-between items-center"> <Popover open={open} onOpenChange={setOpen}>
<Tabs <PopoverTrigger asChild>
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
{build == "saas" && (
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
)}
</TabsList>
</Tabs>
<Button <Button
variant="outline" variant="outline"
size="sm" role="combobox"
onClick={() => aria-expanded={open}
setSortOrder(sortOrder === "asc" ? "desc" : "asc") className="w-full justify-between"
>
{selectedBaseDomain ? (
<div className="flex items-center space-x-2 min-w-0 flex-1">
{selectedBaseDomain.type ===
"organization" ? null : (
<Zap className="h-4 w-4 flex-shrink-0" />
)}
<span className="truncate">
{selectedBaseDomain.domain}
</span>
{selectedBaseDomain.verified && (
<CheckCircle2 className="h-3 w-3 text-green-500 flex-shrink-0" />
)}
</div>
) : (
t("domainPickerSelectBaseDomain")
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t("domainPickerSearchDomains")}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t("domainPickerNoDomainsFound")}
</div>
</CommandEmpty>
{organizationDomains.length > 0 && (
<>
<CommandGroup
heading={t(
"domainPickerOrganizationDomains"
)}
className="py-2"
>
<CommandList>
{organizationDomains.map(
(orgDomain) => (
<CommandItem
key={`org-${orgDomain.domainId}`}
onSelect={() =>
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
} }
> >
<ArrowUpDown className="h-4 w-4 mr-2" /> <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
{sortOrder === "asc" <Building2 className="h-4 w-4 text-muted-foreground" />
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div> </div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{
orgDomain.baseDomain
}
</span>
<span className="text-xs text-muted-foreground">
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? "Verified"
: "Unverified"}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
`org-${orgDomain.domainId}`
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandList>
</CommandGroup>
{(build === "saas" ||
build === "enterprise") && (
<CommandSeparator className="my-2" />
)}
</>
)} )}
{/* Loading State */} {(build === "saas" ||
build === "enterprise") && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t("domainPickerFreeDomains")
}
className="py-2"
>
<CommandList>
<CommandItem
key="provided-search"
onSelect={() =>
handleBaseDomainSelect({
id: "provided-search",
domain:
build ===
"enterprise"
? "Provided Domain"
: "Free Provided Domain",
type: "provided-search"
})
}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Zap className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build === "enterprise"
? "Provided Domain"
: "Free Provided Domain"}
</span>
<span className="text-xs text-muted-foreground">
{t(
"domainPickerSearchForAvailableDomains"
)}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
selectedBaseDomain?.id ===
"provided-search"
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
</CommandList>
</CommandGroup>
)}
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{showProvidedDomainSearch && (
<div className="space-y-4">
{isChecking && ( {isChecking && (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2 text-sm text-muted-foreground"> <div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>{t("domainPickerCheckingAvailability")}</span> <span>
{t("domainPickerCheckingAvailability")}
</span>
</div> </div>
</div> </div>
)} )}
{/* No Options */}
{!isChecking && {!isChecking &&
filteredOptions.length === 0 && sortedAvailableOptions.length === 0 &&
userInput.trim() && ( userInput.trim() && (
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
@ -389,131 +655,67 @@ export default function DomainPicker({
</Alert> </Alert>
)} )}
{/* Domain Options */} {!isChecking && sortedAvailableOptions.length > 0 && (
{!isChecking && filteredOptions.length > 0 && (
<div className="space-y-4">
{/* Organization Domains */}
{organizationOptions.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
{build !== "oss" && ( <RadioGroup
<div className="flex items-center space-x-2"> value={
<Building2 className="h-4 w-4" /> selectedProvidedDomain?.domainNamespaceId ||
<h4 className="text-sm font-medium"> ""
{t("domainPickerOrganizationDomains")}
</h4>
</div>
)}
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{organizationOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/10"
: "border-input hover:bg-accent",
option.verified
? "cursor-pointer"
: "cursor-not-allowed opacity-60"
)}
onClick={() =>
option.verified &&
handleOptionSelect(option)
} }
onValueChange={(value) => {
const option =
displayedProvidedOptions.find(
(opt) =>
opt.domainNamespaceId === value
);
if (option) {
handleProvidedDomainSelect(option);
}
}}
className={`grid gap-2 grid-cols-1 sm:grid-cols-${cols}`}
> >
<div className="flex items-center justify-between"> {displayedProvidedOptions.map((option) => (
<div className="flex-1"> <label
<div className="flex items-center space-x-2"> key={option.domainNamespaceId}
<p className="font-mono text-sm"> htmlFor={option.domainNamespaceId}
{option.domain} data-state={
</p> selectedProvidedDomain?.domainNamespaceId ===
{/* <Badge */} option.domainNamespaceId
{/* variant={ */} ? "checked"
{/* option.domainType === */} : "unchecked"
{/* "ns" */}
{/* ? "default" */}
{/* : "secondary" */}
{/* } */}
{/* > */}
{/* {option.domainType} */}
{/* </Badge> */}
{option.verified ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertCircle className="h-3 w-3 text-yellow-500" />
)}
</div>
{option.subdomain && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"domainPickerSubdomain",
{
subdomain:
option.subdomain
} }
)}
</p>
)}
{!option.verified && (
<p className="text-xs text-yellow-600 mt-1">
Domain is unverified
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Provided Domains */}
{providedOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<div className="text-sm font-medium">
{t("domainPickerProvidedDomains")}
</div>
</div>
<div className={`grid gap-2 ${cols ? `grid-cols-${cols}` : 'grid-cols-1 sm:grid-cols-2'}`}>
{providedOptions.map((option) => (
<div
key={option.id}
className={cn( className={cn(
"transition-all p-3 rounded-lg border", "relative flex rounded-lg border p-3 transition-colors cursor-pointer",
selectedOption?.id === option.id selectedProvidedDomain?.domainNamespaceId ===
option.domainNamespaceId
? "border-primary bg-primary/10" ? "border-primary bg-primary/10"
: "border-input", : "border-input hover:bg-accent"
"cursor-pointer hover:bg-accent"
)} )}
onClick={() =>
handleOptionSelect(option)
}
> >
<div className="flex items-center justify-between"> <RadioGroupItem
value={option.domainNamespaceId}
id={option.domainNamespaceId}
className="absolute left-3 top-3 h-4 w-4 border-primary text-primary"
/>
<div className="flex items-center justify-between pl-7 flex-1">
<div> <div>
<p className="font-mono text-sm"> <p className="font-mono text-sm">
{option.domain} {option.fullDomain}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"domainPickerNamespace", "domainPickerNamespace",
{ {
namespace: namespace:
option.domainNamespaceId as string option.domainNamespaceId
} }
)} )}
</p> </p>
</div> </div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div> </div>
</label>
))} ))}
</div> </RadioGroup>
{hasMoreProvided && ( {hasMoreProvided && (
<Button <Button
variant="outline" variant="outline"
@ -532,6 +734,15 @@ export default function DomainPicker({
)} )}
</div> </div>
)} )}
{loadingDomains && (
<div className="flex items-center justify-center p-4">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>{t("domainPickerLoadingDomains")}</span>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -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<typeof formSchema>;
const form = useForm<FormData>({
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 (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
<CredenzaDescription>
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogDestinationIP")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogDestinationPort")}</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -70,7 +70,7 @@ export function LayoutSidebar({
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
/> />
</div> </div>
<div className="px-4 pt-1"> <div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && ( {!isAdminPage && user.serverAdmin && (
<div className="pb-4"> <div className="pb-4">
<Link <Link

View file

@ -54,7 +54,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn( className={cn(
"shadow-xs", "shadow-2xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4" isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)} )}
> >

View file

@ -150,7 +150,7 @@ export function SidebarNav({
{section.heading} {section.heading}
</div> </div>
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 mt-1 pl-2">
{section.items.map((item) => { {section.items.map((item) => {
const hydratedHref = hydrateHref(item.href); const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref); const isActive = pathname.startsWith(hydratedHref);

View file

@ -9,7 +9,7 @@ const alertVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-card border text-foreground", default: "bg-card border text-foreground",
neutral: "bg-card border text-foreground", neutral: "bg-card bg-muted border text-foreground",
destructive: destructive:
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success: success:

View file

@ -30,7 +30,15 @@ import {
CardHeader, CardHeader,
CardTitle CardTitle
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useMemo } from "react";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
type DataTableProps<TData, TValue> = { type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@ -46,6 +54,8 @@ type DataTableProps<TData, TValue> = {
id: string; id: string;
desc: boolean; desc: boolean;
}; };
tabs?: TabFilter[];
defaultTab?: string;
}; };
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@ -58,17 +68,36 @@ export function DataTable<TData, TValue>({
isRefreshing, isRefreshing,
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
searchColumn = "name", searchColumn = "name",
defaultSort defaultSort,
tabs,
defaultTab
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>( const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : [] defaultSort ? [defaultSort] : []
); );
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]); const [globalFilter, setGlobalFilter] = useState<any>([]);
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const t = useTranslations(); 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({ const table = useReactTable({
data, data: filteredData,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
@ -90,21 +119,50 @@ export function DataTable<TData, TValue>({
} }
}); });
const handleTabChange = (value: string) => {
setActiveTab(value);
// Reset to first page when changing tabs
table.setPageIndex(0);
};
return ( return (
<div className="container mx-auto max-w-12xl"> <div className="container mx-auto max-w-12xl">
<Card> <Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4"> <CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex items-center w-full sm:max-w-sm sm:mr-2 relative"> <div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={globalFilter ?? ""} value={globalFilter ?? ""}
onChange={(e) => onChange={(e) =>
table.setGlobalFilter(String(e.target.value)) table.setGlobalFilter(
String(e.target.value)
)
} }
className="w-full pl-8" className="w-full pl-8"
/> />
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" /> <Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div> </div>
{tabs && tabs.length > 0 && (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
>
{tab.label} (
{data.filter(tab.filterFn).length})
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end"> <div className="flex items-center gap-2 sm:justify-end">
{onRefresh && ( {onRefresh && (
<Button <Button

View file

@ -55,7 +55,7 @@ function InputOTPSlot({
data-slot="input-otp-slot" data-slot="input-otp-slot"
data-active={isActive} data-active={isActive}
className={cn( 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 className
)} )}
{...props} {...props}

View file

@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
data-slot="input" data-slot="input"
className={cn( 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]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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]", "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", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className

View file

@ -36,7 +36,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( 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 className
)} )}
{...props} {...props}

View file

@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground", "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
className className
)} )}
{...props} {...props}

View file

@ -1,11 +1,9 @@
import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { GetResourceResponse } from "@server/routers/resource/getResource"; import { GetResourceResponse } from "@server/routers/resource/getResource";
import { GetSiteResponse } from "@server/routers/site";
import { createContext } from "react"; import { createContext } from "react";
interface ResourceContextType { interface ResourceContextType {
resource: GetResourceResponse; resource: GetResourceResponse;
site: GetSiteResponse | null;
authInfo: GetResourceAuthInfoResponse; authInfo: GetResourceAuthInfoResponse;
updateResource: (updatedResource: Partial<GetResourceResponse>) => void; updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
updateAuthInfo: ( updateAuthInfo: (

View file

@ -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<GetDockerStatusResponse>();
const [containers, setContainers] = useState<Container[]>([]);
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<AxiosResponse<GetDockerStatusResponse>>(
`/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<ListContainersResponse>
>(`/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<AxiosResponse<TriggerFetchResponse>>(
`/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
};
}

136
src/lib/docker.ts Normal file
View file

@ -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<void> {
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<GetDockerStatusResponse | null> {
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<Container[]> {
const fetchContainerList = async (): Promise<Container[]> => {
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<DockerState> {
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
};
}
}

View file

@ -3,20 +3,17 @@
import ResourceContext from "@app/contexts/resourceContext"; import ResourceContext from "@app/contexts/resourceContext";
import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { GetResourceResponse } from "@server/routers/resource/getResource"; import { GetResourceResponse } from "@server/routers/resource/getResource";
import { GetSiteResponse } from "@server/routers/site";
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface ResourceProviderProps { interface ResourceProviderProps {
children: React.ReactNode; children: React.ReactNode;
resource: GetResourceResponse; resource: GetResourceResponse;
site: GetSiteResponse | null;
authInfo: GetResourceAuthInfoResponse; authInfo: GetResourceAuthInfoResponse;
} }
export function ResourceProvider({ export function ResourceProvider({
children, children,
site,
resource: serverResource, resource: serverResource,
authInfo: serverAuthInfo authInfo: serverAuthInfo
}: ResourceProviderProps) { }: ResourceProviderProps) {
@ -66,7 +63,7 @@ export function ResourceProvider({
return ( return (
<ResourceContext.Provider <ResourceContext.Provider
value={{ resource, updateResource, site, authInfo, updateAuthInfo }} value={{ resource, updateResource, authInfo, updateAuthInfo }}
> >
{children} {children}
</ResourceContext.Provider> </ResourceContext.Provider>