mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-20 01:08:41 +02:00
add site targets, client resources, and auto login
This commit is contained in:
parent
67ba225003
commit
5c04b1e14a
80 changed files with 5651 additions and 2385 deletions
|
@ -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
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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,9 +185,11 @@ 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")
|
||||||
onDelete: "cascade"
|
.notNull()
|
||||||
}),
|
.references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
publicKey: text("publicKey").notNull(),
|
publicKey: text("publicKey").notNull(),
|
||||||
signCount: integer("signCount").notNull(),
|
signCount: integer("signCount").notNull(),
|
||||||
transports: text("transports"),
|
transports: text("transports"),
|
||||||
|
@ -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>;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
62
server/middlewares/verifySiteResourceAccess.ts
Normal file
62
server/middlewares/verifySiteResourceAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
39
server/routers/client/targets.ts
Normal file
39
server/routers/client/targets.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -220,78 +220,37 @@ 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
|
.select({
|
||||||
const resourcesList = await tx
|
resourceId: targets.resourceId,
|
||||||
.select({
|
targetId: targets.targetId,
|
||||||
resourceId: resources.resourceId,
|
ip: targets.ip,
|
||||||
subdomain: resources.subdomain,
|
method: targets.method,
|
||||||
fullDomain: resources.fullDomain,
|
port: targets.port,
|
||||||
ssl: resources.ssl,
|
internalPort: targets.internalPort,
|
||||||
blockAccess: resources.blockAccess,
|
enabled: targets.enabled,
|
||||||
sso: resources.sso,
|
protocol: resources.protocol
|
||||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
})
|
||||||
http: resources.http,
|
.from(targets)
|
||||||
proxyPort: resources.proxyPort,
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
protocol: resources.protocol
|
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||||
})
|
|
||||||
.from(resources)
|
|
||||||
.where(and(eq(resources.siteId, siteId), eq(resources.http, false)));
|
|
||||||
|
|
||||||
// Get all enabled targets for these resources in a single query
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
(acc, target) => {
|
||||||
const allTargets =
|
// Filter out invalid targets
|
||||||
resourceIds.length > 0
|
if (!target.internalPort || !target.ip || !target.port) {
|
||||||
? await tx
|
return acc;
|
||||||
.select({
|
}
|
||||||
resourceId: targets.resourceId,
|
|
||||||
targetId: targets.targetId,
|
|
||||||
ip: targets.ip,
|
|
||||||
method: targets.method,
|
|
||||||
port: targets.port,
|
|
||||||
internalPort: targets.internalPort,
|
|
||||||
enabled: targets.enabled,
|
|
||||||
})
|
|
||||||
.from(targets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(targets.resourceId, resourceIds),
|
|
||||||
eq(targets.enabled, true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Combine the data in JS instead of using SQL for the JSON
|
// Format target into string
|
||||||
return resourcesList.map((resource) => ({
|
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
|
||||||
...resource,
|
|
||||||
targets: allTargets.filter(
|
|
||||||
(target) => target.resourceId === resource.resourceId
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
|
||||||
(acc, resource) => {
|
|
||||||
// Skip resources with no targets
|
|
||||||
if (!resource.targets?.length) return acc;
|
|
||||||
|
|
||||||
// Format valid targets into strings
|
|
||||||
const formattedTargets = resource.targets
|
|
||||||
.filter(
|
|
||||||
(target: Target) =>
|
|
||||||
resource.proxyPort && target?.ip && target?.port
|
|
||||||
)
|
|
||||||
.map(
|
|
||||||
(target: Target) =>
|
|
||||||
`${resource.proxyPort}:${target.ip}:${target.port}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
|
@ -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,78 +162,37 @@ 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
|
.select({
|
||||||
const resourcesList = await tx
|
resourceId: targets.resourceId,
|
||||||
.select({
|
targetId: targets.targetId,
|
||||||
resourceId: resources.resourceId,
|
ip: targets.ip,
|
||||||
subdomain: resources.subdomain,
|
method: targets.method,
|
||||||
fullDomain: resources.fullDomain,
|
port: targets.port,
|
||||||
ssl: resources.ssl,
|
internalPort: targets.internalPort,
|
||||||
blockAccess: resources.blockAccess,
|
enabled: targets.enabled,
|
||||||
sso: resources.sso,
|
protocol: resources.protocol
|
||||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
})
|
||||||
http: resources.http,
|
.from(targets)
|
||||||
proxyPort: resources.proxyPort,
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
protocol: resources.protocol
|
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||||
})
|
|
||||||
.from(resources)
|
|
||||||
.where(eq(resources.siteId, siteId));
|
|
||||||
|
|
||||||
// Get all enabled targets for these resources in a single query
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
(acc, target) => {
|
||||||
const allTargets =
|
// Filter out invalid targets
|
||||||
resourceIds.length > 0
|
if (!target.internalPort || !target.ip || !target.port) {
|
||||||
? await tx
|
return acc;
|
||||||
.select({
|
}
|
||||||
resourceId: targets.resourceId,
|
|
||||||
targetId: targets.targetId,
|
|
||||||
ip: targets.ip,
|
|
||||||
method: targets.method,
|
|
||||||
port: targets.port,
|
|
||||||
internalPort: targets.internalPort,
|
|
||||||
enabled: targets.enabled
|
|
||||||
})
|
|
||||||
.from(targets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(targets.resourceId, resourceIds),
|
|
||||||
eq(targets.enabled, true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Combine the data in JS instead of using SQL for the JSON
|
// Format target into string
|
||||||
return resourcesList.map((resource) => ({
|
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
|
||||||
...resource,
|
|
||||||
targets: allTargets.filter(
|
|
||||||
(target) => target.resourceId === resource.resourceId
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
|
||||||
(acc, resource) => {
|
|
||||||
// Skip resources with no targets
|
|
||||||
if (!resource.targets?.length) return acc;
|
|
||||||
|
|
||||||
// Format valid targets into strings
|
|
||||||
const formattedTargets = resource.targets
|
|
||||||
.filter(
|
|
||||||
(target: Target) =>
|
|
||||||
target?.internalPort && target?.ip && target?.port
|
|
||||||
)
|
|
||||||
.map(
|
|
||||||
(target: Target) =>
|
|
||||||
`${target.internalPort}:${target.ip}:${target.port}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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,82 +39,38 @@ const listResourcesSchema = z.object({
|
||||||
.pipe(z.number().int().nonnegative())
|
.pipe(z.number().int().nonnegative())
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryResources(
|
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||||
accessibleResourceIds: number[],
|
return db
|
||||||
siteId?: number,
|
.select({
|
||||||
orgId?: string
|
resourceId: resources.resourceId,
|
||||||
) {
|
name: resources.name,
|
||||||
if (siteId) {
|
ssl: resources.ssl,
|
||||||
return db
|
fullDomain: resources.fullDomain,
|
||||||
.select({
|
passwordId: resourcePassword.passwordId,
|
||||||
resourceId: resources.resourceId,
|
sso: resources.sso,
|
||||||
name: resources.name,
|
pincodeId: resourcePincode.pincodeId,
|
||||||
fullDomain: resources.fullDomain,
|
whitelist: resources.emailWhitelistEnabled,
|
||||||
ssl: resources.ssl,
|
http: resources.http,
|
||||||
siteName: sites.name,
|
protocol: resources.protocol,
|
||||||
siteId: sites.niceId,
|
proxyPort: resources.proxyPort,
|
||||||
passwordId: resourcePassword.passwordId,
|
enabled: resources.enabled,
|
||||||
pincodeId: resourcePincode.pincodeId,
|
domainId: resources.domainId
|
||||||
sso: resources.sso,
|
})
|
||||||
whitelist: resources.emailWhitelistEnabled,
|
.from(resources)
|
||||||
http: resources.http,
|
.leftJoin(
|
||||||
protocol: resources.protocol,
|
resourcePassword,
|
||||||
proxyPort: resources.proxyPort,
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
enabled: resources.enabled,
|
)
|
||||||
domainId: resources.domainId
|
.leftJoin(
|
||||||
})
|
resourcePincode,
|
||||||
.from(resources)
|
eq(resourcePincode.resourceId, resources.resourceId)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
)
|
||||||
.leftJoin(
|
.where(
|
||||||
resourcePassword,
|
and(
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
|
eq(resources.orgId, orgId)
|
||||||
)
|
)
|
||||||
.leftJoin(
|
);
|
||||||
resourcePincode,
|
|
||||||
eq(resourcePincode.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
|
||||||
eq(resources.siteId, siteId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (orgId) {
|
|
||||||
return db
|
|
||||||
.select({
|
|
||||||
resourceId: resources.resourceId,
|
|
||||||
name: resources.name,
|
|
||||||
ssl: resources.ssl,
|
|
||||||
fullDomain: resources.fullDomain,
|
|
||||||
siteName: sites.name,
|
|
||||||
siteId: sites.niceId,
|
|
||||||
passwordId: resourcePassword.passwordId,
|
|
||||||
sso: resources.sso,
|
|
||||||
pincodeId: resourcePincode.pincodeId,
|
|
||||||
whitelist: resources.emailWhitelistEnabled,
|
|
||||||
http: resources.http,
|
|
||||||
protocol: resources.protocol,
|
|
||||||
proxyPort: resources.proxyPort,
|
|
||||||
enabled: resources.enabled,
|
|
||||||
domainId: resources.domainId
|
|
||||||
})
|
|
||||||
.from(resources)
|
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
|
||||||
.leftJoin(
|
|
||||||
resourcePassword,
|
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
resourcePincode,
|
|
||||||
eq(resourcePincode.resourceId, resources.resourceId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListResourcesResponse = {
|
export type ListResourcesResponse = {
|
||||||
|
@ -131,20 +78,6 @@ export type ListResourcesResponse = {
|
||||||
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(
|
||||||
|
@ -207,24 +142,27 @@ export async function listResources(
|
||||||
let accessibleResources;
|
let accessibleResources;
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
accessibleResources = await db
|
accessibleResources = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
||||||
})
|
})
|
||||||
.from(userResources)
|
.from(userResources)
|
||||||
.fullJoin(
|
.fullJoin(
|
||||||
roleResources,
|
roleResources,
|
||||||
eq(userResources.resourceId, roleResources.resourceId)
|
eq(userResources.resourceId, roleResources.resourceId)
|
||||||
)
|
|
||||||
.where(
|
|
||||||
or(
|
|
||||||
eq(userResources.userId, req.user!.userId),
|
|
||||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
|
||||||
)
|
)
|
||||||
);
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userResources.userId, req.user!.userId),
|
||||||
|
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||||
|
)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
accessibleResources = await db.select({
|
accessibleResources = await db
|
||||||
resourceId: resources.resourceId
|
.select({
|
||||||
}).from(resources).where(eq(resources.orgId, orgId));
|
resourceId: resources.resourceId
|
||||||
|
})
|
||||||
|
.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;
|
||||||
|
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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";
|
|
@ -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, {
|
||||||
|
|
171
server/routers/siteResource/createSiteResource.ts
Normal file
171
server/routers/siteResource/createSiteResource.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
124
server/routers/siteResource/deleteSiteResource.ts
Normal file
124
server/routers/siteResource/deleteSiteResource.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
83
server/routers/siteResource/getSiteResource.ts
Normal file
83
server/routers/siteResource/getSiteResource.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
6
server/routers/siteResource/index.ts
Normal file
6
server/routers/siteResource/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export * from "./createSiteResource";
|
||||||
|
export * from "./deleteSiteResource";
|
||||||
|
export * from "./getSiteResource";
|
||||||
|
export * from "./updateSiteResource";
|
||||||
|
export * from "./listSiteResources";
|
||||||
|
export * from "./listAllSiteResourcesByOrg";
|
111
server/routers/siteResource/listAllSiteResourcesByOrg.ts
Normal file
111
server/routers/siteResource/listAllSiteResourcesByOrg.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
118
server/routers/siteResource/listSiteResources.ts
Normal file
118
server/routers/siteResource/listSiteResources.ts
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
196
server/routers/siteResource/updateSiteResource.ts
Normal file
196
server/routers/siteResource/updateSiteResource.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.siteId, siteId));
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
|
||||||
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(
|
const targetsRes = await db
|
||||||
resourcesRes.map(async (resource) => {
|
.select()
|
||||||
const targetsRes = await db
|
.from(targets)
|
||||||
.select()
|
.where(eq(targets.siteId, siteId));
|
||||||
.from(targets)
|
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
const targetIps = targetsRes.map((target) => `${target.ip}/32`);
|
||||||
return targetsRes.map((target) => `${target.ip}/32`);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return targetIps.flat();
|
return targetIps.flat();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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
|
||||||
|
targetId: targets.targetId,
|
||||||
|
targetEnabled: targets.enabled,
|
||||||
|
ip: targets.ip,
|
||||||
|
method: targets.method,
|
||||||
|
port: targets.port,
|
||||||
|
internalPort: targets.internalPort,
|
||||||
|
// Site fields
|
||||||
|
siteId: sites.siteId,
|
||||||
|
siteType: sites.type,
|
||||||
|
subnet: sites.subnet,
|
||||||
|
exitNodeId: sites.exitNodeId
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(sites)
|
||||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||||
|
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||||
.where(
|
.where(
|
||||||
or(
|
and(
|
||||||
eq(sites.exitNodeId, currentExitNodeId),
|
eq(targets.enabled, true),
|
||||||
isNull(sites.exitNodeId)
|
eq(resources.enabled, true),
|
||||||
|
or(
|
||||||
|
eq(sites.exitNodeId, currentExitNodeId),
|
||||||
|
isNull(sites.exitNodeId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all resource IDs from the first query
|
// Group by resource and include targets with their unique site data
|
||||||
const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
// Second query to get all enabled targets for these resources
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const allTargets =
|
const resourceId = row.resourceId;
|
||||||
resourceIds.length > 0
|
|
||||||
? await tx
|
|
||||||
.select({
|
|
||||||
resourceId: targets.resourceId,
|
|
||||||
targetId: targets.targetId,
|
|
||||||
ip: targets.ip,
|
|
||||||
method: targets.method,
|
|
||||||
port: targets.port,
|
|
||||||
internalPort: targets.internalPort,
|
|
||||||
enabled: targets.enabled
|
|
||||||
})
|
|
||||||
.from(targets)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(targets.resourceId, resourceIds),
|
|
||||||
eq(targets.enabled, true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Create a map for fast target lookup by resourceId
|
if (!resourcesMap.has(resourceId)) {
|
||||||
const targetsMap = allTargets.reduce((map, target) => {
|
resourcesMap.set(resourceId, {
|
||||||
if (!map.has(target.resourceId)) {
|
resourceId: row.resourceId,
|
||||||
map.set(target.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}`
|
||||||
};
|
};
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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, {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
|
|
@ -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();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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") && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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
|
||||||
z.object({
|
.array(
|
||||||
id: z.string(),
|
z.object({
|
||||||
text: z.string()
|
id: 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,10 +70,10 @@ 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()
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
|
@ -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,42 +153,64 @@ export default function GeneralPage() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
{env.flags.enableClients &&
|
||||||
control={form.control}
|
site.type === "newt" ? (
|
||||||
name="remoteSubnets"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="remoteSubnets"
|
||||||
<FormLabel>{t("remoteSubnets")}</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<TagInput
|
<FormLabel>
|
||||||
{...field}
|
{t("remoteSubnets")}
|
||||||
activeTagIndex={activeCidrTagIndex}
|
</FormLabel>
|
||||||
setActiveTagIndex={setActiveCidrTagIndex}
|
<FormControl>
|
||||||
placeholder={t("enterCidrRange")}
|
<TagInput
|
||||||
size="sm"
|
{...field}
|
||||||
tags={form.getValues().remoteSubnets || []}
|
activeTagIndex={
|
||||||
setTags={(newSubnets) => {
|
activeCidrTagIndex
|
||||||
form.setValue(
|
}
|
||||||
"remoteSubnets",
|
setActiveTagIndex={
|
||||||
newSubnets as Tag[]
|
setActiveCidrTagIndex
|
||||||
);
|
}
|
||||||
}}
|
placeholder={t(
|
||||||
validateTag={(tag) => {
|
"enterCidrRange"
|
||||||
// Basic CIDR validation regex
|
)}
|
||||||
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
size="sm"
|
||||||
return cidrRegex.test(tag);
|
tags={
|
||||||
}}
|
form.getValues()
|
||||||
allowDuplicates={false}
|
.remoteSubnets ||
|
||||||
sortTags={true}
|
[]
|
||||||
/>
|
}
|
||||||
</FormControl>
|
setTags={(
|
||||||
<FormDescription>
|
newSubnets
|
||||||
{t("remoteSubnetsDescription")}
|
) => {
|
||||||
</FormDescription>
|
form.setValue(
|
||||||
<FormMessage />
|
"remoteSubnets",
|
||||||
</FormItem>
|
newSubnets as Tag[]
|
||||||
)}
|
);
|
||||||
/>
|
}}
|
||||||
|
validateTag={(tag) => {
|
||||||
|
// Basic CIDR validation regex
|
||||||
|
const cidrRegex =
|
||||||
|
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||||
|
return cidrRegex.test(
|
||||||
|
tag
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
allowDuplicates={false}
|
||||||
|
sortTags={true}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"remoteSubnetsDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{site && site.type === "newt" && (
|
{site && site.type === "newt" && (
|
||||||
<FormField
|
<FormField
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
100
src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx
Normal file
100
src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,10 +65,9 @@ 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) {
|
||||||
redirectUrl = searchParams.redirect;
|
redirectUrl = searchParams.redirect;
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
@ -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 ? (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
422
src/components/CreateInternalResourceDialog.tsx
Normal file
422
src/components/CreateInternalResourceDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
276
src/components/EditInternalResourceDialog.tsx
Normal file
276
src/components/EditInternalResourceDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,20 +119,49 @@ 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">
|
||||||
<Input
|
<div className="relative w-full sm:max-w-sm">
|
||||||
placeholder={searchPlaceholder}
|
<Input
|
||||||
value={globalFilter ?? ""}
|
placeholder={searchPlaceholder}
|
||||||
onChange={(e) =>
|
value={globalFilter ?? ""}
|
||||||
table.setGlobalFilter(String(e.target.value))
|
onChange={(e) =>
|
||||||
}
|
table.setGlobalFilter(
|
||||||
className="w-full pl-8"
|
String(e.target.value)
|
||||||
/>
|
)
|
||||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
}
|
||||||
|
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>
|
||||||
|
{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>
|
||||||
<div className="flex items-center gap-2 sm:justify-end">
|
<div className="flex items-center gap-2 sm:justify-end">
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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: (
|
||||||
|
|
|
@ -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
136
src/lib/docker.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue