mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-09 14:08:16 +02:00
Merge branch 'dev' into refactor/standardize-clear-buttons
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When writing TypeScript:
|
||||||
|
|
||||||
|
Prefer to use types instead of interfaces.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating forms, use React form for validation and use Zod schemas.
|
||||||
@@ -38,7 +38,5 @@ flags:
|
|||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}
|
{{if .IsPostgreSQL}}postgres:
|
||||||
postgres:
|
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
|
||||||
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -7,23 +7,17 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1g
|
memory: 2g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256m
|
memory: 512m
|
||||||
{{if or .IsPostgreSQL .IsRedis}}
|
{{if or .IsPostgreSQL .IsRedis}}depends_on:
|
||||||
depends_on:
|
{{if .IsPostgreSQL}}postgres:
|
||||||
{{if .IsPostgreSQL}}
|
condition: service_healthy{{end}}
|
||||||
postgres:
|
{{if .IsRedis}}redis:
|
||||||
condition: service_healthy
|
condition: service_healthy{{end}}
|
||||||
{{end}}
|
|
||||||
{{if .IsRedis}}
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
{{end}}
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- backend
|
- backend{{end}}
|
||||||
{{end}}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -31,8 +25,8 @@ services:
|
|||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
retries: 15
|
retries: 15
|
||||||
{{if .InstallGerbil}}
|
|
||||||
gerbil:
|
{{if .InstallGerbil}}gerbil:
|
||||||
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -53,17 +47,16 @@ services:
|
|||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,8 +67,7 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}
|
{{if .IsPostgreSQL}}postgres:
|
||||||
postgres:
|
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -91,11 +83,9 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend{{end}}
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .IsRedis}}
|
{{if .IsRedis}}redis:
|
||||||
redis:
|
|
||||||
image: redis:8-trixie
|
image: redis:8-trixie
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -113,17 +103,14 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- backend
|
- backend{{end}}
|
||||||
{{end}}
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_frontend
|
name: pangolin_frontend
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
{{if or .IsPostgreSQL .IsRedis}}
|
{{if or .IsPostgreSQL .IsRedis}} backend:
|
||||||
backend:
|
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_backend
|
name: pangolin_backend
|
||||||
internal: true
|
internal: true{{end}}
|
||||||
{{end}}
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
{{if .IsRedis}}
|
{{if .IsRedis}}redis:
|
||||||
redis:
|
|
||||||
host: "redis"
|
host: "redis"
|
||||||
port: 6379
|
port: 6379
|
||||||
password: "{{.IsRedisPass}}"
|
password: "{{.IsRedisPass}}"{{end}}
|
||||||
{{end}}
|
|
||||||
|
|||||||
+7
-4
@@ -71,9 +71,12 @@ const (
|
|||||||
Undefined SupportedContainer = "undefined"
|
Undefined SupportedContainer = "undefined"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var redisFlag *bool
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||||
|
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
@@ -491,13 +494,13 @@ func collectUserInput() Config {
|
|||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
if config.IsEnterprise {
|
if config.IsEnterprise {
|
||||||
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
|
if *redisFlag {
|
||||||
if config.IsRedis {
|
config.IsRedis = true
|
||||||
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
|
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
|
||||||
if config.IsPostgreSQL {
|
if config.IsPostgreSQL {
|
||||||
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
||||||
}
|
}
|
||||||
@@ -544,7 +547,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
|
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
|
|||||||
+47
-4
@@ -101,6 +101,8 @@
|
|||||||
"sitesTableViewPrivateResources": "View Private Resources",
|
"sitesTableViewPrivateResources": "View Private Resources",
|
||||||
"siteInstallNewt": "Install Site",
|
"siteInstallNewt": "Install Site",
|
||||||
"siteInstallNewtDescription": "Install the site connector for your system",
|
"siteInstallNewtDescription": "Install the site connector for your system",
|
||||||
|
"siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
|
||||||
|
"siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
|
||||||
"WgConfiguration": "WireGuard Configuration",
|
"WgConfiguration": "WireGuard Configuration",
|
||||||
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
||||||
"operatingSystem": "Operating System",
|
"operatingSystem": "Operating System",
|
||||||
@@ -1220,8 +1222,10 @@
|
|||||||
"addLabels": "Add labels",
|
"addLabels": "Add labels",
|
||||||
"siteLabelsTab": "Labels",
|
"siteLabelsTab": "Labels",
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "Labels not found",
|
"labelsNotFound": "No labels found.",
|
||||||
|
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
||||||
"labelSearch": "Search labels",
|
"labelSearch": "Search labels",
|
||||||
|
"labelSearchOrCreate": "Search or create a label",
|
||||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
"accessLabelFilterClear": "Clear label filters",
|
"accessLabelFilterClear": "Clear label filters",
|
||||||
@@ -1648,7 +1652,7 @@
|
|||||||
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsLog": "Blueprints Log",
|
"blueprintsLog": "Blueprints Log",
|
||||||
"blueprintsDescription": "View past blueprint applications and their results",
|
"blueprintsDescription": "View past blueprint applications and their results or apply a new blueprint",
|
||||||
"blueprintAdd": "Add Blueprint",
|
"blueprintAdd": "Add Blueprint",
|
||||||
"blueprintGoBack": "See all Blueprints",
|
"blueprintGoBack": "See all Blueprints",
|
||||||
"blueprintCreate": "Create Blueprint",
|
"blueprintCreate": "Create Blueprint",
|
||||||
@@ -2047,6 +2051,7 @@
|
|||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshSettings": "SSH Settings",
|
"sshSettings": "SSH Settings",
|
||||||
|
"sshAccess": "SSH Access",
|
||||||
"rdpSettings": "RDP Settings",
|
"rdpSettings": "RDP Settings",
|
||||||
"vncSettings": "VNC Settings",
|
"vncSettings": "VNC Settings",
|
||||||
"sshServer": "SSH Server",
|
"sshServer": "SSH Server",
|
||||||
@@ -2073,7 +2078,7 @@
|
|||||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||||
"sshDaemonPort": "Daemon Port",
|
"sshDaemonPort": "Daemon Port",
|
||||||
"sshServerDestination": "Server Destination",
|
"sshServerDestination": "Server Destination",
|
||||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
"sshServerDestinationDescription": "Configure the destination of the SSH server",
|
||||||
"destination": "Destination",
|
"destination": "Destination",
|
||||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||||
"roleAllowSsh": "Allow SSH",
|
"roleAllowSsh": "Allow SSH",
|
||||||
@@ -3455,5 +3460,43 @@
|
|||||||
"sshErrorNoTarget": "No target specified",
|
"sshErrorNoTarget": "No target specified",
|
||||||
"sshErrorWebSocket": "WebSocket connection failed",
|
"sshErrorWebSocket": "WebSocket connection failed",
|
||||||
"sshErrorAuthFailed": "Authentication failed",
|
"sshErrorAuthFailed": "Authentication failed",
|
||||||
"sshErrorConnectionClosed": "Connection closed before authentication completed"
|
"sshErrorConnectionClosed": "Connection closed before authentication completed",
|
||||||
|
"sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.",
|
||||||
|
"browserGatewayNoResourceForDomain": "No resource found for this domain",
|
||||||
|
"browserGatewayNoTarget": "No target",
|
||||||
|
"browserGatewayConnect": "Connect",
|
||||||
|
"browserGatewayCtrlAltDel": "Ctrl+Alt+Del",
|
||||||
|
"sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?",
|
||||||
|
"sshTerminalError": "Error: {error}",
|
||||||
|
"sshConnectionClosedCode": "Connection closed (code {code})",
|
||||||
|
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||||
|
"sshPrivateKeyRequired": "Private key is required",
|
||||||
|
"vncTitle": "VNC",
|
||||||
|
"vncSignInDescription": "Enter your VNC password to connect",
|
||||||
|
"vncPasswordOptional": "Password (optional)",
|
||||||
|
"vncNoResourceTarget": "No resource target is available",
|
||||||
|
"vncFailedToLoadNovnc": "Failed to load noVNC",
|
||||||
|
"vncAuthFailedStatus": "Status {status}",
|
||||||
|
"vncPasteClipboard": "Paste clipboard",
|
||||||
|
"rdpTitle": "RDP",
|
||||||
|
"rdpSignInTitle": "Sign in to Remote Desktop",
|
||||||
|
"rdpSignInDescription": "Enter Windows credentials to connect",
|
||||||
|
"rdpLoadingModule": "Loading module...",
|
||||||
|
"rdpFailedToLoadModule": "Failed to load RDP module",
|
||||||
|
"rdpNotReady": "Not ready",
|
||||||
|
"rdpModuleInitializing": "RDP module is still initializing",
|
||||||
|
"rdpDownloadingFiles": "Downloading {count} file(s) from remote…",
|
||||||
|
"rdpDownloadFailed": "Download failed: {fileName}",
|
||||||
|
"rdpUploaded": "Uploaded: {fileName}",
|
||||||
|
"rdpNoConnectionTarget": "No connection target available",
|
||||||
|
"rdpConnectionFailed": "Connection failed",
|
||||||
|
"rdpFit": "Fit",
|
||||||
|
"rdpFull": "Full",
|
||||||
|
"rdpReal": "Real",
|
||||||
|
"rdpMeta": "Meta",
|
||||||
|
"rdpUploadFiles": "Upload files",
|
||||||
|
"rdpFilesReadyToPaste": "Files ready to paste",
|
||||||
|
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
|
||||||
|
"rdpUploadFailed": "Upload failed",
|
||||||
|
"rdpUnicodeKeyboardMode": "Unicode keyboard mode"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -886,7 +886,9 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
|||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
match: varchar("match")
|
||||||
|
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
||||||
|
.notNull(),
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1248,7 +1248,9 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
match: text("match")
|
||||||
|
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
||||||
|
.notNull(),
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ClientResourcesResults,
|
ClientResourcesResults,
|
||||||
updateClientResources
|
updateClientResources
|
||||||
} from "./clientResources";
|
} from "./clientResources";
|
||||||
|
import { updateResourcePolicies } from "./resourcePolicies";
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { generateName } from "@server/db/names";
|
import { generateName } from "@server/db/names";
|
||||||
@@ -56,6 +57,8 @@ export async function applyBlueprint({
|
|||||||
let proxyResourcesResults: ProxyResourcesResults = [];
|
let proxyResourcesResults: ProxyResourcesResults = [];
|
||||||
let clientResourcesResults: ClientResourcesResults = [];
|
let clientResourcesResults: ClientResourcesResults = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
await updateResourcePolicies(orgId, config, trx);
|
||||||
|
|
||||||
proxyResourcesResults = await updateProxyResources(
|
proxyResourcesResults = await updateProxyResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import logger from "@server/logger";
|
|||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
@@ -114,6 +116,30 @@ export async function updateClientResources(
|
|||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["client-resources"]
|
config["client-resources"]
|
||||||
)) {
|
)) {
|
||||||
|
if (resourceData.mode === "http") {
|
||||||
|
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
if (!hasHttpFeature) {
|
||||||
|
throw new Error(
|
||||||
|
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.mode === "ssh") {
|
||||||
|
const hasSshFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
if (!hasSshFeature) {
|
||||||
|
throw new Error(
|
||||||
|
"SSH private resources are not included in your current plan. Please upgrade."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [existingResource] = await trx
|
const [existingResource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -366,7 +392,9 @@ export async function updateClientResources(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
existingRoles.push(created);
|
existingRoles.push(created);
|
||||||
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
logger.info(
|
||||||
|
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
@@ -510,7 +538,9 @@ export async function updateClientResources(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
existingRoles.push(created);
|
existingRoles.push(created);
|
||||||
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
logger.info(
|
||||||
|
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|||||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
@@ -222,17 +223,59 @@ export async function updateProxyResources(
|
|||||||
headers = JSON.stringify(resourceData.headers);
|
headers = JSON.stringify(resourceData.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPublicResources
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.policy) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.resourcePolicies
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domain;
|
let domain;
|
||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
|
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
existingResource.resourceId,
|
existingResource.resourceId,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await enforceDomainNamespacePaywall(
|
||||||
|
orgId,
|
||||||
|
domain.domainId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the only key in the resource is targets, if so, skip the update
|
// check if the only key in the resource is targets, if so, skip the update
|
||||||
@@ -906,12 +949,30 @@ export async function updateProxyResources(
|
|||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
|
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
undefined,
|
undefined,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await enforceDomainNamespacePaywall(
|
||||||
|
orgId,
|
||||||
|
domain.domainId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -1866,6 +1927,37 @@ function checkIfTargetChanged(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enforceDomainNamespacePaywall(
|
||||||
|
orgId: string,
|
||||||
|
domainId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
if (build !== "saas") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDomainNamespaceAccess = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.domainNamespaces
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDomainNamespaceAccess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [namespaceDomain] = await trx
|
||||||
|
.select()
|
||||||
|
.from(domainNamespaces)
|
||||||
|
.where(eq(domainNamespaces.domainId, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (namespaceDomain) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDomain(
|
export async function getDomain(
|
||||||
resourceId: number | undefined,
|
resourceId: number | undefined,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
|
|||||||
@@ -0,0 +1,653 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
idp,
|
||||||
|
idpOrg,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
rolePolicies,
|
||||||
|
roles,
|
||||||
|
Transaction,
|
||||||
|
userOrgs,
|
||||||
|
userPolicies,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and, or } from "drizzle-orm";
|
||||||
|
import { Config, ResourcePolicyData } from "./types";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
|
export type ResourcePoliciesResults = {
|
||||||
|
resourcePolicyId: number;
|
||||||
|
niceId: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export async function updateResourcePolicies(
|
||||||
|
orgId: string,
|
||||||
|
config: Config,
|
||||||
|
trx: Transaction
|
||||||
|
): Promise<ResourcePoliciesResults> {
|
||||||
|
const results: ResourcePoliciesResults = [];
|
||||||
|
|
||||||
|
for (const [policyNiceId, policyData] of Object.entries(
|
||||||
|
config["resource-policies"]
|
||||||
|
)) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.resourcePolicies
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rules
|
||||||
|
for (const rule of policyData.rules) {
|
||||||
|
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
rule.match === "path" &&
|
||||||
|
!isValidUrlGlobPattern(rule.value)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auto-login-idp if provided
|
||||||
|
if (policyData["auto-login-idp"]) {
|
||||||
|
const [provider] = await trx
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(idp.idpId, policyData["auto-login-idp"]),
|
||||||
|
eq(idpOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(
|
||||||
|
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the admin role
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
throw new Error("Admin role not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing policy by niceId and orgId
|
||||||
|
const [existingPolicy] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, policyNiceId),
|
||||||
|
eq(resourcePolicies.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let resourcePolicyId: number;
|
||||||
|
|
||||||
|
if (existingPolicy) {
|
||||||
|
// Update the existing policy
|
||||||
|
await trx
|
||||||
|
.update(resourcePolicies)
|
||||||
|
.set({
|
||||||
|
name: policyData.name,
|
||||||
|
sso: policyData.sso ?? true,
|
||||||
|
idpId: policyData["auto-login-idp"] ?? null,
|
||||||
|
emailWhitelistEnabled:
|
||||||
|
policyData["email-whitelist-enabled"] ??
|
||||||
|
policyData["whitelist-users"].length > 0,
|
||||||
|
applyRules:
|
||||||
|
policyData["apply-rules"] || policyData.rules.length > 0
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
existingPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
resourcePolicyId = existingPolicy.resourcePolicyId;
|
||||||
|
|
||||||
|
// Sync password
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPassword)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyPassword.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyData.password) {
|
||||||
|
const passwordHash = await hashPassword(policyData.password);
|
||||||
|
await trx.insert(resourcePolicyPassword).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync pincode
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPincode)
|
||||||
|
.where(
|
||||||
|
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||||
|
);
|
||||||
|
if (policyData.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(policyData.pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync header auth
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyHeaderAuth)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyData["basic-auth"]) {
|
||||||
|
const basicAuth = policyData["basic-auth"];
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${basicAuth.user}:${basicAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility:
|
||||||
|
basicAuth["extended-compatibility"] ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync SSO roles
|
||||||
|
await syncRolePolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-roles"],
|
||||||
|
orgId,
|
||||||
|
adminRole.roleId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync SSO users
|
||||||
|
await syncUserPolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-users"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync whitelist users
|
||||||
|
await syncWhitelistPolicyUsers(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["whitelist-users"],
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync rules
|
||||||
|
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create a new policy
|
||||||
|
const [newPolicy] = await trx
|
||||||
|
.insert(resourcePolicies)
|
||||||
|
.values({
|
||||||
|
niceId: policyNiceId,
|
||||||
|
orgId,
|
||||||
|
name: policyData.name,
|
||||||
|
sso: policyData.sso ?? true,
|
||||||
|
idpId: policyData["auto-login-idp"] ?? null,
|
||||||
|
emailWhitelistEnabled:
|
||||||
|
policyData["email-whitelist-enabled"] ??
|
||||||
|
policyData["whitelist-users"].length > 0,
|
||||||
|
applyRules:
|
||||||
|
policyData["apply-rules"] ||
|
||||||
|
policyData.rules.length > 0,
|
||||||
|
scope: "global"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
resourcePolicyId = newPolicy.resourcePolicyId;
|
||||||
|
|
||||||
|
// Always add admin role
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
resourcePolicyId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add SSO roles
|
||||||
|
await addRolePolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-roles"],
|
||||||
|
orgId,
|
||||||
|
adminRole.roleId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add SSO users
|
||||||
|
await addUserPolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-users"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add password
|
||||||
|
if (policyData.password) {
|
||||||
|
const passwordHash = await hashPassword(policyData.password);
|
||||||
|
await trx.insert(resourcePolicyPassword).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pincode
|
||||||
|
if (policyData.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(policyData.pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add header auth
|
||||||
|
if (policyData["basic-auth"]) {
|
||||||
|
const basicAuth = policyData["basic-auth"];
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${basicAuth.user}:${basicAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility:
|
||||||
|
basicAuth["extended-compatibility"] ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add whitelist users
|
||||||
|
if (policyData["whitelist-users"].length > 0) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values(
|
||||||
|
policyData["whitelist-users"].map((email) => ({
|
||||||
|
email,
|
||||||
|
resourcePolicyId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rules
|
||||||
|
if (policyData.rules.length > 0) {
|
||||||
|
await trx.insert(resourcePolicyRules).values(
|
||||||
|
policyData.rules.map((rule, index) => ({
|
||||||
|
resourcePolicyId,
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: rule.priority ?? index + 1,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ resourcePolicyId, niceId: policyNiceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
||||||
|
if (input === "allow") return "ACCEPT";
|
||||||
|
if (input === "deny") return "DROP";
|
||||||
|
return "PASS";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleMatch(
|
||||||
|
input: string
|
||||||
|
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
|
||||||
|
return input.toUpperCase() as
|
||||||
|
| "CIDR"
|
||||||
|
| "IP"
|
||||||
|
| "PATH"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRolePolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
adminRoleId: number,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRolePolicies = await trx
|
||||||
|
.select()
|
||||||
|
.from(rolePolicies)
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
logger.warn(
|
||||||
|
`Role '${roleName}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // admin role is always included, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = existingRolePolicies.some(
|
||||||
|
(rp) => rp.roleId === role.roleId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove roles no longer in the list (except admin)
|
||||||
|
for (const existingRolePolicy of existingRolePolicies) {
|
||||||
|
if (existingRolePolicy.roleId === adminRoleId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.roleId, existingRolePolicy.roleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (role?.isAdmin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && !ssoRoles.includes(role.name)) {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, existingRolePolicy.roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRolePolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
adminRoleId: number,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
logger.warn(
|
||||||
|
`Role '${roleName}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // admin already added
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncUserPolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingUserPolicies = await trx
|
||||||
|
.select()
|
||||||
|
.from(userPolicies)
|
||||||
|
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const username of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(users.username, username), eq(users.email, username)),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(
|
||||||
|
`User '${username}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = existingUserPolicies.some(
|
||||||
|
(up) => up.userId === user.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove users no longer in the list
|
||||||
|
for (const existingUserPolicy of existingUserPolicies) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.userId, existingUserPolicy.userId),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user &&
|
||||||
|
user.user.username &&
|
||||||
|
!ssoUsers.includes(user.user.username) &&
|
||||||
|
!ssoUsers.includes(user.user.email ?? "")
|
||||||
|
) {
|
||||||
|
await trx
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, existingUserPolicy.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserPolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
for (const username of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(users.username, username), eq(users.email, username)),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(
|
||||||
|
`User '${username}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWhitelistPolicyUsers(
|
||||||
|
policyId: number,
|
||||||
|
whitelistUsers: string[],
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingWhitelist = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const email of whitelistUsers) {
|
||||||
|
const alreadyExists = existingWhitelist.some((w) => w.email === email);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values({
|
||||||
|
email,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingEntry of existingWhitelist) {
|
||||||
|
if (!whitelistUsers.includes(existingEntry.email)) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
|
||||||
|
eq(resourcePolicyWhiteList.email, existingEntry.email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPolicyRules(
|
||||||
|
policyId: number,
|
||||||
|
rules: ResourcePolicyData["rules"],
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRules = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
|
||||||
|
.orderBy(resourcePolicyRules.priority);
|
||||||
|
|
||||||
|
for (const [index, rule] of rules.entries()) {
|
||||||
|
const intendedPriority = rule.priority ?? index + 1;
|
||||||
|
const existingRule = existingRules[index];
|
||||||
|
|
||||||
|
if (existingRule) {
|
||||||
|
await trx
|
||||||
|
.update(resourcePolicyRules)
|
||||||
|
.set({
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: intendedPriority,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
})
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
|
||||||
|
} else {
|
||||||
|
await trx.insert(resourcePolicyRules).values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: intendedPriority,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove extra rules
|
||||||
|
if (existingRules.length > rules.length) {
|
||||||
|
const rulesToDelete = existingRules.slice(rules.length);
|
||||||
|
for (const rule of rulesToDelete) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,8 @@ export const RuleSchema = z
|
|||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional()
|
priority: z.int().optional(),
|
||||||
|
enabled: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
@@ -507,6 +508,52 @@ export const PrivateResourceSchema = z
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ResourcePolicyRuleSchema = RuleSchema;
|
||||||
|
|
||||||
|
export const ResourcePolicySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
sso: z.boolean().optional().default(true),
|
||||||
|
"auto-login-idp": z.int().positive().optional().nullable(),
|
||||||
|
"sso-roles": z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
.refine((roles) => !roles.includes("Admin"), {
|
||||||
|
error: "Admin role cannot be included in sso-roles"
|
||||||
|
}),
|
||||||
|
"sso-users": z.array(z.string()).optional().default([]),
|
||||||
|
password: z.string().min(4).max(100).optional().nullable(),
|
||||||
|
pincode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{6}$/)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
"basic-auth": z
|
||||||
|
.object({
|
||||||
|
user: z.string().min(4).max(100),
|
||||||
|
password: z.string().min(4).max(100),
|
||||||
|
"extended-compatibility": z.boolean().default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
"email-whitelist-enabled": z.boolean().optional().default(false),
|
||||||
|
"whitelist-users": z
|
||||||
|
.array(
|
||||||
|
z.email().or(
|
||||||
|
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||||
|
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.max(50)
|
||||||
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
"apply-rules": z.boolean().optional().default(false),
|
||||||
|
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
|
||||||
|
});
|
||||||
|
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
|
||||||
|
|
||||||
// Schema for the entire configuration object
|
// Schema for the entire configuration object
|
||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -526,6 +573,10 @@ export const ConfigSchema = z
|
|||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
"resource-policies": z
|
||||||
|
.record(z.string(), ResourcePolicySchema)
|
||||||
|
.optional()
|
||||||
|
.prefault({}),
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
@@ -556,6 +607,10 @@ export const ConfigSchema = z
|
|||||||
string,
|
string,
|
||||||
z.infer<typeof PrivateResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
|
"resource-policies": Record<
|
||||||
|
string,
|
||||||
|
z.infer<typeof ResourcePolicySchema>
|
||||||
|
>;
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -695,3 +750,4 @@ export type Site = z.infer<typeof SiteSchema>;
|
|||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
||||||
|
|||||||
+1
-1
@@ -665,7 +665,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets: SubnetProxyTargetV2[] = [];
|
const targets: SubnetProxyTargetV2[] = [];
|
||||||
|
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
||||||
|
allowBrowserGatewayResources = true
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -240,7 +241,7 @@ export async function getTraefikConfig(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.mode === "http") {
|
||||||
if (!resource.domainId || !resource.fullDomain) {
|
if (!resource.domainId || !resource.fullDomain) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -572,7 +573,7 @@ export async function getTraefikConfig(
|
|||||||
serviceName
|
serviceName
|
||||||
].loadBalancer.serversTransport = transportName;
|
].loadBalancer.serversTransport = transportName;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (resource.mode === "tcp" || resource.mode === "udp") {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
if (!resource.enableProxy || !resource.proxyPort) {
|
if (!resource.enableProxy || !resource.proxyPort) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the cross-organization site binding prevention in verifySiteAccess.
|
||||||
|
*
|
||||||
|
* verifySiteAccess now includes a check: if req.userOrgId is already set by a
|
||||||
|
* previous middleware (e.g. verifyResourceAccess or verifyTargetAccess), and the
|
||||||
|
* loaded site's orgId differs from req.userOrgId, the request is rejected with
|
||||||
|
* 403 Forbidden.
|
||||||
|
*
|
||||||
|
* Route stacks after fix:
|
||||||
|
* PUT /resource/:resourceId/target
|
||||||
|
* → verifyResourceAccess → verifySiteAccess → verifyLimits → ...
|
||||||
|
* POST /target/:targetId
|
||||||
|
* → verifyTargetAccess → verifySiteAccess → verifyLimits → ...
|
||||||
|
*
|
||||||
|
* verifyResourceAccess sets req.userOrgId to the resource's org.
|
||||||
|
* verifyTargetAccess sets req.userOrgId to the target's resource org.
|
||||||
|
* verifySiteAccess then checks site.orgId against req.userOrgId before
|
||||||
|
* overwriting it with the site's org.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Core org-matching logic (mirrors the check in verifySiteAccess) ---
|
||||||
|
function siteOrgMatchesExpectedOrg(
|
||||||
|
siteOrgId: string | null | undefined,
|
||||||
|
expectedOrgId: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!siteOrgId || !expectedOrgId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return siteOrgId === expectedOrgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates the condition check in verifySiteAccess:
|
||||||
|
// if (req.userOrgId && site.orgId !== req.userOrgId) { reject }
|
||||||
|
function shouldRejectCrossOrgSite(
|
||||||
|
siteOrgId: string,
|
||||||
|
reqUserOrgId: string | undefined
|
||||||
|
): boolean {
|
||||||
|
// The actual check in verifySiteAccess is:
|
||||||
|
// if (req.userOrgId && site.orgId !== req.userOrgId) { reject }
|
||||||
|
return !!(reqUserOrgId && siteOrgId !== reqUserOrgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
function testSiteOrgMatchLogic() {
|
||||||
|
console.log("Running verifySiteAccess org-match logic tests...");
|
||||||
|
|
||||||
|
// Test 1: Same org — should match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(
|
||||||
|
"org-attacker",
|
||||||
|
"org-attacker"
|
||||||
|
);
|
||||||
|
assertEquals(result, true, "Same org should match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Different org — should NOT match (cross-org bypass scenario)
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg("org-victim", "org-attacker");
|
||||||
|
assertEquals(
|
||||||
|
result,
|
||||||
|
false,
|
||||||
|
"Cross-org site should NOT match expected org"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Site orgId is null — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(null, "org-attacker");
|
||||||
|
assertEquals(result, false, "Null site orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Expected orgId is null — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg("org-attacker", null);
|
||||||
|
assertEquals(result, false, "Null expected orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Both null — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(null, null);
|
||||||
|
assertEquals(result, false, "Both null should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Empty string orgIds — should NOT match (empty string is falsy)
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg("", "org-attacker");
|
||||||
|
assertEquals(result, false, "Empty site orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: Undefined orgIds — should NOT match
|
||||||
|
{
|
||||||
|
const result = siteOrgMatchesExpectedOrg(undefined, "org-attacker");
|
||||||
|
assertEquals(result, false, "Undefined site orgId should NOT match");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All verifySiteAccess org-match logic tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function testShouldRejectCrossOrgSite() {
|
||||||
|
console.log(
|
||||||
|
"Running shouldRejectCrossOrgSite tests (mirrors verifySiteAccess check)..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test: No prior org context (undefined) — should NOT reject
|
||||||
|
// This is the normal case for site-only routes (e.g. PUT /site/:siteId)
|
||||||
|
// where verifySiteAccess runs without a prior verifyResourceAccess.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite("org-victim", undefined);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"No prior org context should NOT reject (normal site routes)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Same org — should NOT reject
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org-attacker",
|
||||||
|
"org-attacker"
|
||||||
|
);
|
||||||
|
assertEquals(shouldReject, false, "Same org should NOT reject");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Different org — should reject
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org-victim",
|
||||||
|
"org-attacker"
|
||||||
|
);
|
||||||
|
assertEquals(shouldReject, true, "Cross-org site should be rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: Empty string userOrgId — should NOT reject (falsy, check is skipped)
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite("org-victim", "");
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"Empty string userOrgId should NOT reject (check is skipped)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All shouldRejectCrossOrgSite tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Route stack validation tests ---
|
||||||
|
|
||||||
|
function testRouteStackOrdering() {
|
||||||
|
console.log("Running route stack ordering tests...");
|
||||||
|
|
||||||
|
const createTargetStack = [
|
||||||
|
"verifyResourceAccess",
|
||||||
|
"verifySiteAccess",
|
||||||
|
"verifyLimits",
|
||||||
|
"verifyUserHasAction",
|
||||||
|
"logActionAudit",
|
||||||
|
"createTarget"
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateTargetStack = [
|
||||||
|
"verifyTargetAccess",
|
||||||
|
"verifySiteAccess",
|
||||||
|
"verifyLimits",
|
||||||
|
"verifyUserHasAction",
|
||||||
|
"logActionAudit",
|
||||||
|
"updateTarget"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify verifySiteAccess comes after resource/target access middleware
|
||||||
|
{
|
||||||
|
const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const resourceAccessIndex = createTargetStack.indexOf(
|
||||||
|
"verifyResourceAccess"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex > resourceAccessIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come after verifyResourceAccess in create target stack"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const targetAccessIndex =
|
||||||
|
updateTargetStack.indexOf("verifyTargetAccess");
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex > targetAccessIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come after verifyTargetAccess in update target stack"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify verifySiteAccess comes before the handler
|
||||||
|
{
|
||||||
|
const siteAccessIndex = createTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const handlerIndex = createTargetStack.indexOf("createTarget");
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex < handlerIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come before createTarget handler"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const siteAccessIndex = updateTargetStack.indexOf("verifySiteAccess");
|
||||||
|
const handlerIndex = updateTargetStack.indexOf("updateTarget");
|
||||||
|
assertEquals(
|
||||||
|
siteAccessIndex < handlerIndex,
|
||||||
|
true,
|
||||||
|
"verifySiteAccess must come before updateTarget handler"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All route stack ordering tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Security scenario tests ---
|
||||||
|
|
||||||
|
function testSecurityScenarios() {
|
||||||
|
console.log("Running security scenario tests...");
|
||||||
|
|
||||||
|
// Scenario 1: Attacker has resource access in org_attacker, but tries to
|
||||||
|
// bind target to a site in org_victim.
|
||||||
|
// verifyResourceAccess passes (sets req.userOrgId = "org_attacker").
|
||||||
|
// verifySiteAccess loads site (org_victim), checks site.orgId !== req.userOrgId.
|
||||||
|
// Expected: 403 Forbidden.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 1: Cross-org site binding must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 2: Attacker has resource access AND site access in another org.
|
||||||
|
// Even though the user has site access, verifySiteAccess rejects because
|
||||||
|
// the org-match check runs before the site access check.
|
||||||
|
// Expected: 403 Forbidden (org mismatch caught before site access check).
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 2: Cross-org site must be rejected even if user has site access"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 3: Legitimate user creates target with site in same org.
|
||||||
|
// verifyResourceAccess passes, verifySiteAccess org-match passes (same org),
|
||||||
|
// verifySiteAccess site access passes.
|
||||||
|
// Expected: 201 Created.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_attacker",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"Scenario 3: Same-org site must be allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 4: WireGuard site in victim org — org mismatch is caught before
|
||||||
|
// any DB write, pickPort, addPeer, or addTargets side effect.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 4: WireGuard cross-org site must be rejected before addPeer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 5: Newt site in victim org — same as scenario 4 but for newt.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite(
|
||||||
|
"org_victim",
|
||||||
|
"org_attacker"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
true,
|
||||||
|
"Scenario 5: Newt cross-org site must be rejected before addTargets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario 6: Normal site-only route (e.g. PUT /site/:siteId) where
|
||||||
|
// verifySiteAccess runs without a prior verifyResourceAccess.
|
||||||
|
// req.userOrgId is undefined, so the org-match check is skipped.
|
||||||
|
// Normal site access verification proceeds.
|
||||||
|
{
|
||||||
|
const shouldReject = shouldRejectCrossOrgSite("org_victim", undefined);
|
||||||
|
assertEquals(
|
||||||
|
shouldReject,
|
||||||
|
false,
|
||||||
|
"Scenario 6: Site-only routes should skip org-match check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All security scenario tests passed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
testSiteOrgMatchLogic();
|
||||||
|
testShouldRejectCrossOrgSite();
|
||||||
|
testRouteStackOrdering();
|
||||||
|
testSecurityScenarios();
|
||||||
@@ -71,6 +71,15 @@ export async function verifySiteAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.userOrgId && site.orgId !== req.userOrgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this site"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
// Get user's role ID in the organization
|
// Get user's role ID in the organization
|
||||||
const userOrgRole = await db
|
const userOrgRole = await db
|
||||||
@@ -128,10 +137,7 @@ export async function verifySiteAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleSites.siteId, site.siteId),
|
eq(roleSites.siteId, site.siteId),
|
||||||
inArray(
|
inArray(roleSites.roleId, req.userOrgRoleIds!)
|
||||||
roleSites.roleId,
|
|
||||||
req.userOrgRoleIds!
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
@@ -109,7 +109,11 @@ export const privateConfigSchema = z
|
|||||||
enable_redis: z.boolean().optional().default(false),
|
enable_redis: z.boolean().optional().default(false),
|
||||||
use_pangolin_dns: z.boolean().optional().default(false),
|
use_pangolin_dns: z.boolean().optional().default(false),
|
||||||
use_org_only_idp: z.boolean().optional(),
|
use_org_only_idp: z.boolean().optional(),
|
||||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
enable_acme_cert_sync: z.boolean().optional().default(true),
|
||||||
|
disable_private_http_placeholder: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
|||||||
@@ -410,7 +410,11 @@ export async function getTraefikConfig(
|
|||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
mode: "http" | "host" | "cidr" | "ssh";
|
mode: "http" | "host" | "cidr" | "ssh";
|
||||||
}[] = [];
|
}[] = [];
|
||||||
if (build == "enterprise") {
|
if (
|
||||||
|
build == "enterprise" &&
|
||||||
|
!privateConfig.getRawPrivateConfig().flags
|
||||||
|
.disable_private_http_placeholder
|
||||||
|
) {
|
||||||
// we dont want to do this on the cloud
|
// we dont want to do this on the cloud
|
||||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||||
siteResourcesWithFullDomain = await db
|
siteResourcesWithFullDomain = await db
|
||||||
@@ -493,16 +497,29 @@ export async function getTraefikConfig(
|
|||||||
const transportName = `${key}-transport`;
|
const transportName = `${key}-transport`;
|
||||||
const headersMiddlewareName = `${key}-headers-middleware`;
|
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Processing resource ${resource.name} with domain ${fullDomain} and ${targets.length} targets`
|
||||||
|
);
|
||||||
|
|
||||||
if (!resource.enabled) {
|
if (!resource.enabled) {
|
||||||
|
logger.debug(
|
||||||
|
`Resource ${resource.name} is disabled, skipping Traefik config`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource.http) {
|
if (resource.mode == "http") {
|
||||||
if (!resource.domainId) {
|
if (!resource.domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`Resource ${resource.name} does not have a domainId, skipping Traefik config`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.fullDomain) {
|
if (!resource.fullDomain) {
|
||||||
|
logger.debug(
|
||||||
|
`Resource ${resource.name} does not have a fullDomain, skipping Traefik config`
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,7 +975,7 @@ export async function getTraefikConfig(
|
|||||||
serviceName
|
serviceName
|
||||||
].loadBalancer.serversTransport = transportName;
|
].loadBalancer.serversTransport = transportName;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (resource.mode == "tcp" || resource.mode == "udp") {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
if (!resource.enableProxy) {
|
if (!resource.enableProxy) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export async function getBrowserTarget(
|
|||||||
authToken: browserGatewayTarget.authToken,
|
authToken: browserGatewayTarget.authToken,
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
|
name: resources.name,
|
||||||
orgId: resources.orgId,
|
orgId: resources.orgId,
|
||||||
pamMode: resources.pamMode,
|
pamMode: resources.pamMode,
|
||||||
authDaemonMode: resources.authDaemonMode
|
authDaemonMode: resources.authDaemonMode
|
||||||
@@ -93,7 +94,8 @@ export async function getBrowserTarget(
|
|||||||
authDaemonMode: browserTarget.authDaemonMode,
|
authDaemonMode: browserTarget.authDaemonMode,
|
||||||
orgId: browserTarget.orgId,
|
orgId: browserTarget.orgId,
|
||||||
resourceId: browserTarget.resourceId,
|
resourceId: browserTarget.resourceId,
|
||||||
niceId: browserTarget.niceId
|
niceId: browserTarget.niceId,
|
||||||
|
name: browserTarget.name
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { randomInt } from "crypto";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
actionAuditLog,
|
actionAuditLog,
|
||||||
@@ -392,7 +393,7 @@ export async function signSshKey(
|
|||||||
if (existingUserWithSameName) {
|
if (existingUserWithSameName) {
|
||||||
let foundUniqueUsername = false;
|
let foundUniqueUsername = false;
|
||||||
for (let attempt = 0; attempt < 20; attempt++) {
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
const randomNum = randomInt(0, 101); // 0 to 100
|
||||||
const candidateUsername = `${usernameToUse}${randomNum}`;
|
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||||
|
|
||||||
const [existingUser] = await db
|
const [existingUser] = await db
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
|
name: string;
|
||||||
pamMode: "passthrough" | "push" | null;
|
pamMode: "passthrough" | "push" | null;
|
||||||
authDaemonMode: "site" | "remote" | "native" | null;
|
authDaemonMode: "site" | "remote" | "native" | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -561,6 +561,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource/:resourceId/target",
|
"/resource/:resourceId/target",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifySiteAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createTarget),
|
verifyUserHasAction(ActionsEnum.createTarget),
|
||||||
logActionAudit(ActionsEnum.createTarget),
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
@@ -612,6 +613,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
|
verifySiteAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateTarget),
|
verifyUserHasAction(ActionsEnum.updateTarget),
|
||||||
logActionAudit(ActionsEnum.updateTarget),
|
logActionAudit(ActionsEnum.updateTarget),
|
||||||
@@ -1234,7 +1236,8 @@ export const authRouter = Router();
|
|||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
authRouter.use(
|
authRouter.use(
|
||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
|
windowMs:
|
||||||
|
config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
|
||||||
max: config.getRawConfig().rate_limits.auth.max_requests,
|
max: config.getRawConfig().rate_limits.auth.max_requests,
|
||||||
keyGenerator: (req) =>
|
keyGenerator: (req) =>
|
||||||
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
||||||
|
|||||||
@@ -332,17 +332,6 @@ export async function validateOidcCallback(
|
|||||||
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
||||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
allOrgs = idpOrgs.map((o) => o.orgs);
|
||||||
|
|
||||||
for (const org of allOrgs) {
|
|
||||||
const subscribed = await isSubscribed(
|
|
||||||
org.orgId,
|
|
||||||
tierMatrix.autoProvisioning
|
|
||||||
);
|
|
||||||
if (!subscribed) {
|
|
||||||
// filter out the org
|
|
||||||
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
allOrgs = await db.select().from(orgs);
|
allOrgs = await db.select().from(orgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db";
|
import { roleResources, roles, rolePolicies } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -131,31 +131,64 @@ export async function addRoleToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if role already exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(roleResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
|
||||||
eq(roleResources.roleId, roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length > 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
// Check if role already exists in the inline policy
|
||||||
"Role already assigned to resource"
|
const existingEntry = await db
|
||||||
)
|
.select()
|
||||||
);
|
.from(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Role already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(rolePolicies).values({
|
||||||
|
roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if role already exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Role already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(roleResources).values({
|
||||||
|
roleId,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(roleResources).values({
|
|
||||||
roleId,
|
|
||||||
resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { userResources } from "@server/db";
|
import { userResources, userPolicies } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -103,31 +103,64 @@ export async function addUserToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.resourceId, resourceId),
|
|
||||||
eq(userResources.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length > 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
// Check if user already exists in the inline policy
|
||||||
"User already assigned to resource"
|
const existingEntry = await db
|
||||||
)
|
.select()
|
||||||
);
|
.from(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"User already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(userPolicies).values({
|
||||||
|
userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if user already exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.resourceId, resourceId),
|
||||||
|
eq(userResources.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"User already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(userResources).values({
|
||||||
|
userId,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(userResources).values({
|
|
||||||
userId,
|
|
||||||
resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { resourceRules, resources } from "@server/db";
|
import { resourceRules, resourcePolicyRules, resources } 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";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -153,6 +153,34 @@ export async function createResourceRule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create the new resource rule
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
const [newRule] = await db
|
||||||
|
.insert(resourcePolicyRules)
|
||||||
|
.values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
action,
|
||||||
|
match,
|
||||||
|
value,
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: newRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create the new resource rule
|
// Create the new resource rule
|
||||||
const [newRule] = await db
|
const [newRule] = await db
|
||||||
.insert(resourceRules)
|
.insert(resourceRules)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { resourceRules, resources } from "@server/db";
|
import { resourceRules, resourcePolicyRules, resources } 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";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -59,6 +59,48 @@ export async function deleteResourceRule(
|
|||||||
|
|
||||||
const { ruleId } = parsedParams.data;
|
const { ruleId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Look up resource to determine which table to use
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const [deletedRule] = await db
|
||||||
|
.delete(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deletedRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the rule and return the deleted record
|
// Delete the rule and return the deleted record
|
||||||
const [deletedRule] = await db
|
const [deletedRule] = await db
|
||||||
.delete(resourceRules)
|
.delete(resourceRules)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { resourceWhitelist, users } from "@server/db"; // Assuming these are the correct tables
|
import {
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
resources
|
||||||
|
} 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";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -23,6 +27,15 @@ async function queryWhitelist(resourceId: number) {
|
|||||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queryPolicyWhitelist(policyId: number) {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
email: resourcePolicyWhiteList.email
|
||||||
|
})
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
export type GetResourceWhitelistResponse = {
|
export type GetResourceWhitelistResponse = {
|
||||||
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
|
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
|
||||||
};
|
};
|
||||||
@@ -71,7 +84,25 @@ export async function getResourceWhitelist(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
const whitelist = await queryWhitelist(resourceId);
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
const whitelist = isInlinePolicy
|
||||||
|
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
|
||||||
|
: await queryWhitelist(resourceId);
|
||||||
|
|
||||||
return response<GetResourceWhitelistResponse>(res, {
|
return response<GetResourceWhitelistResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db";
|
import { roleResources, roles, rolePolicies, resources } 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";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -27,6 +27,19 @@ async function query(resourceId: number) {
|
|||||||
.where(eq(roleResources.resourceId, resourceId));
|
.where(eq(roleResources.resourceId, resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queryInlinePolicy(policyId: number) {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
roleId: roles.roleId,
|
||||||
|
name: roles.name,
|
||||||
|
description: roles.description,
|
||||||
|
isAdmin: roles.isAdmin
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(roles, eq(rolePolicies.roleId, roles.roleId))
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
export type ListResourceRolesResponse = {
|
export type ListResourceRolesResponse = {
|
||||||
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
|
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
};
|
};
|
||||||
@@ -75,7 +88,25 @@ export async function listResourceRoles(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
const resourceRolesList = await query(resourceId);
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
const resourceRolesList = isInlinePolicy
|
||||||
|
? await queryInlinePolicy(resource.defaultResourcePolicyId!)
|
||||||
|
: await query(resourceId);
|
||||||
|
|
||||||
return response<ListResourceRolesResponse>(res, {
|
return response<ListResourceRolesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourceRules, resources } from "@server/db";
|
import { resourceRules, resourcePolicyRules, resources } 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";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
@@ -47,6 +47,21 @@ function queryResourceRules(resourceId: number) {
|
|||||||
return baseQuery;
|
return baseQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queryPolicyRules(policyId: number) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
ruleId: resourcePolicyRules.ruleId,
|
||||||
|
resourceId: sql<number | null>`null`,
|
||||||
|
action: resourcePolicyRules.action,
|
||||||
|
match: resourcePolicyRules.match,
|
||||||
|
value: resourcePolicyRules.value,
|
||||||
|
priority: resourcePolicyRules.priority,
|
||||||
|
enabled: resourcePolicyRules.enabled
|
||||||
|
})
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
export type ListResourceRulesResponse = {
|
export type ListResourceRulesResponse = {
|
||||||
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
pagination: { total: number; limit: number; offset: number };
|
pagination: { total: number; limit: number; offset: number };
|
||||||
@@ -125,16 +140,34 @@ export async function listResourceRules(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourceRules(resourceId);
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
const countQuery = db
|
let rulesList: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
let totalCount: number;
|
||||||
.from(resourceRules)
|
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
|
||||||
|
|
||||||
let rulesList = await baseQuery.limit(limit).offset(offset);
|
if (isInlinePolicy) {
|
||||||
const totalCountResult = await countQuery;
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
const totalCount = totalCountResult[0].count;
|
const policyRules = await queryPolicyRules(policyId)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
const countResult = await db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId));
|
||||||
|
rulesList = policyRules as typeof rulesList;
|
||||||
|
totalCount = countResult[0].count;
|
||||||
|
} else {
|
||||||
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
const countQuery = db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
const totalCountResult = await countQuery;
|
||||||
|
totalCount = totalCountResult[0].count;
|
||||||
|
}
|
||||||
|
|
||||||
// sort rules list by the priority in ascending order
|
// sort rules list by the priority in ascending order
|
||||||
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db";
|
import { roleResources, roles, rolePolicies } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -130,35 +130,71 @@ export async function removeRoleFromResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if role exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(roleResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
|
||||||
eq(roleResources.roleId, roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length === 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
const existingEntry = await db
|
||||||
"Role not found in resource"
|
.select()
|
||||||
)
|
.from(rolePolicies)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Role not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Check if role exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Role not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(roleResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
|
||||||
eq(roleResources.roleId, roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { userResources } from "@server/db";
|
import { userResources, userPolicies } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -103,35 +103,71 @@ export async function removeUserFromResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.resourceId, resourceId),
|
|
||||||
eq(userResources.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length === 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
const existingEntry = await db
|
||||||
"User not found in resource"
|
.select()
|
||||||
)
|
.from(userPolicies)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"User not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Check if user exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.resourceId, resourceId),
|
||||||
|
eq(userResources.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"User not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.resourceId, resourceId),
|
||||||
|
eq(userResources.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.resourceId, resourceId),
|
|
||||||
eq(userResources.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -89,36 +91,73 @@ export async function setResourceHeaderAuth(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { user, password, extendedCompatibility } = parsedBody.data;
|
const { user, password, extendedCompatibility } = parsedBody.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
if (isInlinePolicy) {
|
||||||
.delete(resourceHeaderAuth)
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.where(eq(resourceHeaderAuth.resourceId, resourceId));
|
await trx
|
||||||
await trx
|
.delete(resourcePolicyHeaderAuth)
|
||||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
.where(
|
||||||
.where(
|
eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId)
|
||||||
eq(
|
);
|
||||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
|
||||||
resourceId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user && password && extendedCompatibility !== null) {
|
if (user && password && extendedCompatibility !== null) {
|
||||||
const headerAuthHash = await hashPassword(
|
const headerAuthHash = await hashPassword(
|
||||||
Buffer.from(`${user}:${password}`).toString("base64")
|
Buffer.from(`${user}:${password}`).toString("base64")
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
trx
|
resourcePolicyId: policyId,
|
||||||
.insert(resourceHeaderAuth)
|
headerAuthHash,
|
||||||
.values({ resourceId, headerAuthHash }),
|
extendedCompatibility: extendedCompatibility!
|
||||||
trx
|
});
|
||||||
.insert(resourceHeaderAuthExtendedCompatibility)
|
}
|
||||||
.values({
|
} else {
|
||||||
resourceId,
|
await trx
|
||||||
extendedCompatibilityIsActivated:
|
.delete(resourceHeaderAuth)
|
||||||
extendedCompatibility
|
.where(eq(resourceHeaderAuth.resourceId, resourceId));
|
||||||
})
|
await trx
|
||||||
]);
|
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||||
|
resourceId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user && password && extendedCompatibility !== null) {
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(`${user}:${password}`).toString("base64")
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
trx
|
||||||
|
.insert(resourceHeaderAuth)
|
||||||
|
.values({ resourceId, headerAuthHash }),
|
||||||
|
trx
|
||||||
|
.insert(resourceHeaderAuthExtendedCompatibility)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
extendedCompatibilityIsActivated:
|
||||||
|
extendedCompatibility
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { resourcePassword } from "@server/db";
|
import {
|
||||||
|
resourcePassword,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resources
|
||||||
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -85,17 +89,49 @@ export async function setResourcePassword(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { password } = parsedBody.data;
|
const { password } = parsedBody.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
if (isInlinePolicy) {
|
||||||
.delete(resourcePassword)
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.where(eq(resourcePassword.resourceId, resourceId));
|
|
||||||
|
|
||||||
if (password) {
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePassword)
|
.delete(resourcePolicyPassword)
|
||||||
.values({ resourceId, passwordHash });
|
.where(
|
||||||
|
eq(resourcePolicyPassword.resourcePolicyId, policyId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
await trx
|
||||||
|
.insert(resourcePolicyPassword)
|
||||||
|
.values({ resourcePolicyId: policyId, passwordHash });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePassword)
|
||||||
|
.where(eq(resourcePassword.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insert(resourcePassword)
|
||||||
|
.values({ resourceId, passwordHash });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { resourcePincode } from "@server/db";
|
import { resourcePincode, resourcePolicyPincode, resources } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -89,17 +89,51 @@ export async function setResourcePincode(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { pincode } = parsedBody.data;
|
const { pincode } = parsedBody.data;
|
||||||
|
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
if (isInlinePolicy) {
|
||||||
.delete(resourcePincode)
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.where(eq(resourcePincode.resourceId, resourceId));
|
|
||||||
|
|
||||||
if (pincode) {
|
|
||||||
const pincodeHash = await hashPassword(pincode);
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePincode)
|
.delete(resourcePolicyPincode)
|
||||||
.values({ resourceId, pincodeHash, digitLength: 6 });
|
.where(
|
||||||
|
eq(resourcePolicyPincode.resourcePolicyId, policyId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pincode) {
|
||||||
|
const pincodeHash = await hashPassword(pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePincode)
|
||||||
|
.where(eq(resourcePincode.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (pincode) {
|
||||||
|
const pincodeHash = await hashPassword(pincode);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insert(resourcePincode)
|
||||||
|
.values({ resourceId, pincodeHash, digitLength: 6 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { apiKeys, roleResources, roles } from "@server/db";
|
import { apiKeys, roleResources, roles, rolePolicies } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -129,28 +129,61 @@ export async function setResourceRoles(
|
|||||||
);
|
);
|
||||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (adminRoleIds.length > 0) {
|
if (isInlinePolicy) {
|
||||||
await trx.delete(roleResources).where(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
// For inline policy, preserve admin roles by only deleting non-admin entries
|
||||||
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
if (adminRoleIds.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
ne(rolePolicies.roleId, adminRoleIds[0])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
roleIds.map((roleId) =>
|
||||||
|
trx
|
||||||
|
.insert(rolePolicies)
|
||||||
|
.values({ roleId, resourcePolicyId: policyId })
|
||||||
|
.returning()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await trx
|
if (adminRoleIds.length > 0) {
|
||||||
.delete(roleResources)
|
await trx.delete(roleResources).where(
|
||||||
.where(eq(roleResources.resourceId, resourceId));
|
and(
|
||||||
}
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(roleResources)
|
||||||
|
.where(eq(roleResources.resourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
const newRoleResources = await Promise.all(
|
await Promise.all(
|
||||||
roleIds.map((roleId) =>
|
roleIds.map((roleId) =>
|
||||||
trx
|
trx
|
||||||
.insert(roleResources)
|
.insert(roleResources)
|
||||||
.values({ roleId, resourceId })
|
.values({ roleId, resourceId })
|
||||||
.returning()
|
.returning()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { userResources } from "@server/db";
|
import { userResources, userPolicies, resources } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -82,19 +82,51 @@ export async function setResourceUsers(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
const [resource] = await db
|
||||||
await trx
|
.select()
|
||||||
.delete(userResources)
|
.from(resources)
|
||||||
.where(eq(userResources.resourceId, resourceId));
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const newUserResources = await Promise.all(
|
if (!resource) {
|
||||||
userIds.map((userId) =>
|
return next(
|
||||||
trx
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
.insert(userResources)
|
|
||||||
.values({ userId, resourceId })
|
|
||||||
.returning()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
await trx
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map((userId) =>
|
||||||
|
trx
|
||||||
|
.insert(userPolicies)
|
||||||
|
.values({ userId, resourcePolicyId: policyId })
|
||||||
|
.returning()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(userResources)
|
||||||
|
.where(eq(userResources.resourceId, resourceId));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map((userId) =>
|
||||||
|
trx
|
||||||
|
.insert(userResources)
|
||||||
|
.values({ userId, resourceId })
|
||||||
|
.returning()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
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 } from "@server/db";
|
||||||
import { resources, resourceWhitelist } from "@server/db";
|
import {
|
||||||
|
resources,
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyWhiteList
|
||||||
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
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";
|
||||||
@@ -104,57 +109,135 @@ export async function setResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.emailWhitelistEnabled) {
|
const isInlinePolicy =
|
||||||
return next(
|
resource.resourcePolicyId === null &&
|
||||||
createHttpError(
|
resource.defaultResourcePolicyId !== null;
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email whitelist is not enabled for this resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whitelist = await db
|
if (isInlinePolicy) {
|
||||||
.select()
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.from(resourceWhitelist)
|
|
||||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
const [policy] = await db
|
||||||
// diff the emails
|
.select()
|
||||||
const existingEmails = whitelist.map((w) => w.email);
|
.from(resourcePolicies)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
const emailsToAdd = emails.filter(
|
if (!policy) {
|
||||||
(e) => !existingEmails.includes(e)
|
return next(
|
||||||
);
|
createHttpError(
|
||||||
const emailsToRemove = existingEmails.filter(
|
HttpCode.NOT_FOUND,
|
||||||
(e) => !emails.includes(e)
|
"Resource policy not found"
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const email of emailsToAdd) {
|
if (!policy.emailWhitelistEnabled) {
|
||||||
await trx.insert(resourceWhitelist).values({
|
return next(
|
||||||
email,
|
createHttpError(
|
||||||
resourceId
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email whitelist is not enabled for this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPolicyWhitelist = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const existingEmails = existingPolicyWhitelist.map(
|
||||||
|
(w) => w.email
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailsToAdd = emails.filter(
|
||||||
|
(e) => !existingEmails.includes(e)
|
||||||
|
);
|
||||||
|
const emailsToRemove = existingEmails.filter(
|
||||||
|
(e) => !emails.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const email of emailsToAdd) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values({
|
||||||
|
email,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of emailsToRemove) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
resourcePolicyWhiteList.resourcePolicyId,
|
||||||
|
policyId
|
||||||
|
),
|
||||||
|
eq(resourcePolicyWhiteList.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Whitelist set for resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
for (const email of emailsToRemove) {
|
|
||||||
await trx
|
|
||||||
.delete(resourceWhitelist)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceWhitelist.resourceId, resourceId),
|
|
||||||
eq(resourceWhitelist.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Whitelist set for resource successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
if (!resource.emailWhitelistEnabled) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email whitelist is not enabled for this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whitelist = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
// diff the emails
|
||||||
|
const existingEmails = whitelist.map((w) => w.email);
|
||||||
|
|
||||||
|
const emailsToAdd = emails.filter(
|
||||||
|
(e) => !existingEmails.includes(e)
|
||||||
|
);
|
||||||
|
const emailsToRemove = existingEmails.filter(
|
||||||
|
(e) => !emails.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const email of emailsToAdd) {
|
||||||
|
await trx.insert(resourceWhitelist).values({
|
||||||
|
email,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of emailsToRemove) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Whitelist set for resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -549,6 +549,58 @@ async function updateHttpResource(
|
|||||||
updateData.maintenanceEstimatedTime = undefined;
|
updateData.maintenanceEstimatedTime = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
const {
|
||||||
|
sso,
|
||||||
|
emailWhitelistEnabled,
|
||||||
|
applyRules,
|
||||||
|
skipToIdpId,
|
||||||
|
...resourceOnlyData
|
||||||
|
} = updateData;
|
||||||
|
|
||||||
|
const policyUpdate: Record<string, unknown> = {};
|
||||||
|
if (sso !== undefined) policyUpdate.sso = sso;
|
||||||
|
if (emailWhitelistEnabled !== undefined)
|
||||||
|
policyUpdate.emailWhitelistEnabled = emailWhitelistEnabled;
|
||||||
|
if (applyRules !== undefined) policyUpdate.applyRules = applyRules;
|
||||||
|
if (skipToIdpId !== undefined) policyUpdate.idpId = skipToIdpId;
|
||||||
|
|
||||||
|
if (Object.keys(policyUpdate).length > 0) {
|
||||||
|
await db
|
||||||
|
.update(resourcePolicies)
|
||||||
|
.set(policyUpdate)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResource = await db
|
||||||
|
.update(resources)
|
||||||
|
.set({ ...resourceOnlyData, headers })
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedResource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resource.resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource[0],
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "HTTP resource updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ ...updateData, headers })
|
.set({ ...updateData, headers })
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
// Notify parent of changes (create mode)
|
// Notify parent of changes (create mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange?.(targets);
|
onChange?.(targets);
|
||||||
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [targets]);
|
||||||
|
|
||||||
// Poll health status only in edit mode
|
// Poll health status only in edit mode
|
||||||
const { data: polledTargets } = useQuery({
|
const { data: polledTargets } = useQuery({
|
||||||
@@ -334,19 +334,15 @@ export function ProxyResourceTargetsForm({
|
|||||||
{row.original.siteType === "newt" ? (
|
{row.original.siteType === "newt" ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-2 w-full text-left cursor-pointer"
|
className="flex items-center space-x-2 w-full text-left cursor-pointer"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openHealthCheckDialog(row.original)
|
openHealthCheckDialog(row.original)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
|
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
|
||||||
>
|
></div>
|
||||||
<div
|
<span>{getStatusText(status)}</span>
|
||||||
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
|
|
||||||
></div>
|
|
||||||
{getStatusText(status)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
@@ -535,7 +531,7 @@ export function ProxyResourceTargetsForm({
|
|||||||
accessorKey: "enabled",
|
accessorKey: "enabled",
|
||||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={row.original.enabled}
|
defaultChecked={row.original.enabled}
|
||||||
onCheckedChange={(val) =>
|
onCheckedChange={(val) =>
|
||||||
@@ -554,9 +550,8 @@ export function ProxyResourceTargetsForm({
|
|||||||
|
|
||||||
const actionsColumn: ColumnDef<LocalTarget> = {
|
const actionsColumn: ColumnDef<LocalTarget> = {
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: () => <span className="p-3">{t("actions")}</span>,
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center justify-end w-full">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => removeTarget(row.original.targetId)}
|
onClick={() => removeTarget(row.original.targetId)}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionForm,
|
SettingsSectionForm,
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
@@ -386,9 +389,9 @@ function SshServerForm({
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshServerMode")}
|
{t("sshServerMode")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{sshServerMode == "standard"
|
{sshServerMode == "standard"
|
||||||
? t("sshServerModeStandard")
|
? t("sshServerModeStandard")
|
||||||
@@ -397,9 +400,9 @@ function SshServerForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshAuthenticationMethod")}
|
{t("sshAuthenticationMethod")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<StrategySelect<"passthrough" | "push">
|
<StrategySelect<"passthrough" | "push">
|
||||||
value={pamMode}
|
value={pamMode}
|
||||||
options={authMethodOptions}
|
options={authMethodOptions}
|
||||||
@@ -410,9 +413,9 @@ function SshServerForm({
|
|||||||
|
|
||||||
{showDaemonLocation && (
|
{showDaemonLocation && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshAuthDaemonLocation")}
|
{t("sshAuthDaemonLocation")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<StrategySelect<"site" | "remote">
|
<StrategySelect<"site" | "remote">
|
||||||
value={standardDaemonLocation}
|
value={standardDaemonLocation}
|
||||||
options={daemonLocationOptions}
|
options={daemonLocationOptions}
|
||||||
@@ -460,14 +463,14 @@ function SshServerForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<SettingsSubsectionHeader>
|
||||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshServerDestination")}
|
{t("sshServerDestination")}
|
||||||
</h2>
|
</SettingsSubsectionTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<SettingsSubsectionDescription>
|
||||||
{t("sshServerDestinationDescription")}
|
{t("sshServerDestinationDescription")}
|
||||||
</p>
|
</SettingsSubsectionDescription>
|
||||||
</div>
|
</SettingsSubsectionHeader>
|
||||||
{isNative ? (
|
{isNative ? (
|
||||||
<Popover
|
<Popover
|
||||||
open={nativeSiteOpen}
|
open={nativeSiteOpen}
|
||||||
|
|||||||
@@ -2,13 +2,6 @@
|
|||||||
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import HealthCheckCredenza from "@app/components/HealthCheckCredenza";
|
|
||||||
import {
|
|
||||||
PathMatchDisplay,
|
|
||||||
PathMatchModal,
|
|
||||||
PathRewriteDisplay,
|
|
||||||
PathRewriteModal
|
|
||||||
} from "@app/components/PathMatchRenameModal";
|
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -16,7 +9,10 @@ import {
|
|||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionForm,
|
SettingsSectionForm,
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import {
|
import {
|
||||||
@@ -48,29 +44,6 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@app/components/ui/select";
|
|
||||||
import { Switch } from "@app/components/ui/switch";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from "@app/components/ui/table";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "@app/components/ui/tooltip";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
@@ -84,32 +57,16 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { Resource } from "@server/db";
|
import { Resource } from "@server/db";
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { ListTargetsResponse } from "@server/routers/target";
|
|
||||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
LocalTarget,
|
LocalTarget,
|
||||||
ProxyResourceTargetsForm
|
ProxyResourceTargetsForm
|
||||||
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import {
|
import {
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
CircleCheck,
|
|
||||||
CircleX,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
SquareArrowOutUpRight
|
SquareArrowOutUpRight
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -119,13 +76,11 @@ import { toASCII } from "punycode";
|
|||||||
import {
|
import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
|
||||||
useTransition,
|
useTransition,
|
||||||
useEffect
|
useEffect
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
@@ -327,14 +282,25 @@ export default function Page() {
|
|||||||
const rawResourcesAllowed =
|
const rawResourcesAllowed =
|
||||||
env.flags.allowRawResources &&
|
env.flags.allowRawResources &&
|
||||||
(build !== "saas" || remoteExitNodes.length > 0);
|
(build !== "saas" || remoteExitNodes.length > 0);
|
||||||
|
const enterpriseModesAllowed =
|
||||||
|
!env.flags.disableEnterpriseFeatures;
|
||||||
|
|
||||||
const availableTypes = useMemo((): NewResourceType[] => {
|
const availableTypes = useMemo((): NewResourceType[] => {
|
||||||
const base: NewResourceType[] = ["http", "ssh", "rdp", "vnc"];
|
const base: NewResourceType[] = ["http"];
|
||||||
|
if (enterpriseModesAllowed) {
|
||||||
|
base.push("ssh", "rdp", "vnc");
|
||||||
|
}
|
||||||
if (rawResourcesAllowed) {
|
if (rawResourcesAllowed) {
|
||||||
base.push("tcp", "udp");
|
base.push("tcp", "udp");
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [rawResourcesAllowed]);
|
}, [enterpriseModesAllowed, rawResourcesAllowed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!availableTypes.includes(resourceType)) {
|
||||||
|
setResourceType("http");
|
||||||
|
}
|
||||||
|
}, [availableTypes, resourceType]);
|
||||||
|
|
||||||
const baseForm = useForm({
|
const baseForm = useForm({
|
||||||
resolver: zodResolver(baseResourceFormSchema),
|
resolver: zodResolver(baseResourceFormSchema),
|
||||||
@@ -686,19 +652,25 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const typeLabels: Record<NewResourceType, string> = {
|
let typeLabels: Partial<Record<NewResourceType, string>> = {
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
ssh: "SSH",
|
|
||||||
rdp: "RDP",
|
|
||||||
vnc: "VNC",
|
|
||||||
tcp: "TCP",
|
tcp: "TCP",
|
||||||
udp: "UDP"
|
udp: "UDP"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (enterpriseModesAllowed) {
|
||||||
|
typeLabels = {
|
||||||
|
...typeLabels,
|
||||||
|
ssh: "SSH",
|
||||||
|
rdp: "RDP",
|
||||||
|
vnc: "VNC",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const typeOptions: OptionSelectOption<NewResourceType>[] =
|
const typeOptions: OptionSelectOption<NewResourceType>[] =
|
||||||
availableTypes.map((type) => ({
|
availableTypes.map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: typeLabels[type]
|
label: typeLabels[type] ?? type.toUpperCase()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -742,7 +714,7 @@ export default function Page() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
|
||||||
id="base-resource-form"
|
id="base-resource-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -825,7 +797,7 @@ export default function Page() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
|
||||||
id="tcp-udp-settings-form"
|
id="tcp-udp-settings-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -910,10 +882,10 @@ export default function Page() {
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
{/* Mode */}
|
{/* Mode */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshServerMode")}
|
{t("sshServerMode")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<StrategySelect<
|
<StrategySelect<
|
||||||
"standard" | "native"
|
"standard" | "native"
|
||||||
>
|
>
|
||||||
@@ -924,12 +896,12 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t(
|
{t(
|
||||||
"sshAuthenticationMethod"
|
"sshAuthenticationMethod"
|
||||||
)}
|
)}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<StrategySelect<
|
<StrategySelect<
|
||||||
"passthrough" | "push"
|
"passthrough" | "push"
|
||||||
>
|
>
|
||||||
@@ -944,12 +916,12 @@ export default function Page() {
|
|||||||
|
|
||||||
{/* Daemon Location (standard + push) */}
|
{/* Daemon Location (standard + push) */}
|
||||||
{showDaemonLocation && (
|
{showDaemonLocation && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t(
|
{t(
|
||||||
"sshAuthDaemonLocation"
|
"sshAuthDaemonLocation"
|
||||||
)}
|
)}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<StrategySelect<
|
<StrategySelect<
|
||||||
"site" | "remote"
|
"site" | "remote"
|
||||||
>
|
>
|
||||||
@@ -984,49 +956,55 @@ export default function Page() {
|
|||||||
{/* Daemon Port (standard + push + remote) */}
|
{/* Daemon Port (standard + push + remote) */}
|
||||||
{showDaemonPort && (
|
{showDaemonPort && (
|
||||||
<Form {...sshDaemonPortForm}>
|
<Form {...sshDaemonPortForm}>
|
||||||
<FormField
|
<div className="w-full md:w-1/2">
|
||||||
control={
|
<FormField
|
||||||
sshDaemonPortForm.control
|
control={
|
||||||
}
|
sshDaemonPortForm.control
|
||||||
name="authDaemonPort"
|
}
|
||||||
render={({ field }) => (
|
name="authDaemonPort"
|
||||||
<FormItem>
|
render={({
|
||||||
<FormLabel>
|
field
|
||||||
{t(
|
}) => (
|
||||||
"sshDaemonPort"
|
<FormItem>
|
||||||
)}
|
<FormLabel>
|
||||||
</FormLabel>
|
{t(
|
||||||
<FormControl>
|
"sshDaemonPort"
|
||||||
<Input
|
)}
|
||||||
type="number"
|
</FormLabel>
|
||||||
min={1}
|
<FormControl>
|
||||||
max={
|
<Input
|
||||||
65535
|
type="number"
|
||||||
}
|
min={
|
||||||
{...field}
|
1
|
||||||
/>
|
}
|
||||||
</FormControl>
|
max={
|
||||||
<FormMessage />
|
65535
|
||||||
</FormItem>
|
}
|
||||||
)}
|
{...field}
|
||||||
/>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Server Destination */}
|
{/* Server Destination */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<SettingsSubsectionHeader>
|
||||||
<h2 className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t(
|
{t(
|
||||||
"sshServerDestination"
|
"sshServerDestination"
|
||||||
)}
|
)}
|
||||||
</h2>
|
</SettingsSubsectionTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<SettingsSubsectionDescription>
|
||||||
{t(
|
{t(
|
||||||
"sshServerDestinationDescription"
|
"sshServerDestinationDescription"
|
||||||
)}
|
)}
|
||||||
</p>
|
</SettingsSubsectionDescription>
|
||||||
</div>
|
</SettingsSubsectionHeader>
|
||||||
{isNative ? (
|
{isNative ? (
|
||||||
<Popover
|
<Popover
|
||||||
open={nativeSiteOpen}
|
open={nativeSiteOpen}
|
||||||
@@ -1038,7 +1016,7 @@ export default function Page() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="w-full max-w-xs justify-between font-normal"
|
className="w-full md:w-1/2 justify-between font-normal"
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{nativeSelectedSite?.name ??
|
{nativeSelectedSite?.name ??
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export default function GeneralPage() {
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="">
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="auto-update-enabled"
|
id="auto-update-enabled"
|
||||||
label={t(
|
label={t(
|
||||||
@@ -285,7 +285,7 @@ export default function GeneralPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-auto p-0 pb-2 text-xs"
|
className="text-sm text-muted-foreground underline px-0"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"autoUpdateOverrideOrg",
|
"autoUpdateOverrideOrg",
|
||||||
|
|||||||
@@ -243,10 +243,8 @@ export default function Page() {
|
|||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
form.setValue("autoProvision", checked);
|
form.setValue("autoProvision", checked);
|
||||||
}}
|
}}
|
||||||
|
description={t("idpAutoProvisionConfigureAfterCreate")}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("idpAutoProvisionConfigureAfterCreate")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@
|
|||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(0.88 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.88 0.004 286.32);
|
--input: oklch(0.88 0.004 286.32);
|
||||||
--ring: oklch(0.705 0.213 47.604);
|
--ring: oklch(0.705 0.213 47.604);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.5382 0.1949 22.216);
|
--destructive: oklch(0.5382 0.1949 22.216);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(1 0 0 / 15%);
|
--border: oklch(1 0 0 / 8%);
|
||||||
--input: oklch(1 0 0 / 18%);
|
--input: oklch(1 0 0 / 18%);
|
||||||
--ring: oklch(0.646 0.222 41.116);
|
--ring: oklch(0.646 0.222 41.116);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export const orgNavSections = (
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...(build !== "oss"
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarPolicies",
|
title: "sidebarPolicies",
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ export default async function Page(props: {
|
|||||||
targetOrgId = lastOrgCookie;
|
targetOrgId = lastOrgCookie;
|
||||||
} else {
|
} else {
|
||||||
let ownedOrg = orgs.find((org) => org.isOwner);
|
let ownedOrg = orgs.find((org) => org.isOwner);
|
||||||
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
|
const primaryOrg = orgs.find((org) => org.isPrimaryOrg);
|
||||||
if (!ownedOrg) {
|
if (!ownedOrg) {
|
||||||
if (primaryOrg) {
|
if (primaryOrg) {
|
||||||
ownedOrg = primaryOrg;
|
ownedOrg = primaryOrg;
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export const metadata: Metadata = {
|
|||||||
export default async function MaintenanceScreen() {
|
export default async function MaintenanceScreen() {
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
let title = t("privateMaintenanceScreenTitle");
|
const title = t("privateMaintenanceScreenTitle");
|
||||||
let message = t("privateMaintenanceScreenMessage");
|
const message = t("privateMaintenanceScreenMessage");
|
||||||
let steps = t("privateMaintenanceScreenSteps");
|
const steps = t("privateMaintenanceScreenSteps");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
|||||||
+181
-193
@@ -1,9 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useForm } from "react-hook-form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Label } from "@/components/ui/label";
|
import * as z from "zod";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import type {
|
import type {
|
||||||
UserInteraction,
|
UserInteraction,
|
||||||
@@ -22,7 +32,10 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import Link from "next/link";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
|
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||||
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
declare module "react" {
|
declare module "react" {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
@@ -40,7 +53,7 @@ declare module "react" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormState = {
|
type RdpCredentialsForm = {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -49,6 +62,23 @@ type FormState = {
|
|||||||
enableClipboard: boolean;
|
enableClipboard: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function loadStoredCredentials(key: string): RdpCredentialsForm {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) return JSON.parse(saved) as RdpCredentialsForm;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
domain: "",
|
||||||
|
kdcProxyUrl: "",
|
||||||
|
pcb: "",
|
||||||
|
enableClipboard: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const isIronError = (error: unknown): error is IronError => {
|
const isIronError = (error: unknown): error is IronError => {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
@@ -60,33 +90,35 @@ const isIronError = (error: unknown): error is IronError => {
|
|||||||
|
|
||||||
export default function RdpClient({
|
export default function RdpClient({
|
||||||
target,
|
target,
|
||||||
error
|
error,
|
||||||
|
primaryColor
|
||||||
}: {
|
}: {
|
||||||
target: GetBrowserTargetResponse | null;
|
target: GetBrowserTargetResponse | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
primaryColor?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
const STORAGE_KEY = "pangolin_rdp_credentials";
|
const STORAGE_KEY = "pangolin_rdp_credentials";
|
||||||
|
const resourceName = target?.name?.trim() || null;
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(() => {
|
const formSchema = z.object({
|
||||||
try {
|
username: z.string().min(1, { message: t("usernameRequired") }),
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
password: z.string().min(1, { message: t("passwordRequired") }),
|
||||||
if (saved) return JSON.parse(saved) as FormState;
|
domain: z.string(),
|
||||||
} catch {
|
kdcProxyUrl: z.string(),
|
||||||
// ignore
|
pcb: z.string(),
|
||||||
}
|
enableClipboard: z.boolean()
|
||||||
return {
|
});
|
||||||
username: "",
|
|
||||||
password: "",
|
const form = useForm<RdpCredentialsForm>({
|
||||||
domain: "",
|
resolver: zodResolver(formSchema),
|
||||||
kdcProxyUrl: "",
|
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
||||||
pcb: "",
|
|
||||||
enableClipboard: true
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showLogin, setShowLogin] = useState(true);
|
const [showLogin, setShowLogin] = useState(true);
|
||||||
const [moduleReady, setModuleReady] = useState(false);
|
const [moduleReady, setModuleReady] = useState(false);
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [unicodeMode, setUnicodeMode] = useState(false);
|
const [unicodeMode, setUnicodeMode] = useState(false);
|
||||||
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
|
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
|
||||||
|
|
||||||
@@ -138,7 +170,7 @@ export default function RdpClient({
|
|||||||
console.error("Failed to load iron-remote-desktop modules", err);
|
console.error("Failed to load iron-remote-desktop modules", err);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to load RDP module",
|
title: t("rdpFailedToLoadModule"),
|
||||||
description: `${err}`
|
description: `${err}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -160,25 +192,17 @@ export default function RdpClient({
|
|||||||
el.addEventListener("ready", onReady);
|
el.addEventListener("ready", onReady);
|
||||||
};
|
};
|
||||||
|
|
||||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
const startSession = async (values: RdpCredentialsForm) => {
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const startSession = async () => {
|
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
const userInteraction = userInteractionRef.current;
|
const userInteraction = userInteractionRef.current;
|
||||||
const exts = extensionsRef.current;
|
const exts = extensionsRef.current;
|
||||||
if (!userInteraction || !exts) {
|
if (!userInteraction || !exts) {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
toast({
|
setSubmitError(t("rdpModuleInitializing"));
|
||||||
variant: "destructive",
|
|
||||||
title: "Not ready",
|
|
||||||
description: "RDP module is still initializing"
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
userInteraction.setEnableClipboard(form.enableClipboard);
|
userInteraction.setEnableClipboard(values.enableClipboard);
|
||||||
|
|
||||||
// Dispose any previous session's provider and create a fresh one so
|
// Dispose any previous session's provider and create a fresh one so
|
||||||
// there is no stale upload state from a prior connection.
|
// there is no stale upload state from a prior connection.
|
||||||
@@ -193,7 +217,9 @@ export default function RdpClient({
|
|||||||
const downloadable = files.filter((f) => !f.isDirectory);
|
const downloadable = files.filter((f) => !f.isDirectory);
|
||||||
if (downloadable.length === 0) return;
|
if (downloadable.length === 0) return;
|
||||||
toast({
|
toast({
|
||||||
title: `Downloading ${downloadable.length} file(s) from remote…`
|
title: t("rdpDownloadingFiles", {
|
||||||
|
count: downloadable.length
|
||||||
|
})
|
||||||
});
|
});
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
@@ -211,7 +237,9 @@ export default function RdpClient({
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: `Download failed: ${file.name}`,
|
title: t("rdpDownloadFailed", {
|
||||||
|
fileName: file.name
|
||||||
|
}),
|
||||||
description: `${err}`
|
description: `${err}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -220,7 +248,7 @@ export default function RdpClient({
|
|||||||
|
|
||||||
// Notify when individual uploads complete (remote pasted a file).
|
// Notify when individual uploads complete (remote pasted a file).
|
||||||
fileTransfer.on("upload-complete", (file: File) => {
|
fileTransfer.on("upload-complete", (file: File) => {
|
||||||
toast({ title: `Uploaded: ${file.name}` });
|
toast({ title: t("rdpUploaded", { fileName: file.name }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register with the web component so CLIPRDR extensions are
|
// Register with the web component so CLIPRDR extensions are
|
||||||
@@ -232,11 +260,7 @@ export default function RdpClient({
|
|||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
toast({
|
setSubmitError(t("rdpNoConnectionTarget"));
|
||||||
variant: "destructive",
|
|
||||||
title: "No target",
|
|
||||||
description: "No connection target available"
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,13 +268,13 @@ export default function RdpClient({
|
|||||||
|
|
||||||
const builder = userInteraction
|
const builder = userInteraction
|
||||||
.configBuilder()
|
.configBuilder()
|
||||||
.withUsername(form.username)
|
.withUsername(values.username)
|
||||||
.withPassword(form.password)
|
.withPassword(values.password)
|
||||||
.withDestination(destination)
|
.withDestination(destination)
|
||||||
.withProxyAddress(
|
.withProxyAddress(
|
||||||
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
|
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
|
||||||
)
|
)
|
||||||
.withServerDomain(form.domain)
|
.withServerDomain(values.domain)
|
||||||
.withAuthToken(target.authToken)
|
.withAuthToken(target.authToken)
|
||||||
.withDesktopSize({
|
.withDesktopSize({
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
@@ -258,18 +282,18 @@ export default function RdpClient({
|
|||||||
})
|
})
|
||||||
.withExtension(exts.displayControl(true));
|
.withExtension(exts.displayControl(true));
|
||||||
|
|
||||||
if (form.pcb !== "") {
|
if (values.pcb !== "") {
|
||||||
builder.withExtension(exts.preConnectionBlob(form.pcb));
|
builder.withExtension(exts.preConnectionBlob(values.pcb));
|
||||||
}
|
}
|
||||||
if (form.kdcProxyUrl !== "") {
|
if (values.kdcProxyUrl !== "") {
|
||||||
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
|
builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionInfo = await userInteraction.connect(builder.build());
|
const sessionInfo = await userInteraction.connect(builder.build());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -285,21 +309,18 @@ export default function RdpClient({
|
|||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
if (isIronError(err)) {
|
if (isIronError(err)) {
|
||||||
toast({
|
setSubmitError(err.backtrace());
|
||||||
variant: "destructive",
|
|
||||||
title: "Connection failed",
|
|
||||||
description: err.backtrace()
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
toast({
|
setSubmitError(`${err}`);
|
||||||
variant: "destructive",
|
|
||||||
title: "Connection failed",
|
|
||||||
description: `${err}`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: RdpCredentialsForm) => {
|
||||||
|
setSubmitError(null);
|
||||||
|
startSession(values);
|
||||||
|
};
|
||||||
|
|
||||||
const ui = () => userInteractionRef.current;
|
const ui = () => userInteractionRef.current;
|
||||||
|
|
||||||
const toggleCursorKind = () => {
|
const toggleCursorKind = () => {
|
||||||
@@ -315,133 +336,114 @@ export default function RdpClient({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||||
<div className="text-center mb-2">
|
<PoweredByPangolin />
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Powered by{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>RDP</CardTitle>
|
<CardTitle>{t("rdpTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showLogin && (
|
{showLogin && (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||||
<div className="text-center mb-2">
|
<PoweredByPangolin />
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Powered by{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sign in to Remote Desktop</CardTitle>
|
<CardTitle>
|
||||||
|
{resourceName
|
||||||
|
? `${t("rdpSignInTitle")} - ${resourceName}`
|
||||||
|
: t("rdpSignInTitle")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter Windows credentials to access xxxx
|
{resourceName
|
||||||
|
? `${t("rdpSignInDescription")} (${resourceName})`
|
||||||
|
: t("rdpSignInDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<Form {...form}>
|
||||||
<Field label="Domain" id="domain">
|
<form
|
||||||
<Input
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
id="domain"
|
className="space-y-4"
|
||||||
value={form.domain}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("domain", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Username" id="username">
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("username", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Password" id="password">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("password", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{/*
|
|
||||||
<Field label="Pre Connection Blob (optional)" id="pcb">
|
|
||||||
<Input
|
|
||||||
id="pcb"
|
|
||||||
value={form.pcb}
|
|
||||||
onChange={(e) => update("pcb", e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field> */}
|
|
||||||
|
|
||||||
{/* <Field
|
|
||||||
label="KDC Proxy URL (optional)"
|
|
||||||
id="kdcProxyUrl"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="kdcProxyUrl"
|
|
||||||
value={form.kdcProxyUrl}
|
|
||||||
onChange={(e) =>
|
|
||||||
update("kdcProxyUrl", e.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field> */}
|
|
||||||
{/* <div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="enable_clipboard"
|
|
||||||
checked={form.enableClipboard}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
update("enableClipboard", checked === true)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="enable_clipboard">
|
|
||||||
Enable Clipboard
|
|
||||||
</Label>
|
|
||||||
</div> */}
|
|
||||||
<Button
|
|
||||||
onClick={startSession}
|
|
||||||
disabled={!moduleReady}
|
|
||||||
loading={connecting}
|
|
||||||
className="w-full"
|
|
||||||
>
|
>
|
||||||
{moduleReady
|
<FormField
|
||||||
? "Connect"
|
control={form.control}
|
||||||
: "Loading module..."}
|
name="domain"
|
||||||
</Button>
|
render={({ field }) => (
|
||||||
</div>
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("domain")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("username")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!moduleReady || connecting}
|
||||||
|
loading={connecting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t("browserGatewayConnect")}
|
||||||
|
</Button>
|
||||||
|
{submitError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{submitError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -454,35 +456,35 @@ export default function RdpClient({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => ui()?.setScale(1)}
|
onClick={() => ui()?.setScale(1)}
|
||||||
>
|
>
|
||||||
Fit
|
{t("rdpFit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => ui()?.setScale(2)}
|
onClick={() => ui()?.setScale(2)}
|
||||||
>
|
>
|
||||||
Full
|
{t("rdpFull")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => ui()?.setScale(3)}
|
onClick={() => ui()?.setScale(3)}
|
||||||
>
|
>
|
||||||
Real
|
{t("rdpReal")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => ui()?.ctrlAltDel()}
|
onClick={() => ui()?.ctrlAltDel()}
|
||||||
>
|
>
|
||||||
Ctrl+Alt+Del
|
{t("browserGatewayCtrlAltDel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => ui()?.metaKey()}
|
onClick={() => ui()?.metaKey()}
|
||||||
>
|
>
|
||||||
Meta
|
{t("rdpMeta")}
|
||||||
</Button>
|
</Button>
|
||||||
{/* <Button
|
{/* <Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -504,19 +506,22 @@ export default function RdpClient({
|
|||||||
try {
|
try {
|
||||||
ft.uploadFiles(files);
|
ft.uploadFiles(files);
|
||||||
toast({
|
toast({
|
||||||
title: "Files ready to paste",
|
title: t("rdpFilesReadyToPaste"),
|
||||||
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
|
description: t(
|
||||||
|
"rdpFilesReadyToPasteDescription",
|
||||||
|
{ count: files.length }
|
||||||
|
)
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Upload failed",
|
title: t("rdpUploadFailed"),
|
||||||
description: `${err}`
|
description: `${err}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upload files
|
{t("rdpUploadFiles")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -526,7 +531,7 @@ export default function RdpClient({
|
|||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Terminate
|
{t("sshTerminate")}
|
||||||
</Button>
|
</Button>
|
||||||
<label className="ml-2 flex items-center gap-2">
|
<label className="ml-2 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -537,7 +542,7 @@ export default function RdpClient({
|
|||||||
ui()?.setKeyboardUnicodeMode(e.target.checked);
|
ui()?.setKeyboardUnicodeMode(e.target.checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
Unicode keyboard mode
|
{t("rdpUnicodeKeyboardMode")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -554,20 +559,3 @@ export default function RdpClient({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor={id}>{label}</Label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-25
@@ -1,40 +1,33 @@
|
|||||||
import { headers } from "next/headers";
|
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
|
||||||
import { priv } from "@app/lib/api";
|
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
|
||||||
import { AxiosResponse } from "axios";
|
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
|
||||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
|
||||||
import RdpClient from "./RdpClient";
|
import RdpClient from "./RdpClient";
|
||||||
import AuthFooter from "@app/components/AuthFooter";
|
import AuthFooter from "@app/components/AuthFooter";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata() {
|
||||||
title: "RDP"
|
return generateBrowserGatewayMetadata("RDP");
|
||||||
};
|
}
|
||||||
|
|
||||||
export default async function RdpPage() {
|
export default async function RdpPage() {
|
||||||
const headersList = await headers();
|
const t = await getTranslations();
|
||||||
const host = headersList.get("host") || "";
|
const { target } = await getBrowserTargetForRequest();
|
||||||
const hostname = host.split(":")[0];
|
const error = target ? null : t("browserGatewayNoResourceForDomain");
|
||||||
|
const { primaryColor } = target
|
||||||
let target: GetBrowserTargetResponse | null = null;
|
? await loadOrgLoginPageBranding(target.orgId)
|
||||||
let error: string | null = null;
|
: { primaryColor: null };
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
|
||||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
|
||||||
);
|
|
||||||
target = res.data.data;
|
|
||||||
console.log("Fetched browser target:", target);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching browser target:", error);
|
|
||||||
error = "No resource found for this domain";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 flex md:items-center justify-center">
|
<div className="flex-1 flex md:items-center justify-center">
|
||||||
<div className="w-full max-w-md p-3">
|
<div className="w-full max-w-md p-3">
|
||||||
<RdpClient target={target} error={error} />
|
<RdpClient
|
||||||
|
target={target}
|
||||||
|
error={error}
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AuthFooter />
|
<AuthFooter />
|
||||||
|
|||||||
+270
-240
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useForm } from "react-hook-form";
|
||||||
import { Input } from "@/components/ui/input";
|
import * as z from "zod";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { Textarea } from "@app/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -15,15 +24,17 @@ import {
|
|||||||
CardDescription
|
CardDescription
|
||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
import { ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { cn } from "@app/lib/cn";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||||
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
|
|
||||||
type AuthTab = "password" | "privateKey";
|
type AuthTab = "password" | "privateKey";
|
||||||
|
|
||||||
type FormState = {
|
type SshCredentialsForm = {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
@@ -36,32 +47,46 @@ type ConnectCredentials = {
|
|||||||
certificate?: string;
|
certificate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function loadStoredCredentials(key: string): SshCredentialsForm {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) return JSON.parse(saved) as SshCredentialsForm;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { username: "", password: "", privateKey: "" };
|
||||||
|
}
|
||||||
|
|
||||||
export default function SshClient({
|
export default function SshClient({
|
||||||
target,
|
target,
|
||||||
error,
|
error,
|
||||||
signedKeyData,
|
signedKeyData,
|
||||||
privateKey: signedPrivateKey
|
privateKey: signedPrivateKey,
|
||||||
|
primaryColor
|
||||||
}: {
|
}: {
|
||||||
target: GetBrowserTargetResponse | null;
|
target: GetBrowserTargetResponse | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
signedKeyData?: SignSshKeyResponse | null;
|
signedKeyData?: SignSshKeyResponse | null;
|
||||||
privateKey?: string | null;
|
privateKey?: string | null;
|
||||||
|
primaryColor?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const STORAGE_KEY = "pangolin_ssh_credentials";
|
const STORAGE_KEY = "pangolin_ssh_credentials";
|
||||||
|
const t = useTranslations();
|
||||||
|
const resourceName = target?.name?.trim() || null;
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(() => {
|
const passwordTabSchema = z.object({
|
||||||
try {
|
username: z.string().min(1, { message: t("usernameRequired") }),
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
password: z.string().min(1, { message: t("passwordRequired") })
|
||||||
if (saved) return JSON.parse(saved) as FormState;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return { username: "", password: "", privateKey: "" };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const t = useTranslations();
|
const privateKeyTabSchema = z.object({
|
||||||
|
username: z.string().min(1, { message: t("usernameRequired") }),
|
||||||
|
privateKey: z.string().min(1, { message: t("sshPrivateKeyRequired") })
|
||||||
|
});
|
||||||
|
|
||||||
const [authTab, setAuthTab] = useState<AuthTab>("password");
|
const form = useForm<SshCredentialsForm>({
|
||||||
|
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -70,11 +95,10 @@ export default function SshClient({
|
|||||||
reader.onload = (ev) => {
|
reader.onload = (ev) => {
|
||||||
const text = ev.target?.result;
|
const text = ev.target?.result;
|
||||||
if (typeof text === "string") {
|
if (typeof text === "string") {
|
||||||
setForm((prev) => ({ ...prev, privateKey: text }));
|
form.setValue("privateKey", text, { shouldDirty: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
// Reset input so the same file can be re-selected if needed.
|
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +150,12 @@ export default function SshClient({
|
|||||||
xtermRef.current = terminal;
|
xtermRef.current = terminal;
|
||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
// Send user keystrokes to the WebSocket.
|
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify({ type: "data", data }));
|
wsRef.current.send(JSON.stringify({ type: "data", data }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send resize events.
|
|
||||||
terminal.onResize(({ cols, rows }) => {
|
terminal.onResize(({ cols, rows }) => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(
|
wsRef.current.send(
|
||||||
@@ -142,7 +164,6 @@ export default function SshClient({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send the initial size once the terminal is rendered.
|
|
||||||
const { cols, rows } = terminal;
|
const { cols, rows } = terminal;
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(
|
wsRef.current.send(
|
||||||
@@ -156,14 +177,12 @@ export default function SshClient({
|
|||||||
};
|
};
|
||||||
}, [connected]);
|
}, [connected]);
|
||||||
|
|
||||||
// Refit terminal when the window resizes.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onResize = () => fitAddonRef.current?.fit();
|
const onResize = () => fitAddonRef.current?.fit();
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
return () => window.removeEventListener("resize", onResize);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cleanup on unmount.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
wsRef.current?.close();
|
wsRef.current?.close();
|
||||||
@@ -171,7 +190,6 @@ export default function SshClient({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-connect when signed key data is provided (push PAM mode).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (signedKeyData && signedPrivateKey && target) {
|
if (signedKeyData && signedPrivateKey && target) {
|
||||||
connect({
|
connect({
|
||||||
@@ -180,11 +198,12 @@ export default function SshClient({
|
|||||||
certificate: signedKeyData.certificate
|
certificate: signedKeyData.certificate
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function connect(override?: ConnectCredentials) {
|
function connect(
|
||||||
setConnectError(null);
|
override?: ConnectCredentials,
|
||||||
|
authMethod: AuthTab = "password"
|
||||||
|
) {
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -193,12 +212,14 @@ export default function SshClient({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = override?.username ?? form.username;
|
const values = form.getValues();
|
||||||
|
const username = override?.username ?? values.username;
|
||||||
const password =
|
const password =
|
||||||
override?.password ?? (authTab === "password" ? form.password : "");
|
override?.password ??
|
||||||
|
(authMethod === "password" ? values.password : "");
|
||||||
const privateKey =
|
const privateKey =
|
||||||
override?.privateKey ??
|
override?.privateKey ??
|
||||||
(authTab === "privateKey" ? form.privateKey : "");
|
(authMethod === "privateKey" ? values.privateKey : "");
|
||||||
const certificate = override?.certificate;
|
const certificate = override?.certificate;
|
||||||
|
|
||||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
||||||
@@ -217,16 +238,10 @@ export default function SshClient({
|
|||||||
const ws = new WebSocket(url.toString(), ["ssh"]);
|
const ws = new WebSocket(url.toString(), ["ssh"]);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
// Track whether the server has confirmed auth by sending the first
|
|
||||||
// data frame. Until then, errors are shown in the login form.
|
|
||||||
let authConfirmed = false;
|
let authConfirmed = false;
|
||||||
let authErrorShown = false;
|
let authErrorShown = false;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
// Send credentials as the first frame so the proxy can complete
|
|
||||||
// SSH authentication before piping pty data. Stay in "connecting"
|
|
||||||
// state until the server responds — this prevents the flash to the
|
|
||||||
// terminal page that would occur if we set connected=true here.
|
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "auth",
|
type: "auth",
|
||||||
@@ -237,7 +252,10 @@ export default function SshClient({
|
|||||||
);
|
);
|
||||||
if (!override) {
|
if (!override) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify(form.getValues())
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -261,7 +279,6 @@ export default function SshClient({
|
|||||||
xtermRef.current?.write(msg.data);
|
xtermRef.current?.write(msg.data);
|
||||||
} else if (msg.type === "error") {
|
} else if (msg.type === "error") {
|
||||||
if (!authConfirmed) {
|
if (!authConfirmed) {
|
||||||
// Auth-phase error — show in the login form.
|
|
||||||
authErrorShown = true;
|
authErrorShown = true;
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnectError(
|
setConnectError(
|
||||||
@@ -269,7 +286,7 @@ export default function SshClient({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
xtermRef.current?.writeln(
|
xtermRef.current?.writeln(
|
||||||
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
|
`\r\n\x1b[31m${t("sshTerminalError", { error: msg.error ?? "" })}\x1b[0m\r\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,13 +299,13 @@ export default function SshClient({
|
|||||||
xtermRef.current?.write(evt.data);
|
xtermRef.current?.write(evt.data);
|
||||||
}
|
}
|
||||||
} else if (evt.data instanceof Blob) {
|
} else if (evt.data instanceof Blob) {
|
||||||
evt.data.text().then((t) => {
|
evt.data.text().then((text) => {
|
||||||
if (!authConfirmed) {
|
if (!authConfirmed) {
|
||||||
authConfirmed = true;
|
authConfirmed = true;
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
}
|
}
|
||||||
xtermRef.current?.write(t);
|
xtermRef.current?.write(text);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -304,11 +321,9 @@ export default function SshClient({
|
|||||||
if (authConfirmed) {
|
if (authConfirmed) {
|
||||||
setConnected(false);
|
setConnected(false);
|
||||||
xtermRef.current?.writeln(
|
xtermRef.current?.writeln(
|
||||||
`\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n`
|
`\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// If auth was never confirmed the login form is already visible;
|
|
||||||
// a generic error is shown only when no specific error was received.
|
|
||||||
if (!authConfirmed && !authErrorShown) {
|
if (!authConfirmed && !authErrorShown) {
|
||||||
setConnectError(t("sshErrorConnectionClosed"));
|
setConnectError(t("sshErrorConnectionClosed"));
|
||||||
}
|
}
|
||||||
@@ -322,7 +337,40 @@ export default function SshClient({
|
|||||||
setConnected(false);
|
setConnected(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In push mode, show a connecting/connected state without the login form.
|
function applyTabSchemaErrors(
|
||||||
|
schema: z.ZodObject<z.ZodRawShape>,
|
||||||
|
values: SshCredentialsForm
|
||||||
|
) {
|
||||||
|
form.clearErrors();
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
if (result.success) return true;
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const field = issue.path[0];
|
||||||
|
if (typeof field === "string") {
|
||||||
|
form.setError(field as keyof SshCredentialsForm, {
|
||||||
|
message: issue.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPasswordSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setConnectError(null);
|
||||||
|
const values = form.getValues();
|
||||||
|
if (!applyTabSchemaErrors(passwordTabSchema, values)) return;
|
||||||
|
connect(undefined, "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPrivateKeySubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setConnectError(null);
|
||||||
|
const values = form.getValues();
|
||||||
|
if (!applyTabSchemaErrors(privateKeyTabSchema, values)) return;
|
||||||
|
connect(undefined, "privateKey");
|
||||||
|
}
|
||||||
|
|
||||||
if (signedKeyData && signedPrivateKey) {
|
if (signedKeyData && signedPrivateKey) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -351,7 +399,6 @@ export default function SshClient({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{connectError}
|
{connectError}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -376,202 +423,202 @@ export default function SshClient({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||||
<div className="text-center mb-2">
|
<PoweredByPangolin />
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("sshPoweredBy")}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("sshTitle")}</CardTitle>
|
<CardTitle>{t("sshTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!connected && (
|
{!connected && (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||||
<div className="text-center mb-2">
|
<PoweredByPangolin />
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("sshPoweredBy")}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("sshSignInTitle")}</CardTitle>
|
<CardTitle>
|
||||||
|
{resourceName
|
||||||
|
? `${t("sshSignInTitle")} - ${resourceName}`
|
||||||
|
: t("sshSignInTitle")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("sshSignInDescription")}
|
{resourceName
|
||||||
|
? `${t("sshSignInDescription")} (${resourceName})`
|
||||||
|
: t("sshSignInDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Tab row */}
|
<Form {...form}>
|
||||||
<div className="flex space-x-4 border-b mb-4">
|
<HorizontalTabs
|
||||||
{(["password", "privateKey"] as const).map(
|
clientSide
|
||||||
(tab) => (
|
defaultTab={0}
|
||||||
<button
|
items={[
|
||||||
key={tab}
|
{
|
||||||
type="button"
|
title: t("sshPasswordTab"),
|
||||||
onClick={() => setAuthTab(tab)}
|
href: "#"
|
||||||
className={cn(
|
},
|
||||||
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",
|
{
|
||||||
authTab === tab
|
title: t("sshPrivateKeyTab"),
|
||||||
? "text-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:rounded-full"
|
href: "#"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
}
|
||||||
)}
|
]}
|
||||||
>
|
|
||||||
{tab === "password"
|
|
||||||
? t("sshPasswordTab")
|
|
||||||
: t("sshPrivateKeyTab")}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{authTab === "password" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Field
|
|
||||||
label={t("username")}
|
|
||||||
id="username-pw"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="username-pw"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
username: e.target.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="root"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={t("password")} id="password">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
password: e.target.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authTab === "privateKey" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("sshPrivateKeyDisclaimer")}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://docs.pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{t("sshLearnMore")}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<Field
|
|
||||||
label={t("username")}
|
|
||||||
id="username-key"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="username-key"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
username: e.target.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="root"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label={t("sshPrivateKeyField")}
|
|
||||||
id="privateKey"
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
id="privateKey"
|
|
||||||
value={form.privateKey}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
privateKey: e.target.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
||||||
rows={5}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label={t("sshPrivateKeyFile")}
|
|
||||||
id="privateKeyFile"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="privateKeyFile"
|
|
||||||
type="file"
|
|
||||||
accept=".pem,.key,.pub,*"
|
|
||||||
onChange={handleKeyFile}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{connectError && (
|
|
||||||
<p className="text-destructive text-sm">
|
|
||||||
{connectError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => connect()}
|
|
||||||
loading={connecting}
|
|
||||||
disabled={
|
|
||||||
!form.username ||
|
|
||||||
(authTab === "password"
|
|
||||||
? !form.password
|
|
||||||
: !form.privateKey)
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
>
|
>
|
||||||
{connecting
|
<form
|
||||||
? t("sshConnecting")
|
onSubmit={onPasswordSubmit}
|
||||||
: t("sshAuthenticate")}
|
className="space-y-4 mt-4 p-1"
|
||||||
</Button>
|
>
|
||||||
</div>
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("username")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={connecting}
|
||||||
|
disabled={connecting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t("sshAuthenticate")}
|
||||||
|
</Button>
|
||||||
|
{connectError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{connectError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onPrivateKeySubmit}
|
||||||
|
className="space-y-4 mt-4 p-1"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sshPrivateKeyDisclaimer")}{" "}
|
||||||
|
<Link
|
||||||
|
href="https://docs.pangolin.net/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t("sshLearnMore")}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("username")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="privateKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"sshPrivateKeyField"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder={t(
|
||||||
|
"sshPrivateKeyPlaceholder"
|
||||||
|
)}
|
||||||
|
rows={5}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("sshPrivateKeyFile")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".pem,.key,.pub,*"
|
||||||
|
onChange={handleKeyFile}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={connecting}
|
||||||
|
disabled={connecting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t("sshAuthenticate")}
|
||||||
|
</Button>
|
||||||
|
{connectError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
{connectError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</HorizontalTabs>
|
||||||
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{connected && (
|
{connected && (
|
||||||
@@ -595,20 +642,3 @@ export default function SshClient({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor={id}>{label}</Label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+45
-43
@@ -1,11 +1,15 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
|
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
|
||||||
|
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
|
||||||
|
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||||
import SshClient from "./SshClient";
|
import SshClient from "./SshClient";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import AuthFooter from "@app/components/AuthFooter";
|
import AuthFooter from "@app/components/AuthFooter";
|
||||||
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
const pollInitialDelayMs = 250;
|
const pollInitialDelayMs = 250;
|
||||||
const pollStartIntervalMs = 250;
|
const pollStartIntervalMs = 250;
|
||||||
@@ -99,14 +103,13 @@ function generateEphemeralKeyPair(): {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata() {
|
||||||
title: "SSH"
|
return generateBrowserGatewayMetadata("SSH");
|
||||||
};
|
}
|
||||||
|
|
||||||
export default async function SshPage() {
|
export default async function SshPage() {
|
||||||
|
const t = await getTranslations();
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const host = headersList.get("host") || "";
|
|
||||||
const hostname = host.split(":")[0];
|
|
||||||
const cookieHeader = headersList.get("cookie") || "";
|
const cookieHeader = headersList.get("cookie") || "";
|
||||||
|
|
||||||
let target: GetBrowserTargetResponse | null = null;
|
let target: GetBrowserTargetResponse | null = null;
|
||||||
@@ -114,51 +117,49 @@ export default async function SshPage() {
|
|||||||
let privateKey: string | null = null;
|
let privateKey: string | null = null;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
try {
|
const { target: browserTarget } = await getBrowserTargetForRequest();
|
||||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
target = browserTarget;
|
||||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
|
||||||
);
|
|
||||||
target = res.data.data;
|
|
||||||
|
|
||||||
if (target.pamMode === "push") {
|
if (!target) {
|
||||||
try {
|
error = t("browserGatewayNoResourceForDomain");
|
||||||
const { privateKeyPem, publicKeyOpenSSH } =
|
} else if (target.pamMode === "push") {
|
||||||
generateEphemeralKeyPair();
|
try {
|
||||||
privateKey = privateKeyPem;
|
const { privateKeyPem, publicKeyOpenSSH } =
|
||||||
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
|
generateEphemeralKeyPair();
|
||||||
`/org/${target.orgId}/ssh/sign-key`,
|
privateKey = privateKeyPem;
|
||||||
{
|
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
|
||||||
publicKey: publicKeyOpenSSH,
|
`/org/${target.orgId}/ssh/sign-key`,
|
||||||
resourceId: target.resourceId,
|
{
|
||||||
type: "public"
|
publicKey: publicKeyOpenSSH,
|
||||||
},
|
resourceId: target.resourceId,
|
||||||
{
|
type: "public"
|
||||||
headers: {
|
},
|
||||||
Cookie: cookieHeader
|
{
|
||||||
}
|
headers: {
|
||||||
|
Cookie: cookieHeader
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
signedKeyData = res.data.data;
|
);
|
||||||
|
signedKeyData = res.data.data;
|
||||||
|
|
||||||
const messageIds =
|
const messageIds =
|
||||||
signedKeyData.messageIds.length > 0
|
signedKeyData.messageIds.length > 0
|
||||||
? signedKeyData.messageIds
|
? signedKeyData.messageIds
|
||||||
: signedKeyData.messageId
|
: signedKeyData.messageId
|
||||||
? [signedKeyData.messageId]
|
? [signedKeyData.messageId]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
await waitForRoundTripCompletion(messageIds, cookieHeader);
|
await waitForRoundTripCompletion(messageIds, cookieHeader);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error signing SSH key:", err);
|
console.error("Error signing SSH key:", err);
|
||||||
error =
|
error = t("sshErrorSignKeyFailed");
|
||||||
"Failed to sign SSH key for PAM push authentication. Did you sign in as a user?";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error("Error fetching browser target:", err);
|
|
||||||
error = "No resource found for this domain";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { primaryColor } = target
|
||||||
|
? await loadOrgLoginPageBranding(target.orgId)
|
||||||
|
: { primaryColor: null };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 flex md:items-center justify-center">
|
<div className="flex-1 flex md:items-center justify-center">
|
||||||
@@ -168,6 +169,7 @@ export default async function SshPage() {
|
|||||||
error={error}
|
error={error}
|
||||||
signedKeyData={signedKeyData}
|
signedKeyData={signedKeyData}
|
||||||
privateKey={privateKey}
|
privateKey={privateKey}
|
||||||
|
primaryColor={primaryColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+114
-116
@@ -1,9 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useForm } from "react-hook-form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Label } from "@/components/ui/label";
|
import * as z from "zod";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||||
import {
|
import {
|
||||||
@@ -13,41 +23,52 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from "@app/components/ui/card";
|
} from "@app/components/ui/card";
|
||||||
import Link from "next/link";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
|
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||||
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
type FormState = {
|
type VncCredentialsForm = {
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function loadStoredCredentials(key: string): VncCredentialsForm {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) return JSON.parse(saved) as VncCredentialsForm;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { password: "" };
|
||||||
|
}
|
||||||
|
|
||||||
export default function VncClient({
|
export default function VncClient({
|
||||||
target,
|
target,
|
||||||
error
|
error,
|
||||||
|
primaryColor
|
||||||
}: {
|
}: {
|
||||||
target: GetBrowserTargetResponse | null;
|
target: GetBrowserTargetResponse | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
primaryColor?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
const STORAGE_KEY = "pangolin_vnc_credentials";
|
const STORAGE_KEY = "pangolin_vnc_credentials";
|
||||||
|
const resourceName = target?.name?.trim() || null;
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(() => {
|
const formSchema = z.object({
|
||||||
try {
|
password: z.string()
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
});
|
||||||
if (saved) return JSON.parse(saved) as FormState;
|
|
||||||
} catch {
|
const form = useForm<VncCredentialsForm>({
|
||||||
// ignore
|
resolver: zodResolver(formSchema),
|
||||||
}
|
defaultValues: loadStoredCredentials(STORAGE_KEY)
|
||||||
return { password: "" };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
const rfbRef = useRef<any>(null);
|
const rfbRef = useRef<any>(null);
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disconnect and clean up the RFB instance.
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
if (rfbRef.current) {
|
if (rfbRef.current) {
|
||||||
rfbRef.current.disconnect();
|
rfbRef.current.disconnect();
|
||||||
@@ -56,28 +77,20 @@ export default function VncClient({
|
|||||||
setConnected(false);
|
setConnected(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clean up on unmount.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => disconnect();
|
return () => disconnect();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []);
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = async (values: VncCredentialsForm) => {
|
||||||
if (!target) {
|
if (!target) {
|
||||||
toast({
|
setConnectError(t("vncNoResourceTarget"));
|
||||||
variant: "destructive",
|
|
||||||
title: "No target",
|
|
||||||
description: "No resource target is available"
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!screenRef.current) return;
|
if (!screenRef.current) return;
|
||||||
|
|
||||||
// Disconnect any existing session first.
|
|
||||||
disconnect();
|
disconnect();
|
||||||
|
|
||||||
// noVNC has no ESM default export — import the module dynamically to
|
|
||||||
// keep it out of the server bundle, then grab the default export.
|
|
||||||
let RFB: new (
|
let RFB: new (
|
||||||
target: HTMLElement,
|
target: HTMLElement,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -90,14 +103,12 @@ export default function VncClient({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Failed to load noVNC",
|
title: t("vncFailedToLoadNovnc"),
|
||||||
description: `${err}`
|
description: `${err}`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the proxy WebSocket URL:
|
|
||||||
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
|
|
||||||
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
|
||||||
const base = proxyAddress.replace(/\/$/, "");
|
const base = proxyAddress.replace(/\/$/, "");
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -107,15 +118,13 @@ export default function VncClient({
|
|||||||
});
|
});
|
||||||
const wsUrl = `${base}?${params.toString()}`;
|
const wsUrl = `${base}?${params.toString()}`;
|
||||||
|
|
||||||
// Clear the container so noVNC gets a clean mount point.
|
|
||||||
screenRef.current.innerHTML = "";
|
screenRef.current.innerHTML = "";
|
||||||
|
|
||||||
const options: Record<string, unknown> = {};
|
const options: Record<string, unknown> = {};
|
||||||
if (form.password) {
|
if (values.password) {
|
||||||
options.credentials = { password: form.password };
|
options.credentials = { password: values.password };
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
const rfb: any = new RFB(screenRef.current, wsUrl, options);
|
||||||
|
|
||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
@@ -123,7 +132,7 @@ export default function VncClient({
|
|||||||
|
|
||||||
rfb.addEventListener("connect", () => {
|
rfb.addEventListener("connect", () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -141,92 +150,99 @@ export default function VncClient({
|
|||||||
rfb.addEventListener(
|
rfb.addEventListener(
|
||||||
"securityfailure",
|
"securityfailure",
|
||||||
(e: { detail: { status: number; reason?: string } }) => {
|
(e: { detail: { status: number; reason?: string } }) => {
|
||||||
toast({
|
disconnect();
|
||||||
variant: "destructive",
|
setConnectError(
|
||||||
title: "Authentication failed",
|
e.detail.reason ??
|
||||||
description: e.detail.reason ?? `Status ${e.detail.status}`
|
t("vncAuthFailedStatus", {
|
||||||
});
|
status: e.detail.status
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
rfbRef.current = rfb;
|
rfbRef.current = rfb;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: VncCredentialsForm) => {
|
||||||
|
setConnectError(null);
|
||||||
|
connect(values);
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||||
<div className="text-center mb-2">
|
<PoweredByPangolin />
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Powered by{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>VNC</CardTitle>
|
<CardTitle>{t("vncTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!connected && (
|
{!connected && (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={primaryColor}>
|
||||||
<div className="text-center mb-2">
|
<PoweredByPangolin />
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Powered by{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>VNC</CardTitle>
|
<CardTitle>
|
||||||
|
{resourceName
|
||||||
|
? `${t("vncTitle")} - ${resourceName}`
|
||||||
|
: t("vncTitle")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter your credentials to access xxxx
|
{resourceName
|
||||||
|
? `${t("vncSignInDescription")} (${resourceName})`
|
||||||
|
: t("vncSignInDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<Form {...form}>
|
||||||
<Field
|
<form
|
||||||
label="Password (optional)"
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
id="password"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<Input
|
<FormField
|
||||||
id="password"
|
control={form.control}
|
||||||
type="password"
|
name="password"
|
||||||
value={form.password}
|
render={({ field }) => (
|
||||||
onChange={(e) =>
|
<FormItem>
|
||||||
update("password", e.target.value)
|
<FormLabel>
|
||||||
}
|
{t("vncPasswordOptional")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
<Button type="submit" className="w-full">
|
||||||
|
{t("browserGatewayConnect")}
|
||||||
<Button onClick={connect} className="w-full">
|
</Button>
|
||||||
Connect
|
{connectError && (
|
||||||
</Button>
|
<Alert variant="destructive">
|
||||||
</div>
|
<AlertDescription>
|
||||||
|
{connectError}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -243,7 +259,7 @@ export default function VncClient({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Ctrl+Alt+Del
|
{t("browserGatewayCtrlAltDel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -257,18 +273,17 @@ export default function VncClient({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Paste clipboard
|
{t("vncPasteClipboard")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={disconnect}
|
onClick={disconnect}
|
||||||
>
|
>
|
||||||
Terminate
|
{t("sshTerminate")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* noVNC mounts a <canvas> inside this div */}
|
|
||||||
<div
|
<div
|
||||||
ref={screenRef}
|
ref={screenRef}
|
||||||
className="flex-1 overflow-hidden"
|
className="flex-1 overflow-hidden"
|
||||||
@@ -278,20 +293,3 @@ export default function VncClient({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor={id}>{label}</Label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-24
@@ -1,39 +1,33 @@
|
|||||||
import { headers } from "next/headers";
|
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
|
||||||
import { priv } from "@app/lib/api";
|
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
|
||||||
import { AxiosResponse } from "axios";
|
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
|
||||||
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
|
||||||
import VncClient from "./VncClient";
|
import VncClient from "./VncClient";
|
||||||
import AuthFooter from "@app/components/AuthFooter";
|
import AuthFooter from "@app/components/AuthFooter";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata = {
|
export async function generateMetadata() {
|
||||||
title: "VNC"
|
return generateBrowserGatewayMetadata("VNC");
|
||||||
};
|
}
|
||||||
|
|
||||||
export default async function VncPage() {
|
export default async function VncPage() {
|
||||||
const headersList = await headers();
|
const t = await getTranslations();
|
||||||
const host = headersList.get("host") || "";
|
const { target } = await getBrowserTargetForRequest();
|
||||||
const hostname = host.split(":")[0];
|
const error = target ? null : t("browserGatewayNoResourceForDomain");
|
||||||
|
const { primaryColor } = target
|
||||||
let target: GetBrowserTargetResponse | null = null;
|
? await loadOrgLoginPageBranding(target.orgId)
|
||||||
let error: string | null = null;
|
: { primaryColor: null };
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
|
||||||
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
|
||||||
);
|
|
||||||
target = res.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching browser target:", error);
|
|
||||||
error = "No resource found for this domain";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 flex md:items-center justify-center">
|
<div className="flex-1 flex md:items-center justify-center">
|
||||||
<div className="w-full max-w-md p-3">
|
<div className="w-full max-w-md p-3">
|
||||||
<VncClient target={target} error={error} />
|
<VncClient
|
||||||
|
target={target}
|
||||||
|
error={error}
|
||||||
|
primaryColor={primaryColor}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AuthFooter />
|
<AuthFooter />
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
|
type BrandedAuthSurfaceProps = {
|
||||||
|
primaryColor?: string | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BrandedAuthSurface({
|
||||||
|
primaryColor,
|
||||||
|
children
|
||||||
|
}: BrandedAuthSurfaceProps) {
|
||||||
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error CSS variable
|
||||||
|
"--primary": isUnlocked() ? primaryColor : null
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -105,7 +105,6 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
|||||||
{t("destination")}
|
{t("destination")}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="192.168.1.1"
|
|
||||||
value={props.destination}
|
value={props.destination}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.onDestinationChange(e.target.value)
|
props.onDestinationChange(e.target.value)
|
||||||
@@ -116,7 +115,6 @@ export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
|||||||
<label className="text-sm font-semibold">{t("port")}</label>
|
<label className="text-sm font-semibold">{t("port")}</label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={props.defaultPort.toString()}
|
|
||||||
value={props.destinationPort}
|
value={props.destinationPort}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
props.onDestinationPortChange(e.target.value)
|
props.onDestinationPortChange(e.target.value)
|
||||||
|
|||||||
@@ -23,19 +23,22 @@ import {
|
|||||||
isHostname,
|
isHostname,
|
||||||
type InternalResourceFormValues
|
type InternalResourceFormValues
|
||||||
} from "./PrivateResourceForm";
|
} from "./PrivateResourceForm";
|
||||||
|
import type { Selectedsite } from "./site-selector";
|
||||||
|
|
||||||
type CreateInternalResourceDialogProps = {
|
type CreateInternalResourceDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (val: boolean) => void;
|
setOpen: (val: boolean) => void;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
initialSites?: Selectedsite[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreatePrivateResourceDialog({
|
export default function CreatePrivateResourceDialog({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
orgId,
|
orgId,
|
||||||
onSuccess
|
onSuccess,
|
||||||
|
initialSites
|
||||||
}: CreateInternalResourceDialogProps) {
|
}: CreateInternalResourceDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
@@ -175,6 +178,7 @@ export default function CreatePrivateResourceDialog({
|
|||||||
formId="create-internal-resource-form"
|
formId="create-internal-resource-form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||||
|
initialSites={initialSites}
|
||||||
/>
|
/>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100dvh-clamp(1.5rem,12vh,200px)-1.5rem)] md:translate-y-0 md:overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,10 @@ import {
|
|||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||||
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
|
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
type OrgLoginPageProps = {
|
type OrgLoginPageProps = {
|
||||||
loginPage: LoadLoginPageResponse | undefined;
|
loginPage: LoadLoginPageResponse | undefined;
|
||||||
@@ -52,22 +53,8 @@ export default async function OrgLoginPage({
|
|||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
return (
|
return (
|
||||||
<div>
|
<BrandedAuthSurface primaryColor={branding?.primaryColor ?? null}>
|
||||||
{build !== "enterprise" || !env.branding.hidePoweredBy ? (
|
<PoweredByPangolin />
|
||||||
<div className="text-center mb-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("poweredBy")}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
{env.branding.appName || "Pangolin"}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
{branding?.logoUrl && (
|
{branding?.logoUrl && (
|
||||||
@@ -127,6 +114,6 @@ export default async function OrgLoginPage({
|
|||||||
{t("loginBack")}
|
{t("loginBack")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
function PoweredByLabel({ brandName }: { brandName: string }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center mb-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("poweredBy")}{" "}
|
||||||
|
{brandName === "Pangolin" ? (
|
||||||
|
<Link
|
||||||
|
href="https://pangolin.net/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Pangolin
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
brandName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoweredByPangolin() {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
|
if (isUnlocked() && build === "enterprise") {
|
||||||
|
if (
|
||||||
|
env.branding.resourceAuthPage?.hidePoweredBy ||
|
||||||
|
env.branding.hidePoweredBy
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PoweredByLabel
|
||||||
|
brandName={env.branding.appName || "Pangolin"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PoweredByLabel brandName="Pangolin" />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
import {
|
import {
|
||||||
OptionSelect,
|
OptionSelect,
|
||||||
@@ -208,6 +213,7 @@ type InternalResourceFormProps = {
|
|||||||
formId: string;
|
formId: string;
|
||||||
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
||||||
onSubmitDisabledChange?: (disabled: boolean) => void;
|
onSubmitDisabledChange?: (disabled: boolean) => void;
|
||||||
|
initialSites?: Selectedsite[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PrivateResourceForm({
|
export function PrivateResourceForm({
|
||||||
@@ -218,7 +224,8 @@ export function PrivateResourceForm({
|
|||||||
siteResourceId,
|
siteResourceId,
|
||||||
formId,
|
formId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onSubmitDisabledChange
|
onSubmitDisabledChange,
|
||||||
|
initialSites = []
|
||||||
}: InternalResourceFormProps) {
|
}: InternalResourceFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -609,6 +616,8 @@ export function PrivateResourceForm({
|
|||||||
authDaemonMode === "remote";
|
authDaemonMode === "remote";
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
const previousResourceId = useRef<number | null>(null);
|
const previousResourceId = useRef<number | null>(null);
|
||||||
|
const initialSitesRef = useRef(initialSites);
|
||||||
|
initialSitesRef.current = initialSites;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||||
@@ -623,9 +632,13 @@ export function PrivateResourceForm({
|
|||||||
// Reset when create dialog opens
|
// Reset when create dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant === "create" && open) {
|
if (variant === "create" && open) {
|
||||||
|
const prefillSites =
|
||||||
|
initialSitesRef.current.length > 0
|
||||||
|
? initialSitesRef.current
|
||||||
|
: [];
|
||||||
form.reset({
|
form.reset({
|
||||||
name: "",
|
name: "",
|
||||||
siteIds: [],
|
siteIds: prefillSites.map((s) => s.siteId),
|
||||||
mode: "host",
|
mode: "host",
|
||||||
destination: "",
|
destination: "",
|
||||||
alias: null,
|
alias: null,
|
||||||
@@ -645,7 +658,7 @@ export function PrivateResourceForm({
|
|||||||
users: [],
|
users: [],
|
||||||
clients: []
|
clients: []
|
||||||
});
|
});
|
||||||
setSelectedSites([]);
|
setSelectedSites(prefillSites);
|
||||||
setSshServerMode("native");
|
setSshServerMode("native");
|
||||||
setTcpPortMode("all");
|
setTcpPortMode("all");
|
||||||
setUdpPortMode("all");
|
setUdpPortMode("all");
|
||||||
@@ -1799,10 +1812,10 @@ export function PrivateResourceForm({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Mode */}
|
{/* Mode */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshServerMode")}
|
{t("sshServerMode")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<StrategySelect<"standard" | "native">
|
<StrategySelect<"standard" | "native">
|
||||||
value={sshServerMode}
|
value={sshServerMode}
|
||||||
options={[
|
options={[
|
||||||
@@ -1856,10 +1869,10 @@ export function PrivateResourceForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshAuthenticationMethod")}
|
{t("sshAuthenticationMethod")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="pamMode"
|
name="pamMode"
|
||||||
@@ -1951,10 +1964,10 @@ export function PrivateResourceForm({
|
|||||||
|
|
||||||
{/* Daemon Location (standard + push) */}
|
{/* Daemon Location (standard + push) */}
|
||||||
{showDaemonLocation && (
|
{showDaemonLocation && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold">
|
<SettingsSubsectionTitle>
|
||||||
{t("sshAuthDaemonLocation")}
|
{t("sshAuthDaemonLocation")}
|
||||||
</p>
|
</SettingsSubsectionTitle>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="authDaemonMode"
|
name="authDaemonMode"
|
||||||
@@ -2054,51 +2067,57 @@ export function PrivateResourceForm({
|
|||||||
|
|
||||||
{/* Daemon Port (standard + push + remote) */}
|
{/* Daemon Port (standard + push + remote) */}
|
||||||
{showDaemonPort && (
|
{showDaemonPort && (
|
||||||
<FormField
|
<div className="w-full md:w-1/2">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="authDaemonPort"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="authDaemonPort"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
{t("sshDaemonPort")}
|
<FormLabel>
|
||||||
</FormLabel>
|
{t("sshDaemonPort")}
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
type="number"
|
<Input
|
||||||
min={1}
|
type="number"
|
||||||
max={65535}
|
min={1}
|
||||||
placeholder="22123"
|
max={65535}
|
||||||
disabled={
|
placeholder="22123"
|
||||||
sshSectionDisabled
|
disabled={
|
||||||
}
|
sshSectionDisabled
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (sshSectionDisabled)
|
|
||||||
return;
|
|
||||||
const v =
|
|
||||||
e.target.value;
|
|
||||||
if (v === "") {
|
|
||||||
field.onChange(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const num = parseInt(
|
value={
|
||||||
v,
|
field.value ?? ""
|
||||||
10
|
}
|
||||||
);
|
onChange={(e) => {
|
||||||
field.onChange(
|
if (
|
||||||
Number.isNaN(num)
|
sshSectionDisabled
|
||||||
? null
|
)
|
||||||
: num
|
return;
|
||||||
);
|
const v =
|
||||||
}}
|
e.target.value;
|
||||||
/>
|
if (v === "") {
|
||||||
</FormControl>
|
field.onChange(
|
||||||
<FormMessage />
|
null
|
||||||
</FormItem>
|
);
|
||||||
)}
|
return;
|
||||||
/>
|
}
|
||||||
|
const num =
|
||||||
|
parseInt(v, 10);
|
||||||
|
field.onChange(
|
||||||
|
Number.isNaN(
|
||||||
|
num
|
||||||
|
)
|
||||||
|
? null
|
||||||
|
: num
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -744,7 +744,7 @@ function TargetStatusCell({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center gap-2 h-8 px-2 font-normal"
|
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||||
>
|
>
|
||||||
<StatusIcon status={overallStatus} />
|
<StatusIcon status={overallStatus} />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ import {
|
|||||||
} from "@app/actions/server";
|
} from "@app/actions/server";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
|
||||||
import BrandingLogo from "@app/components/BrandingLogo";
|
import BrandingLogo from "@app/components/BrandingLogo";
|
||||||
|
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||||
|
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -366,57 +367,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<BrandedAuthSurface primaryColor={props.branding?.primaryColor}>
|
||||||
style={{
|
|
||||||
// @ts-expect-error CSS variable
|
|
||||||
"--primary": isUnlocked() ? props.branding?.primaryColor : null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!accessDenied ? (
|
{!accessDenied ? (
|
||||||
<div>
|
<div>
|
||||||
{isUnlocked() && build === "enterprise" ? (
|
<PoweredByPangolin />
|
||||||
!env.branding.resourceAuthPage?.hidePoweredBy &&
|
|
||||||
!env.branding.hidePoweredBy && (
|
|
||||||
<div className="text-center mb-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("poweredBy")}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
{env.branding.appName || "Pangolin"}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="text-center mb-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("poweredBy")}{" "}
|
|
||||||
<Link
|
|
||||||
href="https://pangolin.net/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Pangolin
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
{isUnlocked() &&
|
{isUnlocked() &&
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
(env.branding?.resourceAuthPage?.showLogo ||
|
props.branding?.logoUrl && (
|
||||||
props.branding) && (
|
|
||||||
<div className="flex flex-row items-center justify-center mb-3">
|
<div className="flex flex-row items-center justify-center mb-3">
|
||||||
<BrandingLogo
|
<BrandingLogo
|
||||||
height={logoHeight}
|
height={logoHeight}
|
||||||
width={logoWidth}
|
width={logoWidth}
|
||||||
logoPath={props.branding?.logoUrl}
|
logoPath={props.branding.logoUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -790,6 +754,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
) : (
|
) : (
|
||||||
<ResourceAccessDenied />
|
<ResourceAccessDenied />
|
||||||
)}
|
)}
|
||||||
</div>
|
</BrandedAuthSurface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function SetResourceHeaderAuthForm({
|
|||||||
label={t(
|
label={t(
|
||||||
"headerAuthCompatibility"
|
"headerAuthCompatibility"
|
||||||
)}
|
)}
|
||||||
info={t(
|
description={t(
|
||||||
"headerAuthCompatibilityInfo"
|
"headerAuthCompatibilityInfo"
|
||||||
)}
|
)}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
|
|||||||
@@ -63,6 +63,42 @@ export function SettingsSectionDescription({
|
|||||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SettingsSubsectionHeader({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <div className={cn("space-y-0.5", className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSubsectionTitle({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<h3 className={cn("text-sm font-semibold", className)}>{children}</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSubsectionDescription({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-sm text-muted-foreground", className)}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsSectionBody({
|
export function SettingsSectionBody({
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export function SwitchInput({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<div className="flex items-center space-x-2">
|
||||||
{label && (
|
{label && (
|
||||||
<Label
|
<Label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useActionState, useMemo, useState } from "react";
|
import { useActionState, useMemo, useRef, useState } from "react";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Checkbox } from "./ui/checkbox";
|
import { Checkbox } from "./ui/checkbox";
|
||||||
@@ -88,6 +88,11 @@ export function LabelsSelector({
|
|||||||
colorValues[Math.floor(Math.random() * colorValues.length)];
|
colorValues[Math.floor(Math.random() * colorValues.length)];
|
||||||
|
|
||||||
const [, action, isPending] = useActionState(createLabel, null);
|
const [, action, isPending] = useActionState(createLabel, null);
|
||||||
|
const createFormRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
const trimmedQuery = labelSearchQuery.trim();
|
||||||
|
const canCreateLabel =
|
||||||
|
trimmedQuery.length > 0 && labelsShown.length === 0 && !isPending;
|
||||||
|
|
||||||
async function createLabel(_: any, formData: FormData) {
|
async function createLabel(_: any, formData: FormData) {
|
||||||
const name = formData.get("name")?.toString();
|
const name = formData.get("name")?.toString();
|
||||||
@@ -120,21 +125,28 @@ export function LabelsSelector({
|
|||||||
return (
|
return (
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={t("labelSearch")}
|
placeholder={t("labelSearchOrCreate")}
|
||||||
value={labelSearchQuery}
|
value={labelSearchQuery}
|
||||||
onValueChange={setlabelsSearchQuery}
|
onValueChange={setlabelsSearchQuery}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && canCreateLabel) {
|
||||||
|
e.preventDefault();
|
||||||
|
createFormRef.current?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="px-3 break-all wrap-anywhere text-wrap">
|
<CommandEmpty className="px-3 py-6 text-center text-wrap">
|
||||||
{labelSearchQuery.trim().length > 0 ? (
|
{labelSearchQuery.trim().length > 0 ? (
|
||||||
<div className="flex flex-col gap-2 items-center">
|
<div className="flex flex-col gap-2 items-center">
|
||||||
<span className="max-w-34">
|
<span className="max-w-34 break-words">
|
||||||
{t("createNewLabel", {
|
{t("createNewLabel", {
|
||||||
label: labelSearchQuery.trim()
|
label: labelSearchQuery.trim()
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
ref={createFormRef}
|
||||||
action={action}
|
action={action}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -159,14 +171,17 @@ export function LabelsSelector({
|
|||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="size-4 rounded-full bg-(--color) flex-none"
|
className="size-2 rounded-full bg-(--color) flex-none"
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error css color
|
// @ts-expect-error css color
|
||||||
"--color": value
|
"--color": value
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span data-name>
|
<span data-name>
|
||||||
{color}
|
{color
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase() +
|
||||||
|
color.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)
|
)
|
||||||
@@ -176,7 +191,6 @@ export function LabelsSelector({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
@@ -185,7 +199,14 @@ export function LabelsSelector({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
t("labelsNotFound")
|
<div className="flex flex-col gap-1 items-center">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("labelsNotFound")}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{t("labelsEmptyCreateHint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import {
|
|||||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
|
import {
|
||||||
|
FaApple,
|
||||||
|
FaCubes,
|
||||||
|
FaDocker,
|
||||||
|
FaHdd,
|
||||||
|
FaLinux,
|
||||||
|
FaWindows
|
||||||
|
} from "react-icons/fa";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||||
|
|
||||||
export type CommandItem = string | { title: string; command: string };
|
export type CommandItem = string | { title: string; command: string };
|
||||||
@@ -20,6 +28,7 @@ const PLATFORMS = [
|
|||||||
"macos",
|
"macos",
|
||||||
"docker",
|
"docker",
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
|
"advantech",
|
||||||
"podman",
|
"podman",
|
||||||
"nixos",
|
"nixos",
|
||||||
"windows"
|
"windows"
|
||||||
@@ -43,11 +52,15 @@ export function NewtSiteInstallCommands({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [acceptClients, setAcceptClients] = useState(true);
|
const [acceptClients, setAcceptClients] = useState(true);
|
||||||
|
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true);
|
||||||
const [platform, setPlatform] = useState<Platform>("linux");
|
const [platform, setPlatform] = useState<Platform>("linux");
|
||||||
const [architecture, setArchitecture] = useState(
|
const [architecture, setArchitecture] = useState(
|
||||||
() => getArchitectures(platform)[0]
|
() => getArchitectures(platform)[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showSiteConfiguration = platform !== "advantech";
|
||||||
|
const supportsSshOption = platform === "linux" || platform === "nixos";
|
||||||
|
|
||||||
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
|
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
|
||||||
const acceptClientsEnv = !acceptClients
|
const acceptClientsEnv = !acceptClients
|
||||||
? "\n - DISABLE_CLIENTS=true"
|
? "\n - DISABLE_CLIENTS=true"
|
||||||
@@ -57,6 +70,11 @@ export function NewtSiteInstallCommands({
|
|||||||
--set newtInstances[0].acceptClients=true`
|
--set newtInstances[0].acceptClients=true`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const disableSshFlag =
|
||||||
|
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : "";
|
||||||
|
const runAsRootPrefix =
|
||||||
|
supportsSshOption && allowPangolinSsh ? "sudo " : "";
|
||||||
|
|
||||||
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
||||||
linux: {
|
linux: {
|
||||||
Run: [
|
Run: [
|
||||||
@@ -66,7 +84,7 @@ export function NewtSiteInstallCommands({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("run"),
|
title: t("run"),
|
||||||
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Systemd Service": [
|
"Systemd Service": [
|
||||||
@@ -86,6 +104,11 @@ PANGOLIN_ENDPOINT=${endpoint}${
|
|||||||
? `
|
? `
|
||||||
DISABLE_CLIENTS=true`
|
DISABLE_CLIENTS=true`
|
||||||
: ""
|
: ""
|
||||||
|
}${
|
||||||
|
!allowPangolinSsh
|
||||||
|
? `
|
||||||
|
DISABLE_SSH=true`
|
||||||
|
: ""
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
sudo chmod 600 /etc/newt/newt.env`
|
sudo chmod 600 /etc/newt/newt.env`
|
||||||
@@ -180,6 +203,9 @@ sudo systemctl enable --now newt`
|
|||||||
--set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}`
|
--set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}`
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
advantech: {
|
||||||
|
Documentation: []
|
||||||
|
},
|
||||||
podman: {
|
podman: {
|
||||||
"Podman Quadlet": [
|
"Podman Quadlet": [
|
||||||
`[Unit]
|
`[Unit]
|
||||||
@@ -205,7 +231,7 @@ WantedBy=default.target`
|
|||||||
},
|
},
|
||||||
nixos: {
|
nixos: {
|
||||||
Flake: [
|
Flake: [
|
||||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -257,45 +283,87 @@ WantedBy=default.target`
|
|||||||
className="mt-4"
|
className="mt-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="pt-4">
|
{showSiteConfiguration && (
|
||||||
<p className="font-semibold mb-3">
|
<div className="pt-4">
|
||||||
{t("siteConfiguration")}
|
<p className="font-semibold mb-3">
|
||||||
</p>
|
{t("siteConfiguration")}
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
</p>
|
||||||
<CheckboxWithLabel
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
id="acceptClients"
|
<CheckboxWithLabel
|
||||||
aria-describedby="acceptClients-desc"
|
id="acceptClients"
|
||||||
checked={acceptClients}
|
aria-describedby="acceptClients-desc"
|
||||||
onCheckedChange={(checked) => {
|
checked={acceptClients}
|
||||||
const value = checked as boolean;
|
onCheckedChange={(checked) => {
|
||||||
setAcceptClients(value);
|
const value = checked as boolean;
|
||||||
}}
|
setAcceptClients(value);
|
||||||
label={t("siteAcceptClientConnections")}
|
}}
|
||||||
/>
|
label={t("siteAcceptClientConnections")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
id="acceptClients-desc"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("siteAcceptClientConnectionsDescription")}
|
||||||
|
</p>
|
||||||
|
{supportsSshOption && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2 mb-2 mt-2">
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="allowPangolinSsh"
|
||||||
|
checked={allowPangolinSsh}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const value = checked as boolean;
|
||||||
|
setAllowPangolinSsh(value);
|
||||||
|
}}
|
||||||
|
label="Allow Pangolin SSH"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
id="allowPangolinSsh-desc"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("sitePangolinSshDescription")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
)}
|
||||||
id="acceptClients-desc"
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("siteAcceptClientConnectionsDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<p className="font-semibold mb-3">{t("commands")}</p>
|
<p className="font-semibold mb-3">{t("commands")}</p>
|
||||||
{platform === "kubernetes" && (
|
{platform === "kubernetes" && (
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
For more and up to date Kubernetes installation
|
{t.rich("siteInstallKubernetesDocsDescription", {
|
||||||
information, see{" "}
|
docsLink: (chunks) => (
|
||||||
<a
|
<a
|
||||||
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
|
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline"
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
docs.pangolin.net/manage/sites/install-kubernetes
|
{chunks}
|
||||||
</a>
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
.
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{platform === "advantech" && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
{t.rich("siteInstallAdvantechDocsDescription", {
|
||||||
|
docsLink: (chunks) => (
|
||||||
|
<a
|
||||||
|
href="https://docs.pangolin.net/manage/sites/install-advantech"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
@@ -342,6 +410,8 @@ function getPlatformIcon(platformName: Platform) {
|
|||||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||||
case "kubernetes":
|
case "kubernetes":
|
||||||
return <SiKubernetes className="h-4 w-4 mr-2" />;
|
return <SiKubernetes className="h-4 w-4 mr-2" />;
|
||||||
|
case "advantech":
|
||||||
|
return <FaHdd className="h-4 w-4 mr-2" />;
|
||||||
case "podman":
|
case "podman":
|
||||||
return <FaCubes className="h-4 w-4 mr-2" />;
|
return <FaCubes className="h-4 w-4 mr-2" />;
|
||||||
case "nixos":
|
case "nixos":
|
||||||
@@ -363,6 +433,8 @@ function getPlatformName(platformName: Platform) {
|
|||||||
return "Docker";
|
return "Docker";
|
||||||
case "kubernetes":
|
case "kubernetes":
|
||||||
return "Kubernetes";
|
return "Kubernetes";
|
||||||
|
case "advantech":
|
||||||
|
return "Advantech";
|
||||||
case "podman":
|
case "podman":
|
||||||
return "Podman";
|
return "Podman";
|
||||||
case "nixos":
|
case "nixos":
|
||||||
@@ -384,6 +456,8 @@ function getArchitectures(platform: Platform) {
|
|||||||
return ["Docker Compose", "Docker Run"];
|
return ["Docker Compose", "Docker Run"];
|
||||||
case "kubernetes":
|
case "kubernetes":
|
||||||
return ["Helm Chart"];
|
return ["Helm Chart"];
|
||||||
|
case "advantech":
|
||||||
|
return ["Documentation"];
|
||||||
case "podman":
|
case "podman":
|
||||||
return ["Podman Quadlet", "Podman Run"];
|
return ["Podman Quadlet", "Podman Run"];
|
||||||
case "nixos":
|
case "nixos":
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ export function CreatePolicyAuthMethodsSectionForm({
|
|||||||
label={t(
|
label={t(
|
||||||
"headerAuthCompatibility"
|
"headerAuthCompatibility"
|
||||||
)}
|
)}
|
||||||
info={t(
|
description={t(
|
||||||
"headerAuthCompatibilityInfo"
|
"headerAuthCompatibilityInfo"
|
||||||
)}
|
)}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
@@ -426,7 +426,10 @@ export function CreatePolicyAuthMethodsSectionForm({
|
|||||||
{/* Password row */}
|
{/* Password row */}
|
||||||
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||||
<div
|
<div
|
||||||
className={cn("flex items-center text-sm space-x-2", password && "text-green-500")}
|
className={cn(
|
||||||
|
"flex items-center text-sm space-x-2",
|
||||||
|
password && "text-green-500"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Key size="14" />
|
<Key size="14" />
|
||||||
<span>
|
<span>
|
||||||
@@ -456,7 +459,10 @@ export function CreatePolicyAuthMethodsSectionForm({
|
|||||||
{/* Pincode row */}
|
{/* Pincode row */}
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
<div
|
<div
|
||||||
className={cn("flex items-center space-x-2 text-sm", pincode && "text-green-500")}
|
className={cn(
|
||||||
|
"flex items-center space-x-2 text-sm",
|
||||||
|
pincode && "text-green-500"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Binary size="14" />
|
<Binary size="14" />
|
||||||
<span>
|
<span>
|
||||||
@@ -484,7 +490,10 @@ export function CreatePolicyAuthMethodsSectionForm({
|
|||||||
{/* Header auth row */}
|
{/* Header auth row */}
|
||||||
<div className="flex items-center justify-between border rounded-md p-2">
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
<div
|
<div
|
||||||
className={cn("flex items-center space-x-2 text-sm", headerAuth && "text-green-500")}
|
className={cn(
|
||||||
|
"flex items-center space-x-2 text-sm",
|
||||||
|
headerAuth && "text-green-500"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Bot size="14" />
|
<Bot size="14" />
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ export function EditPolicyAuthMethodsSectionForm({
|
|||||||
label={t(
|
label={t(
|
||||||
"headerAuthCompatibility"
|
"headerAuthCompatibility"
|
||||||
)}
|
)}
|
||||||
info={t(
|
description={t(
|
||||||
"headerAuthCompatibilityInfo"
|
"headerAuthCompatibilityInfo"
|
||||||
)}
|
)}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ import {
|
|||||||
|
|
||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
|
import {
|
||||||
|
REGIONS,
|
||||||
|
getRegionNameById,
|
||||||
|
isValidRegionId
|
||||||
|
} from "@server/db/regions";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
isValidCIDR,
|
||||||
isValidIP,
|
isValidIP,
|
||||||
@@ -210,6 +215,8 @@ export function EditPolicyRulesSectionForm({
|
|||||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false);
|
||||||
|
const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
const addRuleForm = useForm({
|
||||||
resolver: zodResolver(addRuleSchema),
|
resolver: zodResolver(addRuleSchema),
|
||||||
@@ -235,7 +242,8 @@ export function EditPolicyRulesSectionForm({
|
|||||||
IP: "IP",
|
IP: "IP",
|
||||||
CIDR: t("ipAddressRange"),
|
CIDR: t("ipAddressRange"),
|
||||||
COUNTRY: t("country"),
|
COUNTRY: t("country"),
|
||||||
ASN: "ASN"
|
ASN: "ASN",
|
||||||
|
REGION: t("region")
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
@@ -309,6 +317,14 @@ export function EditPolicyRulesSectionForm({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.match === "REGION" && !isValidRegionId(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("rulesErrorInvalidRegion"),
|
||||||
|
description: t("rulesErrorInvalidRegionDescription") || ""
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let priority = data.priority;
|
let priority = data.priority;
|
||||||
if (priority === undefined) {
|
if (priority === undefined) {
|
||||||
@@ -378,6 +394,8 @@ export function EditPolicyRulesSectionForm({
|
|||||||
return t("rulesMatchCountry");
|
return t("rulesMatchCountry");
|
||||||
case "ASN":
|
case "ASN":
|
||||||
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
return "Enter an Autonomous System Number (e.g., AS15169 or 15169)";
|
||||||
|
case "REGION":
|
||||||
|
return t("rulesMatchRegion");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
@@ -476,7 +494,13 @@ export function EditPolicyRulesSectionForm({
|
|||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
disabled={readonly || row.original.fromPolicy}
|
disabled={readonly || row.original.fromPolicy}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
|
value:
|
||||||
|
| "CIDR"
|
||||||
|
| "IP"
|
||||||
|
| "PATH"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
) =>
|
) =>
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
@@ -485,7 +509,9 @@ export function EditPolicyRulesSectionForm({
|
|||||||
? "US"
|
? "US"
|
||||||
: value === "ASN"
|
: value === "ASN"
|
||||||
? "AS15169"
|
? "AS15169"
|
||||||
: row.original.value
|
: value === "REGION"
|
||||||
|
? "021"
|
||||||
|
: row.original.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -505,6 +531,11 @@ export function EditPolicyRulesSectionForm({
|
|||||||
{RuleMatch.COUNTRY}
|
{RuleMatch.COUNTRY}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isMaxmindAvailable && (
|
||||||
|
<SelectItem value="REGION">
|
||||||
|
{RuleMatch.REGION}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
{isMaxmindAsnAvailable && (
|
{isMaxmindAsnAvailable && (
|
||||||
<SelectItem value="ASN">
|
<SelectItem value="ASN">
|
||||||
{RuleMatch.ASN}
|
{RuleMatch.ASN}
|
||||||
@@ -666,6 +697,111 @@ export function EditPolicyRulesSectionForm({
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : row.original.match === "REGION" ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={
|
||||||
|
readonly || row.original.fromPolicy
|
||||||
|
}
|
||||||
|
className="min-w-50 justify-between"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const regionName = getRegionNameById(
|
||||||
|
row.original.value
|
||||||
|
);
|
||||||
|
if (!regionName) {
|
||||||
|
return t("selectRegion");
|
||||||
|
}
|
||||||
|
return `${t(regionName)} (${row.original.value})`;
|
||||||
|
})()}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="min-w-50 p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t("searchRegions")}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{t("noRegionFound")}
|
||||||
|
</CommandEmpty>
|
||||||
|
{REGIONS.map((continent) => (
|
||||||
|
<CommandGroup
|
||||||
|
key={continent.id}
|
||||||
|
heading={t(continent.name)}
|
||||||
|
>
|
||||||
|
<CommandItem
|
||||||
|
value={continent.id}
|
||||||
|
keywords={[
|
||||||
|
t(continent.name),
|
||||||
|
continent.id
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
updateRule(
|
||||||
|
row.original.ruleId,
|
||||||
|
{
|
||||||
|
value: continent.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
row.original
|
||||||
|
.value ===
|
||||||
|
continent.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(continent.name)} (
|
||||||
|
{continent.id})
|
||||||
|
</CommandItem>
|
||||||
|
{continent.includes.map(
|
||||||
|
(subregion) => (
|
||||||
|
<CommandItem
|
||||||
|
key={subregion.id}
|
||||||
|
value={subregion.id}
|
||||||
|
keywords={[
|
||||||
|
t(
|
||||||
|
subregion.name
|
||||||
|
),
|
||||||
|
subregion.id
|
||||||
|
]}
|
||||||
|
onSelect={() =>
|
||||||
|
updateRule(
|
||||||
|
row.original
|
||||||
|
.ruleId,
|
||||||
|
{
|
||||||
|
value: subregion.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
row.original
|
||||||
|
.value ===
|
||||||
|
subregion.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(subregion.name)}{" "}
|
||||||
|
({subregion.id})
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.value}
|
defaultValue={row.original.value}
|
||||||
@@ -988,6 +1124,13 @@ export function EditPolicyRulesSectionForm({
|
|||||||
}
|
}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
{isMaxmindAvailable && (
|
||||||
|
<SelectItem value="REGION">
|
||||||
|
{
|
||||||
|
RuleMatch.REGION
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
{isMaxmindAsnAvailable && (
|
{isMaxmindAsnAvailable && (
|
||||||
<SelectItem value="ASN">
|
<SelectItem value="ASN">
|
||||||
{RuleMatch.ASN}
|
{RuleMatch.ASN}
|
||||||
@@ -1240,6 +1383,160 @@ export function EditPolicyRulesSectionForm({
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : addRuleForm.watch(
|
||||||
|
"match"
|
||||||
|
) === "REGION" ? (
|
||||||
|
<Popover
|
||||||
|
open={
|
||||||
|
openAddRuleRegionSelect
|
||||||
|
}
|
||||||
|
onOpenChange={
|
||||||
|
setOpenAddRuleRegionSelect
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={
|
||||||
|
readonly ||
|
||||||
|
(!isResourceOverlay &&
|
||||||
|
!rulesEnabled)
|
||||||
|
}
|
||||||
|
aria-expanded={
|
||||||
|
openAddRuleRegionSelect
|
||||||
|
}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? (() => {
|
||||||
|
const regionName =
|
||||||
|
getRegionNameById(
|
||||||
|
field.value
|
||||||
|
);
|
||||||
|
return regionName
|
||||||
|
? `${t(regionName)} (${field.value})`
|
||||||
|
: field.value;
|
||||||
|
})()
|
||||||
|
: t(
|
||||||
|
"selectRegion"
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t(
|
||||||
|
"searchRegions"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{t(
|
||||||
|
"noRegionFound"
|
||||||
|
)}
|
||||||
|
</CommandEmpty>
|
||||||
|
{REGIONS.map(
|
||||||
|
(
|
||||||
|
continent
|
||||||
|
) => (
|
||||||
|
<CommandGroup
|
||||||
|
key={
|
||||||
|
continent.id
|
||||||
|
}
|
||||||
|
heading={t(
|
||||||
|
continent.name
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CommandItem
|
||||||
|
value={
|
||||||
|
continent.id
|
||||||
|
}
|
||||||
|
keywords={[
|
||||||
|
t(
|
||||||
|
continent.name
|
||||||
|
),
|
||||||
|
continent.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(
|
||||||
|
continent.id
|
||||||
|
);
|
||||||
|
setOpenAddRuleRegionSelect(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
field.value ===
|
||||||
|
continent.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
continent.name
|
||||||
|
)}{" "}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
continent.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</CommandItem>
|
||||||
|
{continent.includes.map(
|
||||||
|
(
|
||||||
|
subregion
|
||||||
|
) => (
|
||||||
|
<CommandItem
|
||||||
|
key={
|
||||||
|
subregion.id
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
subregion.id
|
||||||
|
}
|
||||||
|
keywords={[
|
||||||
|
t(
|
||||||
|
subregion.name
|
||||||
|
),
|
||||||
|
subregion.id
|
||||||
|
]}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(
|
||||||
|
subregion.id
|
||||||
|
);
|
||||||
|
setOpenAddRuleRegionSelect(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
field.value ===
|
||||||
|
subregion.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{t(
|
||||||
|
subregion.name
|
||||||
|
)}{" "}
|
||||||
|
(
|
||||||
|
{
|
||||||
|
subregion.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ export function PolicyAuthMethodsSection({
|
|||||||
label={t(
|
label={t(
|
||||||
"headerAuthCompatibility"
|
"headerAuthCompatibility"
|
||||||
)}
|
)}
|
||||||
info={t(
|
description={t(
|
||||||
"headerAuthCompatibilityInfo"
|
"headerAuthCompatibilityInfo"
|
||||||
)}
|
)}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card px-6 pt-6 pb-4 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card px-6 pt-6 pb-4 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export async function generateBrowserGatewayMetadata(
|
||||||
|
protocol: "SSH" | "RDP" | "VNC"
|
||||||
|
): Promise<Metadata> {
|
||||||
|
const { target } = await getBrowserTargetForRequest();
|
||||||
|
return {
|
||||||
|
title: target?.name
|
||||||
|
? `${protocol} - ${target.name}`
|
||||||
|
: `${protocol} - Pangolin`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
export const getBrowserTargetForRequest = cache(async () => {
|
||||||
|
const headersList = await headers();
|
||||||
|
const host = headersList.get("host") || "";
|
||||||
|
const hostname = host.split(":")[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
|
||||||
|
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
|
||||||
|
);
|
||||||
|
return { target: res.data.data };
|
||||||
|
} catch {
|
||||||
|
return { target: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { priv } from "@app/lib/api";
|
||||||
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
export async function loadOrgLoginPageBranding(orgId: string): Promise<{
|
||||||
|
primaryColor: string | null;
|
||||||
|
}> {
|
||||||
|
if (build === "oss") {
|
||||||
|
return { primaryColor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribed = await isOrgSubscribed(orgId);
|
||||||
|
if (!subscribed) {
|
||||||
|
return { primaryColor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await priv.get<
|
||||||
|
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||||
|
>(`/login-page-branding?orgId=${orgId}`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
return { primaryColor: res.data.data.primaryColor ?? null };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return { primaryColor: null };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user