Compare commits

..

39 Commits

Author SHA1 Message Date
Owen
d1af7a153f Enforece some more things on the types 2026-06-05 16:57:53 -07:00
Owen
13efa47db7 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-05 16:12:46 -07:00
Owen
69bd61c308 Update migrations 2026-06-05 16:02:28 -07:00
Owen
7b7ff51289 Add target mode and auth token 2026-06-05 15:37:21 -07:00
Owen
772ac8af73 Remove browser gateway targets for regular targets 2026-06-05 15:30:42 -07:00
miloschwartz
8ee520dbb5 form validation improvements 2026-06-05 14:55:27 -07:00
Owen
8e5d9e94a9 Fix delete site only working on newt site 2026-06-05 14:37:44 -07:00
Owen
c9cb28af45 Rename to public-policies 2026-06-05 14:30:36 -07:00
miloschwartz
ea8eaf9736 adjust targets input styles 2026-06-05 12:43:00 -07:00
miloschwartz
b78db3daef move policies routes 2026-06-05 12:43:00 -07:00
Owen
7cf3f8df92 Rename proxy -> public 2026-06-05 12:12:27 -07:00
Owen
f2b5cff3f9 Fix resource protection status showing wrong 2026-06-05 12:12:27 -07:00
Owen
6de9ab8f05 Add watermark on missing login pages 2026-06-05 12:12:27 -07:00
Owen
ad0e800d8d Fix validation error and bring alias back to table 2026-06-05 12:12:27 -07:00
miloschwartz
65470fb64b adjust styles 2026-06-05 12:04:33 -07:00
miloschwartz
f23142336b enable editing resource policy niceID 2026-06-05 11:57:55 -07:00
miloschwartz
2da4987cd3 rename policies 2026-06-05 11:52:51 -07:00
miloschwartz
253ba554a2 fix resources cell styling 2026-06-05 11:46:30 -07:00
miloschwartz
add9b8dfb0 many minor visual improvements 2026-06-04 22:25:18 -07:00
Owen
2adb7b64cb Add the resource name 2026-06-04 22:10:38 -07:00
Owen
84fef5f1d6 Resource policy api backward compatability 2026-06-04 22:02:42 -07:00
Owen
def1e9c851 Add region back to the rules 2026-06-04 21:28:29 -07:00
Owen
67b08ca61e Properly do disable enterprise features this time 2026-06-04 21:18:04 -07:00
Owen
614df75880 Add policy to blueprints 2026-06-04 21:18:04 -07:00
Owen
676cf37ee2 Make sure things are paywalled in the blueprints 2026-06-04 21:18:04 -07:00
Owen
6b96e3dce6 Make sure the disableEnterpriseFeatures works 2026-06-04 21:18:04 -07:00
miloschwartz
b67037e2ea use default animation speed on dialog 2026-06-04 18:19:23 -07:00
miloschwartz
5a5b77cf62 update project cursor rules 2026-06-04 18:13:26 -07:00
miloschwartz
d2793dfad7 use react forms 2026-06-04 18:07:50 -07:00
miloschwartz
ff507f1275 use standard error alert 2026-06-04 17:44:45 -07:00
miloschwartz
6b04bcb383 translate strings in auth pages for ssh, vnc, and rdp 2026-06-04 17:35:43 -07:00
miloschwartz
b2f1115ef8 standardize and fix branding on new resources auth pages 2026-06-04 17:24:06 -07:00
Owen
567ef23ac4 Add initial advantech install commands 2026-06-04 16:59:58 -07:00
Owen
6affebc666 Finish adding ssh toggle 2026-06-04 16:59:58 -07:00
miloschwartz
889f78ddb8 use resource name in ssh/rdp/vnc page meta 2026-06-04 16:45:35 -07:00
Owen
9d3f96cf83 Add disable_private_http_placeholder 2026-06-04 16:41:07 -07:00
miloschwartz
e5d0673bbf prefill site field on create private resource when filtering sites 2026-06-04 16:30:14 -07:00
miloschwartz
0907c0346f remove check on oidc login 2026-06-04 16:24:29 -07:00
Owen
6420a90d08 Replace tab component 2026-06-04 16:21:30 -07:00
125 changed files with 6101 additions and 4302 deletions

View File

@@ -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.

View File

@@ -0,0 +1,7 @@
---
alwaysApply: true
---
When writing TypeScript:
Prefer to use types instead of interfaces.

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating forms, use React form for validation and use Zod schemas.

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Моля, изберете ресурс",
"proxyResourceTitle": "Управление на обществени ресурси",
"proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.",
"proxyResourcesBannerTitle": "Публичен достъп чрез уеб.",
"proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.",
"publicResourcesBannerTitle": "Публичен достъп чрез уеб.",
"publicResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.",
"clientResourceTitle": "Управление на частни ресурси",
"clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.",
"privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Zvolte prosím zdroj",
"proxyResourceTitle": "Spravovat veřejné zdroje",
"proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče",
"proxyResourcesBannerTitle": "Veřejný přístup založený na webu",
"proxyResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
"publicResourcesBannerTitle": "Veřejný přístup založený na webu",
"publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
"clientResourceTitle": "Spravovat soukromé zdroje",
"clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta",
"privateResourcesBannerTitle": "Zero-Trust soukromý přístup",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Bitte wählen Sie eine Ressource",
"proxyResourceTitle": "Öffentliche Ressourcen verwalten",
"proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind",
"proxyResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
"proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
"publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
"publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
"clientResourceTitle": "Private Ressourcen verwalten",
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",

View File

@@ -101,6 +101,8 @@
"sitesTableViewPrivateResources": "View Private Resources",
"siteInstallNewt": "Install Site",
"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",
"WgConfigurationDescription": "Use the following configuration to connect to the network",
"operatingSystem": "Operating System",
@@ -200,8 +202,8 @@
"shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
"proxyResourcesBannerTitle": "Web-based Public Access",
"proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
"publicResourcesBannerTitle": "Web-based Public Access",
"publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
"clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
@@ -209,15 +211,16 @@
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
"resourcePoliciesTitle": "Manage Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
"resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)",
"resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# resources}}",
"resourcePoliciesAttachedResourcesEmpty": "no resources",
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your public resources",
"resourcePoliciesSearch": "Search policies...",
"resourcePoliciesAdd": "Add Policy",
"resourcePoliciesDefaultBadgeText": "Default policy",
"resourcePoliciesCreate": "Create Resource Policy",
"resourcePoliciesCreate": "Create Public Resource Policy",
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
"resourcePolicyName": "Policy Name",
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
@@ -311,7 +314,7 @@
"rules": "Rules",
"resourceSettingDescription": "Configure the settings on the resource",
"resourceSetting": "{resourceName} Settings",
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
"resourcePolicySettingDescription": "Configure the settings on this public resource policy",
"resourcePolicySetting": "{policyName} Settings",
"alwaysAllow": "Bypass Auth",
"alwaysDeny": "Block Access",
@@ -1220,8 +1223,10 @@
"addLabels": "Add labels",
"siteLabelsTab": "Labels",
"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",
"labelSearchOrCreate": "Search or create a label",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
@@ -1462,7 +1467,7 @@
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
"sidebarPolicies": "Policies",
"sidebarResourcePolicies": "Resources",
"sidebarResourcePolicies": "Public Resources",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team",
@@ -1470,7 +1475,7 @@
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Links",
"sidebarShareableLinks": "Share Links",
"sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
@@ -1647,7 +1652,7 @@
"standaloneHcFilterResourceIdFallback": "Resource {id}",
"blueprints": "Blueprints",
"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",
"blueprintGoBack": "See all Blueprints",
"blueprintCreate": "Create Blueprint",
@@ -2027,13 +2032,13 @@
"healthCheckUnknown": "Unknown",
"healthCheck": "Health Check",
"configureHealthCheck": "Configure Health Check",
"configureHealthCheckDescription": "Set up health monitoring for {target}",
"configureHealthCheckDescription": "Set up monitoring for your resource to ensure it is always available",
"enableHealthChecks": "Enable Health Checks",
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
"healthScheme": "Method",
"healthSelectScheme": "Select Method",
"healthCheckPortInvalid": "Health check port must be between 1 and 65535",
"healthCheckPortInvalid": "Port must be between 1 and 65535",
"healthCheckPath": "Path",
"healthHostname": "IP / Host",
"healthPort": "Port",
@@ -2073,8 +2078,13 @@
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
"sshDaemonPort": "Daemon Port",
"sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"sshServerDestinationDescription": "Configure the destination of the SSH server",
"destination": "Destination",
"destinationRequired": "Destination is required.",
"domainRequired": "Domain is required.",
"proxyPortRequired": "Port is required.",
"invalidPathConfiguration": "Invalid path configuration.",
"invalidRewritePathConfiguration": "Invalid rewrite path configuration.",
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
"roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow",
@@ -3455,5 +3465,43 @@
"sshErrorNoTarget": "No target specified",
"sshErrorWebSocket": "WebSocket connection 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"
}

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Por favor, seleccione un recurso",
"proxyResourceTitle": "Administrar recursos públicos",
"proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web",
"proxyResourcesBannerTitle": "Acceso público basado en web",
"proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
"publicResourcesBannerTitle": "Acceso público basado en web",
"publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
"clientResourceTitle": "Administrar recursos privados",
"clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado",
"privateResourcesBannerTitle": "Acceso privado de confianza cero",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Veuillez sélectionner une ressource",
"proxyResourceTitle": "Gérer les ressources publiques",
"proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web",
"proxyResourcesBannerTitle": "Accès public basé sur le Web",
"proxyResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.",
"publicResourcesBannerTitle": "Accès public basé sur le Web",
"publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.",
"clientResourceTitle": "Gérer les ressources privées",
"clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté",
"privateResourcesBannerTitle": "Accès privé sans confiance",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Seleziona una risorsa",
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
"proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
"proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
"publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
"publicResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
"clientResourceTitle": "Gestisci Risorse Private",
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "리소스를 선택하세요",
"proxyResourceTitle": "공개 리소스 관리",
"proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.",
"proxyResourcesBannerTitle": "웹 기반 공공 접근",
"proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
"publicResourcesBannerTitle": "웹 기반 공공 접근",
"publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
"clientResourceTitle": "개인 리소스 관리",
"clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.",
"privateResourcesBannerTitle": "제로 트러스트 개인 접근",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Vennligst velg en ressurs",
"proxyResourceTitle": "Administrere offentlige ressurser",
"proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser",
"proxyResourcesBannerTitle": "Nettbasert offentlig tilgang",
"proxyResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.",
"publicResourcesBannerTitle": "Nettbasert offentlig tilgang",
"publicResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.",
"clientResourceTitle": "Administrer private ressurser",
"clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient",
"privateResourcesBannerTitle": "Zero-Trust privat tilgang",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Selecteer een bron",
"proxyResourceTitle": "Openbare bronnen beheren",
"proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser",
"proxyResourcesBannerTitle": "Webgebaseerde openbare toegang",
"proxyResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv<69><76>bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.",
"publicResourcesBannerTitle": "Webgebaseerde openbare toegang",
"publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv<69><76>bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.",
"clientResourceTitle": "Privébronnen beheren",
"clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Wybierz zasób",
"proxyResourceTitle": "Zarządzaj zasobami publicznymi",
"proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej",
"proxyResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web",
"proxyResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
"publicResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web",
"publicResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
"clientResourceTitle": "Zarządzaj zasobami prywatnymi",
"clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta",
"privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Por favor, selecione um recurso",
"proxyResourceTitle": "Gerenciar Recursos Públicos",
"proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web",
"proxyResourcesBannerTitle": "Acesso Público via Web",
"proxyResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
"publicResourcesBannerTitle": "Acesso Público via Web",
"publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
"clientResourceTitle": "Gerenciar recursos privados",
"clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado",
"privateResourcesBannerTitle": "Acesso Privado com Confiança Zero",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Пожалуйста, выберите ресурс",
"proxyResourceTitle": "Управление публичными ресурсами",
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
"proxyResourcesBannerTitle": "Общедоступный доступ через веб",
"proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
"publicResourcesBannerTitle": "Общедоступный доступ через веб",
"publicResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
"clientResourceTitle": "Управление приватными ресурсами",
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "Lütfen bir kaynak seçin",
"proxyResourceTitle": "Herkese Açık Kaynakları Yönet",
"proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin",
"proxyResourcesBannerTitle": "Web Tabanlı Genel Erişim",
"proxyResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
"publicResourcesBannerTitle": "Web Tabanlı Genel Erişim",
"publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
"clientResourceTitle": "Özel Kaynakları Yönet",
"clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin",
"privateResourcesBannerTitle": "Sıfır Güven Özel Erişim",

View File

@@ -200,8 +200,8 @@
"shareErrorSelectResource": "请选择一个资源",
"proxyResourceTitle": "管理公共资源",
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
"proxyResourcesBannerTitle": "基于Web的公共访问",
"proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同它们不需要客户端软件并且可以包含身份和上下文感知访问策略。",
"publicResourcesBannerTitle": "基于Web的公共访问",
"publicResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同它们不需要客户端软件并且可以包含身份和上下文感知访问策略。",
"clientResourceTitle": "管理私有资源",
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
"privateResourcesBannerTitle": "零信任的私人访问",

View File

@@ -152,8 +152,8 @@
"shareErrorSelectResource": "請選擇一個資源",
"proxyResourceTitle": "管理公開資源",
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
"proxyResourcesBannerTitle": "基於網頁的公開存取",
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"publicResourcesBannerTitle": "基於網頁的公開存取",
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"clientResourceTitle": "管理私有資源",
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
"privateResourcesBannerTitle": "零信任私有存取",

View File

@@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: varchar("authToken").notNull(),
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
destination: varchar("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -290,7 +290,12 @@ export const targets = pgTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").notNull().default(100),
mode: varchar("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: varchar("authToken")
});
export const targetHealthCheck = pgTable("targetHealthCheck", {
@@ -886,7 +891,9 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").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()
});

View File

@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: text("authToken").notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").notNull().default(100),
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
});
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
@@ -1248,7 +1253,9 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").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()
});

View File

@@ -20,6 +20,7 @@ import {
ClientResourcesResults,
updateClientResources
} from "./clientResources";
import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { generateName } from "@server/db/names";
@@ -56,6 +57,8 @@ export async function applyBlueprint({
let proxyResourcesResults: ProxyResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);
proxyResourcesResults = await updateProxyResources(
orgId,
config,

View File

@@ -23,6 +23,8 @@ import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
@@ -114,6 +116,30 @@ export async function updateClientResources(
for (const [resourceNiceId, resourceData] of Object.entries(
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
.select()
.from(siteResources)
@@ -366,7 +392,9 @@ export async function updateClientResources(
}))
);
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);
@@ -510,7 +538,9 @@ export async function updateClientResources(
}))
);
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);

View File

@@ -47,6 +47,10 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { build } from "@server/build";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
import serverConfig from "@server/lib/config";
export type ProxyResourcesResults = {
proxyResource: Resource;
@@ -79,7 +83,7 @@ export async function updateProxyResources(
if (targetSiteId) {
// Look up site by niceId
[site] = await trx
.select({ siteId: sites.siteId })
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(
@@ -91,7 +95,7 @@ export async function updateProxyResources(
} else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[site] = await trx
.select({ siteId: sites.siteId })
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
@@ -118,6 +122,15 @@ export async function updateProxyResources(
internalPortToCreate = targetData["internal-port"];
}
let authToken: string | undefined;
if (site.type !== "local") {
const plainToken = generateId(48);
authToken = encrypt(
plainToken,
serverConfig.getRawConfig().server.secret!
);
}
// Create target
const [newTarget] = await trx
.insert(targets)
@@ -125,10 +138,12 @@ export async function updateProxyResources(
resourceId: resourceId,
siteId: site.siteId,
ip: targetData.hostname,
mode: resourceData.mode as Target["mode"],
method: targetData.method,
port: targetData.port,
enabled: targetData.enabled,
internalPort: internalPortToCreate,
authToken: authToken,
path: targetData.path,
pathMatchType: targetData["path-match"],
rewritePath:
@@ -222,17 +237,59 @@ export async function updateProxyResources(
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) {
let domain;
if (
["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(
existingResource.resourceId,
resourceData["full-domain"]!,
orgId,
trx
);
await enforceDomainNamespacePaywall(
orgId,
domain.domainId,
trx
);
}
// check if the only key in the resource is targets, if so, skip the update
@@ -522,6 +579,13 @@ export async function updateProxyResources(
? (resourceData["proxy-protocol-version"] ??
1)
: 1,
pamMode:
resourceData["auth-daemon"]?.pam ||
"passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort:
resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId
})
@@ -664,7 +728,8 @@ export async function updateProxyResources(
? "/"
: undefined),
rewritePathType: targetData["rewrite-match"],
priority: targetData.priority
priority: targetData.priority,
mode: resourceData.mode
})
.where(eq(targets.targetId, existingTarget.targetId))
.returning();
@@ -906,12 +971,30 @@ export async function updateProxyResources(
if (
["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(
undefined,
resourceData["full-domain"]!,
orgId,
trx
);
await enforceDomainNamespacePaywall(
orgId,
domain.domainId,
trx
);
}
const isLicensed = await isLicensedOrSubscribed(
@@ -1866,6 +1949,37 @@ function checkIfTargetChanged(
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(
resourceId: number | undefined,
fullDomain: string,

View File

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

View File

@@ -83,7 +83,8 @@ export const RuleSchema = z
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.coerce.string(),
priority: z.int().optional()
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
})
.refine(
(rule) => {
@@ -267,8 +268,37 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is http, it must have a full-domain
if ((resource.mode ?? resource.protocol) === "http") {
const effectiveProtocol = resource.mode ?? resource.protocol;
if (effectiveProtocol !== "ssh") {
return true;
}
const authDaemonMode = resource["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
return (
resource.targets.filter((target) => target != null).length <= 1
);
},
{
path: ["targets"],
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
const effectiveProtocol = resource.mode ?? resource.protocol;
if (
effectiveProtocol !== undefined &&
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
) {
return (
resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0
@@ -278,7 +308,7 @@ export const PublicResourceSchema = z
},
{
path: ["full-domain"],
error: "When protocol is 'http', a 'full-domain' must be provided"
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
}
)
.refine(
@@ -505,7 +535,90 @@ export const PrivateResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
);
)
.refine(
(data) => {
if (data.mode !== "ssh") {
return true;
}
const authDaemonMode = data["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
const uniqueSites = new Set<string>();
if (data.site) {
uniqueSites.add(data.site);
}
for (const site of data.sites) {
uniqueSites.add(site);
}
return uniqueSites.size <= 1;
},
{
path: ["sites"],
message:
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
}
)
.transform((data) => {
if (
data.mode === "ssh" &&
data.destination !== undefined &&
data["destination-port"] === undefined
) {
data["destination-port"] = 22;
}
return data;
});
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
export const ConfigSchema = z
@@ -526,6 +639,10 @@ export const ConfigSchema = z
.record(z.string(), PrivateResourceSchema)
.optional()
.prefault({}),
"public-policies": z
.record(z.string(), ResourcePolicySchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
})
.transform((data) => {
@@ -556,6 +673,10 @@ export const ConfigSchema = z
string,
z.infer<typeof PrivateResourceSchema>
>;
"public-policies": Record<
string,
z.infer<typeof ResourcePolicySchema>
>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
})
@@ -695,3 +816,4 @@ export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;

View File

@@ -109,7 +109,11 @@ export const privateConfigSchema = z
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
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()
.prefault({}),

View File

@@ -12,7 +12,6 @@
*/
import {
browserGatewayTarget,
certificates,
db,
domainNamespaces,
@@ -182,6 +181,9 @@ export async function getTraefikConfig(
const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => {
if (!["http", "tcp", "udp"].includes(row.mode)) {
return;
}
const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
@@ -295,13 +297,12 @@ export async function getTraefikConfig(
maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null;
targets: {
browserGatewayTargetId: number;
targetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
siteExitNodeId: number | null;
}[];
};
const browserGatewayResourcesMap = new Map<
@@ -310,66 +311,10 @@ export async function getTraefikConfig(
>();
if (allowBrowserGatewayResources) {
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Maintenance fields
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Browser gateway target fields
browserGatewayTargetId:
browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
for (const row of browserGatewayRows) {
for (const row of resourcesWithTargetsAndSites) {
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
return;
}
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
@@ -394,13 +339,12 @@ export async function getTraefikConfig(
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
targetId: row.targetId,
bgType: row.mode,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
subnet: row.subnet
});
}
}
@@ -410,7 +354,11 @@ export async function getTraefikConfig(
fullDomain: string | null;
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
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
siteResourcesWithFullDomain = await db

View File

@@ -1,187 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive(),
type: z.enum(["ssh", "rdp", "vnc"]),
destination: z.string().nonempty(),
destinationPort: z.number().int().min(1).max(65535)
});
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "put",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
description: "Create a browser gateway target for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found in organization ${orgId}`
)
);
}
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
const [record] = await db
.insert(browserGatewayTarget)
.values({
resourceId,
siteId,
type,
destination,
destinationPort,
authToken: encryptedToken
})
.returning();
if (site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[record],
newt.version
);
}
}
logger.info(
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
);
return response<CreateBrowserGatewayTargetResponse>(res, {
data: record,
success: true,
error: false,
message: "Browser gateway target created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create browser gateway target"
)
);
}
}

View File

@@ -1,130 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db, newts, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Delete a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
await db
.delete(browserGatewayTarget)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
);
if (existing.site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.bgt.siteId))
.limit(1);
if (newt) {
await removeBrowserGatewayTarget(
newt.newtId,
browserGatewayTargetId,
newt.version
);
}
}
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
return response(res, {
data: null,
success: true,
error: false,
message: "Browser gateway target deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete browser gateway target"
)
);
}
}

View File

@@ -1,109 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "get",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Get a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function getBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [result] = await db
.select({ bgt: browserGatewayTarget })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!result) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
return response<GetBrowserGatewayTargetResponse>(res, {
data: result.bgt,
success: true,
error: false,
message: "Browser gateway target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve browser gateway target"
)
);
}
}

View File

@@ -13,9 +13,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import { db, resources, targets } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -51,31 +50,30 @@ export async function getBrowserTarget(
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
const [row] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken,
ip: targets.ip,
port: targets.port,
authToken: targets.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
name: resources.name,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(
and(
eq(resources.fullDomain, fullDomain),
eq(targets.enabled, true),
inArray(targets.mode, ["ssh", "rdp", "vnc"])
)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptedAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
if (!row) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -84,16 +82,21 @@ export async function getBrowserTarget(
);
}
const decryptedAuthToken = row.authToken
? decrypt(row.authToken, config.getRawConfig().server.secret!)
: "";
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
ip: row.ip,
port: row.port,
authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
pamMode: row.pamMode,
authDaemonMode: row.authDaemonMode,
orgId: row.orgId,
resourceId: row.resourceId,
niceId: row.niceId,
name: row.name ?? ""
},
success: true,
error: false,

View File

@@ -11,9 +11,4 @@
* This file is not licensed under the AGPLv3.
*/
export * from "./createBrowserGatewayTarget";
export * from "./updateBrowserGatewayTarget";
export * from "./deleteBrowserGatewayTarget";
export * from "./getBrowserGatewayTarget";
export * from "./listBrowserGatewayTargets";
export * from "./getBrowserTarget";

View File

@@ -1,159 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const querySchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export type ListBrowserGatewayTargetsResponse = {
targets: BrowserGatewayTarget[];
total: number;
limit: number;
offset: number;
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
description: "List browser gateway targets for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function listBrowserGatewayTargets(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const rows = await db
.select({
browserGatewayTargetId:
browserGatewayTarget.browserGatewayTargetId,
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
authToken: browserGatewayTarget.authToken,
type: browserGatewayTarget.type,
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
siteName: sites.name
})
.from(browserGatewayTarget)
.leftJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(eq(browserGatewayTarget.resourceId, resourceId))
.limit(limit)
.offset(offset);
return response<ListBrowserGatewayTargetsResponse>(res, {
data: {
targets: rows as any,
total: rows.length,
limit,
offset
},
success: true,
error: false,
message: "Browser gateway targets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to list browser gateway targets"
)
);
}
}

View File

@@ -1,180 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive().optional(),
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
destination: z.string().nonempty().optional(),
destinationPort: z.number().int().min(1).max(65535).optional()
});
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "post",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Update a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
const updateValues: Partial<BrowserGatewayTarget> = {};
if (siteId !== undefined) updateValues.siteId = siteId;
if (type !== undefined) updateValues.type = type;
if (destination !== undefined) updateValues.destination = destination;
if (destinationPort !== undefined)
updateValues.destinationPort = destinationPort;
const [updated] = await db
.update(browserGatewayTarget)
.set(updateValues)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
)
.returning();
const targetSiteId = siteId ?? existing.bgt.siteId;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, targetSiteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, targetSiteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[updated],
newt.version
);
}
}
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
return response<UpdateBrowserGatewayTargetResponse>(res, {
data: updated,
success: true,
error: false,
message: "Browser gateway target updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update browser gateway target"
)
);
}
}

View File

@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource";
@@ -879,48 +878,3 @@ authenticated.post(
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import {
verifyApiKeyHasAction,
@@ -216,43 +215,3 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole
);
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp";
import * as billing from "#private/routers/billing";
import * as license from "#private/routers/license";
import * as resource from "#private/routers/resource";
import * as browserTarget from "#private/routers/browserGatewayTarget";
import * as ssh from "#private/routers/ssh";
import * as ws from "@server/routers/ws";
import * as browserTarget from "#private/routers/browserGatewayTarget";
import {
verifySessionUserMiddleware,

View File

@@ -216,6 +216,7 @@ export async function listResourcePolicies(
: await db
.select({
resourceId: resources.resourceId,
niceId: resources.niceId,
name: resources.name,
fullDomain: resources.fullDomain,
resourcePolicyId: resources.resourcePolicyId

View File

@@ -30,8 +30,7 @@ import {
userOrgs,
sites,
Resource,
SiteResource,
browserGatewayTarget
SiteResource
} from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
@@ -291,16 +290,15 @@ export async function signSshKey(
const publicResource = resource as Resource;
const targetRows = await db
.select({
siteId: browserGatewayTarget.siteId,
ip: browserGatewayTarget.destination
siteId: targets.siteId,
ip: targets.ip
})
.from(browserGatewayTarget)
.from(targets)
.where(
and(
eq(
browserGatewayTarget.resourceId,
publicResource.resourceId
)
eq(targets.resourceId, publicResource.resourceId),
eq(targets.enabled, true),
eq(targets.mode, "ssh")
)
);

View File

@@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = {
orgId: string;
resourceId: number;
niceId: string;
name: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
};

View File

@@ -332,17 +332,6 @@ export async function validateOidcCallback(
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
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 {
allOrgs = await db.select().from(orgs);
}

View File

@@ -1,6 +1,4 @@
import {
browserGatewayTarget,
BrowserGatewayTarget,
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
@@ -16,7 +14,7 @@ import {
} from "@server/db";
import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import { decrypt } from "@server/lib/crypto";
import {
@@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient(
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
.where(
and(
eq(targets.siteId, siteId),
eq(targets.enabled, true),
inArray(targets.mode, ["http", "udp", "tcp"])
)
);
const allHealthChecks = await db
.select({
@@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient(
.from(targetHealthCheck)
.where(eq(targetHealthCheck.siteId, siteId));
// Get all enabled targets with their resource mode information
const allBrowserGatewayTargets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
.select({
resourceId: targets.resourceId,
targetId: targets.targetId,
ip: targets.ip,
method: targets.method,
port: targets.port,
enabled: targets.enabled,
mode: resources.mode,
authToken: targets.authToken
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(
and(
eq(targets.siteId, siteId),
eq(targets.enabled, true),
inArray(targets.mode, ["ssh", "rdp", "vnc"])
)
);
const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => {
@@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient(
const serverSecret = config.getRawConfig().server.secret!;
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
if (!t.ip || !t.port || !t.authToken) {
return null;
}
const decryptAuthToken = decrypt(t.authToken, serverSecret);
return {
id: t.browserGatewayTargetId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
id: t.targetId,
type: t.mode,
destination: t.ip,
destinationPort: t.port,
authToken: decryptAuthToken
};
});

View File

@@ -1,4 +1,4 @@
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
import { Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks";
@@ -244,23 +244,27 @@ export async function removeTargets(
export async function sendBrowserGatewayTargets(
newtId: string,
targets: BrowserGatewayTarget[],
targets: Target[],
version?: string | null
) {
if (targets.length === 0) return;
const payload = targets.map((t) => {
// filter out the ones without auth tokens
const filteredTargets = targets.filter((t) => t.authToken);
if (filteredTargets.length === 0) return;
const payload = filteredTargets.map((t) => {
const decryptAuthToken = decrypt(
t.authToken,
t.authToken!,
config.getRawConfig().server.secret!
);
return {
id: t.browserGatewayTargetId,
id: t.targetId,
resourceId: t.resourceId,
siteId: t.siteId,
type: t.type,
destination: t.destination,
destinationPort: t.destinationPort,
type: t.mode,
destination: t.ip,
destinationPort: t.port,
authToken: decryptAuthToken
};
});

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -131,31 +131,64 @@ export async function addRoleToResource(
);
}
// Check if role already exists in resource
const existingEntry = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Role already assigned to resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// Check if role already exists in the inline policy
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, {
data: {},
success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { userResources } from "@server/db";
import { userResources, userPolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -103,31 +103,64 @@ export async function addUserToResource(
);
}
// Check if user already exists in resource
const existingEntry = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"User already assigned to resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// Check if user already exists in the inline policy
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, {
data: {},
success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db";
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
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
const [newRule] = await db
.insert(resourceRules)

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db";
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -59,6 +59,48 @@ export async function deleteResourceRule(
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
const [deletedRule] = await db
.delete(resourceRules)

View File

@@ -1,7 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -23,6 +27,15 @@ async function queryWhitelist(resourceId: number) {
.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 = {
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
};
@@ -71,7 +84,25 @@ export async function getResourceWhitelist(
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, {
data: {

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -27,6 +27,19 @@ async function query(resourceId: number) {
.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 = {
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
};
@@ -75,7 +88,25 @@ export async function listResourceRoles(
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, {
data: {

View File

@@ -1,5 +1,5 @@
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 response from "@server/lib/response";
import { eq, sql } from "drizzle-orm";
@@ -47,6 +47,21 @@ function queryResourceRules(resourceId: number) {
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 = {
rules: Awaited<ReturnType<typeof queryResourceRules>>;
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
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
let rulesList: Awaited<ReturnType<typeof queryResourceRules>>;
let totalCount: number;
let rulesList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
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
rulesList = rulesList.sort((a, b) => a.priority - b.priority);

View File

@@ -1,9 +1,8 @@
import {
browserGatewayTarget,
alias,
db,
labels,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourceLabels,
resourcePassword,
resourcePincode,
@@ -187,16 +186,98 @@ export type ResourceWithTargets = {
};
function queryResourcesBase() {
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
const sharedPolicyPincode = alias(
resourcePolicyPincode,
"sharedPolicyPincode"
);
const defaultPolicyPincode = alias(
resourcePolicyPincode,
"defaultPolicyPincode"
);
const sharedPolicyPassword = alias(
resourcePolicyPassword,
"sharedPolicyPassword"
);
const defaultPolicyPassword = alias(
resourcePolicyPassword,
"defaultPolicyPassword"
);
const sharedPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"sharedPolicyHeaderAuth"
);
const defaultPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"defaultPolicyHeaderAuth"
);
const effectivePasswordId = sql<number | null>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId}
ELSE ${defaultPolicyPassword.passwordId}
END,
${resourcePassword.passwordId}
)
`;
const effectivePincodeId = sql<number | null>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId}
ELSE ${defaultPolicyPincode.pincodeId}
END,
${resourcePincode.pincodeId}
)
`;
const effectiveHeaderAuthId = sql<number | null>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId}
ELSE ${defaultPolicyHeaderAuth.headerAuthId}
END,
${resourceHeaderAuth.headerAuthId}
)
`;
const effectiveSso = sql<boolean>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
ELSE ${defaultPolicy.sso}
END,
false
)
`;
const effectiveWhitelist = sql<boolean>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled}
ELSE ${defaultPolicy.emailWhitelistEnabled}
END,
false
)
`;
const effectiveHeaderAuthExtendedCompatibility = sql<boolean>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.extendedCompatibility}
ELSE ${defaultPolicyHeaderAuth.extendedCompatibility}
END,
false
)
`;
return db
.select({
resourceId: resources.resourceId,
name: resources.name,
ssl: resources.ssl,
fullDomain: resources.fullDomain,
passwordId: resourcePolicyPassword.passwordId,
sso: resourcePolicies.sso,
pincodeId: resourcePolicyPincode.pincodeId,
whitelist: resourcePolicies.emailWhitelistEnabled,
passwordId: effectivePasswordId,
sso: effectiveSso,
pincodeId: effectivePincodeId,
whitelist: effectiveWhitelist,
proxyPort: resources.proxyPort,
enabled: resources.enabled,
domainId: resources.domainId,
@@ -204,44 +285,74 @@ function queryResourcesBase() {
wildcard: resources.wildcard,
mode: resources.mode,
health: resources.health,
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
headerAuthId: effectiveHeaderAuthId,
headerAuthExtendedCompatibility:
resourcePolicyHeaderAuth.extendedCompatibility
effectiveHeaderAuthExtendedCompatibility
})
.from(resources)
.leftJoin(
resourcePolicies,
or(
eq(
resourcePolicies.resourcePolicyId,
resources.resourcePolicyId
),
eq(
resourcePolicies.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePolicyPassword,
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
sharedPolicy,
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
sharedPolicyPincode,
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicies.resourcePolicyId
sharedPolicyPincode.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
resourcePolicyPincode,
sharedPolicyPassword,
eq(
resourcePolicyPincode.resourcePolicyId,
resourcePolicies.resourcePolicyId
sharedPolicyPassword.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
resourcePolicyHeaderAuth,
sharedPolicyHeaderAuth,
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicies.resourcePolicyId
sharedPolicyHeaderAuth.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicy,
eq(
defaultPolicy.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
.leftJoin(
defaultPolicyPincode,
eq(
defaultPolicyPincode.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyPassword,
eq(
defaultPolicyPassword.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyHeaderAuth,
eq(
defaultPolicyHeaderAuth.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
@@ -251,10 +362,23 @@ function queryResourcesBase() {
)
.groupBy(
resources.resourceId,
resourcePolicies.resourcePolicyId,
resourcePolicyPassword.passwordId,
resourcePolicyPincode.pincodeId,
resourcePolicyHeaderAuth.headerAuthId
resourcePincode.pincodeId,
resourcePassword.passwordId,
resourceHeaderAuth.headerAuthId,
sharedPolicy.resourcePolicyId,
sharedPolicy.sso,
sharedPolicy.emailWhitelistEnabled,
sharedPolicyPincode.pincodeId,
sharedPolicyPassword.passwordId,
sharedPolicyHeaderAuth.headerAuthId,
sharedPolicyHeaderAuth.extendedCompatibility,
defaultPolicy.resourcePolicyId,
defaultPolicy.sso,
defaultPolicy.emailWhitelistEnabled,
defaultPolicyPincode.pincodeId,
defaultPolicyPassword.passwordId,
defaultPolicyHeaderAuth.headerAuthId,
defaultPolicyHeaderAuth.extendedCompatibility
);
}
@@ -396,6 +520,80 @@ export async function listResources(
}
if (typeof authState !== "undefined") {
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
const sharedPolicyPincode = alias(
resourcePolicyPincode,
"sharedPolicyPincode"
);
const defaultPolicyPincode = alias(
resourcePolicyPincode,
"defaultPolicyPincode"
);
const sharedPolicyPassword = alias(
resourcePolicyPassword,
"sharedPolicyPassword"
);
const defaultPolicyPassword = alias(
resourcePolicyPassword,
"defaultPolicyPassword"
);
const sharedPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"sharedPolicyHeaderAuth"
);
const defaultPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"defaultPolicyHeaderAuth"
);
const effectiveSso = sql<boolean>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
ELSE ${defaultPolicy.sso}
END,
false
)
`;
const effectiveWhitelist = sql<boolean>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled}
ELSE ${defaultPolicy.emailWhitelistEnabled}
END,
false
)
`;
const effectiveHeaderAuthId = sql<number | null>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId}
ELSE ${defaultPolicyHeaderAuth.headerAuthId}
END,
${resourceHeaderAuth.headerAuthId}
)
`;
const effectivePincodeId = sql<number | null>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId}
ELSE ${defaultPolicyPincode.pincodeId}
END,
${resourcePincode.pincodeId}
)
`;
const effectivePasswordId = sql<number | null>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId}
ELSE ${defaultPolicyPassword.passwordId}
END,
${resourcePassword.passwordId}
)
`;
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"];
switch (authState) {
case "none":
conditions.push(
@@ -404,22 +602,28 @@ export async function listResources(
break;
case "protected":
conditions.push(
or(
eq(resourcePolicies.sso, true),
eq(resourcePolicies.emailWhitelistEnabled, true),
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
not(isNull(resourcePolicyPincode.pincodeId)),
not(isNull(resourcePolicyPassword.passwordId))
and(
inArray(resources.mode, browserGatewayModes),
or(
eq(effectiveSso, true),
eq(effectiveWhitelist, true),
not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId))
)
)
);
break;
case "not_protected":
conditions.push(
not(eq(resourcePolicies.sso, true)),
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
isNull(resourcePolicyHeaderAuth.headerAuthId),
isNull(resourcePolicyPincode.pincodeId),
isNull(resourcePolicyPassword.passwordId)
and(
inArray(resources.mode, browserGatewayModes),
not(eq(effectiveSso, true)),
not(eq(effectiveWhitelist, true)),
isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId),
isNull(effectivePasswordId)
)
);
break;
}
@@ -434,15 +638,8 @@ export async function listResources(
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
const resourcesWithBrowserGateway = db
.select({ resourceId: browserGatewayTarget.resourceId })
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.siteId, siteId));
conditions.push(
or(
inArray(resources.resourceId, resourcesWithSite),
inArray(resources.resourceId, resourcesWithBrowserGateway)
)
or(inArray(resources.resourceId, resourcesWithSite))
);
}
@@ -565,30 +762,6 @@ export async function listResources(
)
.leftJoin(sites, eq(targets.siteId, sites.siteId));
const allBgTargetSites =
resourceIdList.length === 0
? []
: await db
.select({
resourceId: browserGatewayTarget.resourceId,
siteId: browserGatewayTarget.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online,
siteType: sites.type
})
.from(browserGatewayTarget)
.where(
inArray(
browserGatewayTarget.resourceId,
resourceIdList
)
)
.leftJoin(
sites,
eq(sites.siteId, browserGatewayTarget.siteId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -651,21 +824,6 @@ export async function listResources(
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
const bgRaw = allBgTargetSites.filter(
(t) => t.resourceId === entry.resourceId
);
for (const t of bgRaw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
const isLocal = t.siteType === "local";
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: isLocal ? undefined : Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -130,35 +130,71 @@ export async function removeRoleFromResource(
);
}
// Check if role exists in resource
const existingEntry = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Role not found in resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
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.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, {
data: {},
success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources } from "@server/db";
import { userResources } from "@server/db";
import { userResources, userPolicies } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -103,35 +103,71 @@ export async function removeUserFromResource(
);
}
// Check if user exists in resource
const existingEntry = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.resourceId, resourceId),
eq(userResources.userId, userId)
)
);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"User not found in resource"
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
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.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, {
data: {},
success: true,

View File

@@ -3,7 +3,9 @@ import { z } from "zod";
import {
db,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility
resourceHeaderAuthExtendedCompatibility,
resourcePolicyHeaderAuth,
resources
} from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@@ -89,36 +91,73 @@ export async function setResourceHeaderAuth(
const { resourceId } = parsedParams.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 trx
.delete(resourceHeaderAuth)
.where(eq(resourceHeaderAuth.resourceId, resourceId));
await trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resourceId
)
);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.delete(resourcePolicyHeaderAuth)
.where(
eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId)
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
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
})
]);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId: policyId,
headerAuthHash,
extendedCompatibility: extendedCompatibility!
});
}
} else {
await trx
.delete(resourceHeaderAuth)
.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
})
]);
}
}
});

View File

@@ -1,7 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePassword } from "@server/db";
import {
resourcePassword,
resourcePolicyPassword,
resources
} from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -85,17 +89,49 @@ export async function setResourcePassword(
const { resourceId } = parsedParams.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 trx
.delete(resourcePassword)
.where(eq(resourcePassword.resourceId, resourceId));
if (password) {
const passwordHash = await hashPassword(password);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.insert(resourcePassword)
.values({ resourceId, passwordHash });
.delete(resourcePolicyPassword)
.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 });
}
}
});

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePincode } from "@server/db";
import { resourcePincode, resourcePolicyPincode, resources } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -89,17 +89,51 @@ export async function setResourcePincode(
const { resourceId } = parsedParams.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 trx
.delete(resourcePincode)
.where(eq(resourcePincode.resourceId, resourceId));
if (pincode) {
const pincodeHash = await hashPassword(pincode);
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await trx
.insert(resourcePincode)
.values({ resourceId, pincodeHash, digitLength: 6 });
.delete(resourcePolicyPincode)
.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 });
}
}
});

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -129,28 +129,61 @@ export async function setResourceRoles(
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
await db.transaction(async (trx) => {
if (adminRoleIds.length > 0) {
await trx.delete(roleResources).where(
and(
eq(roleResources.resourceId, resourceId),
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
// For inline policy, preserve admin roles by only deleting non-admin entries
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 {
await trx
.delete(roleResources)
.where(eq(roleResources.resourceId, resourceId));
}
if (adminRoleIds.length > 0) {
await trx.delete(roleResources).where(
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(
roleIds.map((roleId) =>
trx
.insert(roleResources)
.values({ roleId, resourceId })
.returning()
)
);
await Promise.all(
roleIds.map((roleId) =>
trx
.insert(roleResources)
.values({ roleId, resourceId })
.returning()
)
);
}
return response(res, {
data: {},

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userResources } from "@server/db";
import { userResources, userPolicies, resources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -82,19 +82,51 @@ export async function setResourceUsers(
const { resourceId } = parsedParams.data;
await db.transaction(async (trx) => {
await trx
.delete(userResources)
.where(eq(userResources.resourceId, resourceId));
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
const newUserResources = await Promise.all(
userIds.map((userId) =>
trx
.insert(userResources)
.values({ userId, resourceId })
.returning()
)
if (!resource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
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, {
data: {},

View File

@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -104,57 +109,135 @@ export async function setResourceWhitelist(
);
}
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const isInlinePolicy =
resource.resourcePolicyId === null &&
resource.defaultResourcePolicyId !== null;
const whitelist = await db
.select()
.from(resourceWhitelist)
.where(eq(resourceWhitelist.resourceId, resourceId));
if (isInlinePolicy) {
const policyId = resource.defaultResourcePolicyId!;
await db.transaction(async (trx) => {
// diff the emails
const existingEmails = whitelist.map((w) => w.email);
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
const emailsToAdd = emails.filter(
(e) => !existingEmails.includes(e)
);
const emailsToRemove = existingEmails.filter(
(e) => !emails.includes(e)
);
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
for (const email of emailsToAdd) {
await trx.insert(resourceWhitelist).values({
email,
resourceId
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
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) {
logger.error(error);
return next(

View File

@@ -14,7 +14,7 @@ export type GetMaintenanceInfoResponse = {
export type AttachedResource = Pick<
Resource,
"resourceId" | "name" | "fullDomain"
"resourceId" | "niceId" | "name" | "fullDomain"
>;
export type ResourcePolicyWithResources = Pick<

View File

@@ -549,6 +549,58 @@ async function updateHttpResource(
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
.update(resources)
.set({ ...updateData, headers })

View File

@@ -93,10 +93,9 @@ export async function deleteSite(
// Clean up all client associations and send peer/proxy removal
// messages in a single efficient pass before deleting the row.
await cleanupSiteAssociations(site, trx);
await trx.delete(sites).where(eq(sites.siteId, siteId));
}
await trx.delete(sites).where(eq(sites.siteId, siteId));
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
});

View File

@@ -12,7 +12,6 @@ import {
userSites,
labels,
siteLabels,
browserGatewayTarget,
type Label
} from "@server/db";
import cache from "#dynamic/lib/cache";
@@ -241,10 +240,6 @@ function querySitesBase() {
ON ${siteResources.networkId} = ${siteNetworks.networkId}
WHERE ${siteNetworks.siteId} = ${sites.siteId}
AND ${siteResources.orgId} = ${sites.orgId}
) + (
SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId})
FROM ${browserGatewayTarget}
WHERE ${browserGatewayTarget.siteId} = ${sites.siteId}
)`,
status: sites.status
})

View File

@@ -142,6 +142,7 @@ const createSiteResourceSchema = z
data.destinationPort <= 65535)
);
}
return true;
},
{
message:

View File

@@ -24,6 +24,9 @@ import {
fireHealthCheckUnhealthyAlert,
fireHealthCheckUnknownAlert
} from "@server/lib/alerts";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config";
const createTargetParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
@@ -32,6 +35,7 @@ const createTargetParamsSchema = z.strictObject({
const createTargetSchema = z.strictObject({
siteId: z.int().positive(),
ip: z.string().refine(isTargetValid),
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
method: z.string().optional().nullable(),
port: z.int().min(1).max(65535),
enabled: z.boolean().default(true),
@@ -161,6 +165,12 @@ export async function createTarget(
);
}
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
let newTarget: Target[] = [];
let targetIps: string[] = [];
let healthCheck: TargetHealthCheck[] = [];
@@ -191,6 +201,9 @@ export async function createTarget(
.values({
resourceId,
...targetData,
mode: (targetData.mode ??
resource.mode ??
"http") as Target["mode"],
priority: targetData.priority || 100
})
.returning();
@@ -226,6 +239,10 @@ export async function createTarget(
resourceId,
siteId: site.siteId,
ip: targetData.ip,
mode: (targetData.mode ??
resource.mode ??
"http") as Target["mode"],
authToken: encryptedToken,
method: targetData.method,
port: targetData.port,
internalPort,

View File

@@ -34,6 +34,7 @@ function queryTargets(resourceId: number) {
.select({
targetId: targets.targetId,
ip: targets.ip,
mode: targets.mode,
method: targets.method,
port: targets.port,
enabled: targets.enabled,

View File

@@ -27,6 +27,10 @@ const updateTargetBodySchema = z
.strictObject({
siteId: z.int().positive(),
ip: z.string().refine(isTargetValid),
mode: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional()
.nullable(),
method: z.string().min(1).max(10).optional().nullable(),
port: z.int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
@@ -184,6 +188,8 @@ export async function updateTarget(
}
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
const nextMode =
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
let updatedTarget: any;
let updatedHc: any;
@@ -193,6 +199,7 @@ export async function updateTarget(
.set({
siteId: parsedBody.data.siteId,
ip: parsedBody.data.ip,
mode: nextMode,
method: parsedBody.data.method,
port: parsedBody.data.port,
internalPort,

View File

@@ -39,18 +39,6 @@ export default async function migration() {
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
CREATE TABLE "browserGatewayTarget" (
"browserGatewayTargetId" serial PRIMARY KEY NOT NULL,
"resourceId" integer NOT NULL,
"siteId" integer NOT NULL,
"authToken" varchar NOT NULL,
"type" varchar NOT NULL,
"destination" varchar NOT NULL,
"destinationPort" integer NOT NULL
);
`);
await db.execute(sql`
CREATE TABLE "clientLabels" (
"clientLabelId" serial PRIMARY KEY NOT NULL,
@@ -215,12 +203,6 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
);
@@ -289,6 +271,10 @@ export default async function migration() {
);
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
await db.execute(
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;`
);
await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");

View File

@@ -40,22 +40,6 @@ export default async function migration() {
try {
db.transaction(() => {
db.prepare(
`
CREATE TABLE 'browserGatewayTarget' (
'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'resourceId' integer NOT NULL,
'siteId' integer NOT NULL,
'authToken' text NOT NULL,
'type' text NOT NULL,
'destination' text NOT NULL,
'destinationPort' integer NOT NULL,
FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
CREATE TABLE 'clientLabels' (
@@ -350,6 +334,16 @@ export default async function migration() {
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
`
).run();
db.prepare(
`
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
`
).run();
db.prepare(
`
ALTER TABLE 'targets' ADD 'authToken' text;
`
).run();
})();
const existingResources = db

View File

@@ -28,11 +28,11 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
);
policyResponse = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/policies/resource`);
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
if (!policyResponse) {
redirect(`/${params.orgId}/settings/policies/resource`);
redirect(`/${params.orgId}/settings/policies/resources/public`);
}
return (
@@ -46,7 +46,9 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>

View File

@@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage(
/>
<Button asChild variant="outline">
<Link href={`/${params.orgId}/settings/policies/resource`}>
<Link
href={`/${params.orgId}/settings/policies/resources/public`}
>
{t("resourcePoliciesSeeAll")}
</Link>
</Button>

View File

@@ -10,7 +10,10 @@ import {
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import {
ResourceTargetAddressItem,
ResourceTargetSiteItem
} from "@app/components/resource-target-address-item";
import {
SettingsSection,
SettingsSectionBody,
@@ -65,6 +68,7 @@ import {
useMemo,
useState
} from "react";
import { maxSize } from "zod";
export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
@@ -138,11 +142,6 @@ export function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) {
return;
@@ -207,42 +206,6 @@ export function ProxyResourceTargetsForm({
})
);
// Browser-gateway targets (edit mode only)
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
},
enabled: !!resource
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -269,7 +232,7 @@ export function ProxyResourceTargetsForm({
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 p-3">
{t("priority")}
<TooltipProvider>
<Tooltip>
@@ -285,7 +248,6 @@ export function ProxyResourceTargetsForm({
),
cell: ({ row }) => {
return (
<div className="flex items-center justify-center w-full">
<Input
type="number"
min="1"
@@ -303,7 +265,6 @@ export function ProxyResourceTargetsForm({
}
}}
/>
</div>
);
},
size: 120,
@@ -334,19 +295,15 @@ export function ProxyResourceTargetsForm({
{row.original.siteType === "newt" ? (
<Button
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={() =>
openHealthCheckDialog(row.original)
}
>
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
<span>{getStatusText(status)}</span>
</Button>
) : (
<span>-</span>
@@ -441,13 +398,12 @@ export function ProxyResourceTargetsForm({
maxSize: 200
};
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
const siteColumn: ColumnDef<LocalTarget> = {
accessorKey: "site",
header: () => <span className="p-3">{t("site")}</span>,
cell: ({ row }) => {
return (
<ResourceTargetAddressItem
isHttp={isHttp}
<ResourceTargetSiteItem
orgId={orgId}
getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original}
@@ -456,9 +412,26 @@ export function ProxyResourceTargetsForm({
/>
);
},
size: 400,
minSize: 350,
maxSize: 500
size: 220,
minSize: 180,
maxSize: 280
};
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
return (
<ResourceTargetAddressItem
isHttp={isHttp}
proxyTarget={row.original}
updateTarget={updateTarget}
/>
);
},
size: 350,
minSize: 300,
maxSize: 450
};
const rewritePathColumn: ColumnDef<LocalTarget> = {
@@ -535,7 +508,7 @@ export function ProxyResourceTargetsForm({
accessorKey: "enabled",
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
@@ -554,9 +527,8 @@ export function ProxyResourceTargetsForm({
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<div className="flex items-center justify-end w-full">
<Button
variant="outline"
onClick={() => removeTarget(row.original.targetId)}
@@ -572,6 +544,7 @@ export function ProxyResourceTargetsForm({
if (isAdvancedMode) {
const cols = [
siteColumn,
addressColumn,
healthCheckColumn,
enabledColumn,
@@ -580,12 +553,13 @@ export function ProxyResourceTargetsForm({
if (isHttp) {
cols.unshift(matchPathColumn);
cols.splice(3, 0, rewritePathColumn, priorityColumn);
cols.splice(4, 0, rewritePathColumn, priorityColumn);
}
return cols;
} else {
return [
siteColumn,
addressColumn,
healthCheckColumn,
enabledColumn,
@@ -608,6 +582,8 @@ export function ProxyResourceTargetsForm({
const newTarget: LocalTarget = {
targetId: -Date.now(),
ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
@@ -823,6 +799,10 @@ export function ProxyResourceTargetsForm({
header.column
.id ===
"actions";
const isSiteColumn =
header.column
.id ===
"site";
return (
<TableHead
key={
@@ -831,7 +811,9 @@ export function ProxyResourceTargetsForm({
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
: isSiteColumn
? "w-45"
: ""
}
>
{header.isPlaceholder
@@ -863,6 +845,10 @@ export function ProxyResourceTargetsForm({
cell.column
.id ===
"actions";
const isSiteColumn =
cell.column
.id ===
"site";
return (
<TableCell
key={
@@ -871,7 +857,9 @@ export function ProxyResourceTargetsForm({
className={
isActionsColumn
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
: ""
: isSiteColumn
? "w-45"
: ""
}
>
{flexRender(

View File

@@ -330,7 +330,7 @@ export default function ResourceAuthenticationPage() {
asChild
>
<Link
href={`/${org.org.orgId}/settings/policies/resource/${policies.sharedPolicy.niceId}`}
href={`/${org.org.orgId}/settings/policies/resources/public/${policies.sharedPolicy.niceId}`}
>
{t("editSharedPolicy")}
<ArrowRightIcon className="size-4" />

View File

@@ -11,201 +11,184 @@ import {
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { use, useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
mode: string | null;
ip: string;
port: number;
};
export default function SshSettingsPage(props: {
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function RdpSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
queryFn: async () => {
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
<RdpServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
}
function SshServerForm({
function RdpServerForm({
orgId,
resource,
updateResource,
disabled
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const targets = targetsResponse.targets.filter((t) => t.mode === "rdp");
const firstTarget = targets[0];
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
const formSchema = useMemo(
() => createBrowserGatewayTargetFormSchema(t),
[t]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
const form = useForm<BrowserGatewayTargetFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
selectedSites: targets.map((target) => ({
siteId: target.siteId,
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
destination: firstTarget?.ip ?? "",
destinationPort: firstTarget ? String(firstTarget.port) : "3389"
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() =>
targets.map((target) => ({
targetId: target.targetId,
siteId: target.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const { selectedSites, destination, destinationPort } =
form.getValues();
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId));
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(`/target/${t.targetId}`, {
mode: "rdp",
ip: destination,
port: Number(destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "rdp",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "rdp",
ip: destination,
port: Number(destinationPort),
authToken: null,
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
const newTargets: ExistingTarget[] = created.map((res, i) => ({
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
toast({
title: t("settingsUpdated"),
@@ -237,31 +220,31 @@ function SshServerForm({
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);

View File

@@ -7,15 +7,15 @@ import {
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SitesSelector,
type Selectedsite
} from "@app/components/site-selector";
import { SitesSelector } from "@app/components/site-selector";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button } from "@app/components/ui/button";
@@ -38,33 +38,39 @@ import { Badge } from "@app/components/ui/badge";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createSshSettingsFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
import type { SshSettingsFormValues } from "@app/lib/browserGatewayTargetFormSchema";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { use, useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
authToken?: string | null;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
mode: string | null;
ip: string;
port: number;
authToken?: string | null;
};
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
@@ -72,10 +78,23 @@ export default function SshSettingsPage(props: {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
queryFn: async () => {
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<PaidFeaturesAlert
@@ -86,6 +105,7 @@ export default function SshSettingsPage(props: {
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
@@ -95,232 +115,241 @@ function SshServerForm({
orgId,
resource,
updateResource,
disabled
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native";
const targets = targetsResponse.targets.filter((t) => t.mode === "ssh");
const firstTarget = targets[0];
const initialPamMode =
(resource.pamMode as "passthrough" | "push") || "passthrough";
const initialStandardDaemonLocation = isNativeInitially
? "site"
: ((resource.authDaemonMode as "site" | "remote") || "site");
const useSingleSiteOnLoad =
!isNativeInitially &&
initialPamMode === "push" &&
initialStandardDaemonLocation === "site";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
const [sshServerMode] = useState<"standard" | "native">(
isNativeInitially ? "native" : "standard"
);
const isNative = sshServerMode === "native";
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
(resource.pamMode as "passthrough" | "push") || "passthrough"
const formSchema = useMemo(
() => createSshSettingsFormSchema(t, { isNative }),
[t, isNative]
);
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
"site" | "remote"
>(
isNativeInitially
? "site"
: (resource.authDaemonMode as "site" | "remote") || "site"
);
const form = useForm({
resolver: zodResolver(sshFormSchema),
const form = useForm<SshSettingsFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
authDaemonPort: (resource as any).authDaemonPort
? String((resource as any).authDaemonPort)
: "22123"
pamMode: initialPamMode,
standardDaemonLocation: initialStandardDaemonLocation,
authDaemonPort: (resource as { authDaemonPort?: number })
.authDaemonPort
? String((resource as { authDaemonPort?: number }).authDaemonPort)
: "22123",
selectedSites:
isNativeInitially || useSingleSiteOnLoad
? []
: targets.map((target) => ({
siteId: target.siteId,
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
selectedSite:
useSingleSiteOnLoad && firstTarget
? {
siteId: firstTarget.siteId,
name:
firstTarget.siteName ??
String(firstTarget.siteId),
type: "newt" as const
}
: null,
selectedNativeSite:
isNativeInitially && firstTarget
? {
siteId: firstTarget.siteId,
name:
firstTarget.siteName ??
String(firstTarget.siteId),
type: "newt" as const
}
: null,
destination: isNativeInitially
? ""
: (firstTarget?.ip ?? ""),
destinationPort: isNativeInitially
? "22"
: firstTarget
? String(firstTarget.port)
: "22"
}
});
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
() =>
isNativeInitially
? []
: targets.map((target) => ({
targetId: target.targetId,
siteId: target.siteId,
authToken: target.authToken
}))
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
useState<ExistingTarget | null>(() =>
isNativeInitially && firstTarget
? {
targetId: firstTarget.targetId,
siteId: firstTarget.siteId,
authToken: firstTarget.authToken
}
: null
);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
if (isNativeInitially) {
setSelectedNativeSite({
siteId: first.siteId,
name: first.siteName ?? String(first.siteId),
type: "newt" as const
});
setNativeExistingTarget({
browserGatewayTargetId: first.browserGatewayTargetId,
siteId: first.siteId
});
} else {
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}
}, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null);
const pamMode = form.watch("pamMode");
const standardDaemonLocation = form.watch("standardDaemonLocation");
const selectedNativeSite = form.watch("selectedNativeSite");
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const effectiveMode = isNative ? "native" : standardDaemonLocation;
const portVal = form.getValues().authDaemonPort;
const values = form.getValues();
const effectiveMode = isNative ? "native" : values.standardDaemonLocation;
const effectivePort =
!isNative && standardDaemonLocation === "remote" && portVal
? Number(portVal)
!isNative &&
values.standardDaemonLocation === "remote" &&
values.authDaemonPort
? Number(values.authDaemonPort)
: null;
try {
await api.post(`/resource/${resource.resourceId}`, {
pamMode,
pamMode: values.pamMode,
authDaemonMode: effectiveMode,
authDaemonPort: effectivePort
});
updateResource({
...resource,
pamMode,
pamMode: values.pamMode,
authDaemonMode: effectiveMode
});
if (isNative) {
if (selectedNativeSite) {
const nativeSite = values.selectedNativeSite;
if (nativeSite) {
if (nativeExistingTarget) {
await api.post(
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
`/target/${nativeExistingTarget.targetId}`,
{
type: "ssh",
destination: "localhost",
destinationPort: 22,
siteId: selectedNativeSite.siteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
mode: "ssh",
ip: "localhost",
port: 22,
siteId: nativeSite.siteId,
authToken: nativeExistingTarget.authToken,
hcEnabled: false
}
);
setNativeExistingTarget({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: selectedNativeSite.siteId
...nativeExistingTarget,
siteId: nativeSite.siteId
});
} else {
const res = await api.put(
`/resource/${resource.resourceId}/target`,
{
siteId: nativeSite.siteId,
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
setNativeExistingTarget({
targetId: res.data.data.targetId,
siteId: nativeSite.siteId,
authToken: res.data.data.authToken
});
}
}
} else {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const useMultiSite =
values.standardDaemonLocation !== "site" ||
values.pamMode === "passthrough";
const activeSites = useMultiSite
? values.selectedSites
: values.selectedSite
? [values.selectedSite]
: [];
const selectedSiteIds = new Set(
activeSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) => api.delete(`/target/${t.targetId}`))
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map(
(res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(`/target/${t.targetId}`, {
mode: "ssh",
ip: values.destination,
port: Number(values.destinationPort),
siteId: t.siteId,
authToken: t.authToken,
hcEnabled: false
})
);
setExistingTargets([...toUpdate, ...newTargets]);
}
)
);
const toCreate = activeSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "ssh",
ip: values.destination,
port: Number(values.destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId,
authToken: res.data.data.authToken
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
toast({
@@ -370,6 +399,9 @@ function SshServerForm({
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
const useMultiSiteTargetForm =
!isNative &&
(standardDaemonLocation !== "site" || pamMode === "passthrough");
return (
<SettingsSection>
@@ -383,160 +415,189 @@ function SshServerForm({
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshServerMode")}
</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={setPamMode}
cols={2}
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={(value) =>
form.setValue("pamMode", value, {
shouldValidate: true
})
}
cols={2}
/>
</div>
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={setStandardDaemonLocation}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<Form {...form}>
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
<div className="space-y-3">
<div>
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{t("sshServerDestination")}
</h2>
<p className="text-sm text-muted-foreground">
{t("sshServerDestinationDescription")}
</p>
</div>
{isNative ? (
<Popover
open={nativeSiteOpen}
onOpenChange={setNativeSiteOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedNativeSite}
onSelectSite={(site) => {
setSelectedNativeSite(site);
setNativeSiteOpen(false);
}}
{showDaemonLocation && (
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={(value) =>
form.setValue(
"standardDaemonLocation",
value,
{ shouldValidate: true }
)
}
cols={2}
/>
</PopoverContent>
</Popover>
) : standardDaemonLocation !== "site" ||
pamMode === "passthrough" ? (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={false}
selectedSite={selectedSite}
onSiteChange={setSelectedSite}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)}
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{showDaemonPort && (
<div className="w-full md:w-1/2">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={
selectedNativeSite
}
onSelectSite={(
site
) => {
form.setValue(
"selectedNativeSite",
site,
{
shouldValidate:
true
}
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
) : useMultiSiteTargetForm ? (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)}
</div>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);

View File

@@ -11,199 +11,184 @@ import {
} from "@app/components/Settings";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { type Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { z } from "zod";
import { use, useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
mode: string | null;
ip: string;
port: number;
};
export default function SshSettingsPage(props: {
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function VncSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
const { isPaidUser } = usePaidStatus();
const api = createApiClient(useEnvContext());
const disabled = !isPaidUser(
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
queryFn: async () => {
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
if (isLoadingTargets) {
return null;
}
return (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
<VncServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
}
function SshServerForm({
function VncServerForm({
orgId,
resource,
updateResource,
disabled
disabled,
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const targets = targetsResponse.targets.filter((t) => t.mode === "vnc");
const firstTarget = targets[0];
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
[]
const formSchema = useMemo(
() => createBrowserGatewayTargetFormSchema(t),
[t]
);
// Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
}>;
};
const form = useForm<BrowserGatewayTargetFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
selectedSites: targets.map((target) => ({
siteId: target.siteId,
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
destination: firstTarget?.ip ?? "",
destinationPort: firstTarget ? String(firstTarget.port) : "5900"
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const targets = bgTargetsResponse.targets;
const first = targets[0];
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() =>
targets.map((target) => ({
targetId: target.targetId,
siteId: target.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
}, [bgTargetsResponse]);
);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const { selectedSites, destination, destinationPort } =
form.getValues();
try {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
);
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId));
const existingSiteIds = new Set(
existingTargets.map((t) => t.siteId)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(`/target/${t.targetId}`, {
mode: "vnc",
ip: destination,
port: Number(destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "vnc",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "vnc",
ip: destination,
port: Number(destinationPort),
authToken: null,
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
}
const newTargets: ExistingTarget[] = created.map((res, i) => ({
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);
toast({
title: t("settingsUpdated"),
@@ -235,31 +220,31 @@ function SshServerForm({
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</Form>
</fieldset>
</SettingsSection>
);

View File

@@ -2,13 +2,6 @@
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckCredenza from "@app/components/HealthCheckCredenza";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import {
SettingsContainer,
SettingsSection,
@@ -16,7 +9,10 @@ import {
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
@@ -48,35 +44,18 @@ import {
PopoverContent,
PopoverTrigger
} 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 { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createBrowserGatewayTargetFormSchema,
createSshSettingsFormSchema,
selectedSiteSchema,
type SshSettingsFormValues
} from "@app/lib/browserGatewayTargetFormSchema";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
@@ -84,32 +63,16 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import {
LocalTarget,
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 {
ChevronsUpDown,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -119,105 +82,137 @@ import { toASCII } from "punycode";
import {
useMemo,
useState,
useCallback,
useTransition,
useEffect
} from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod";
import { cn } from "@app/lib/cn";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
http: z.boolean()
});
type TranslateFn = (key: string) => string;
const httpResourceFormSchema = z.object({
domainId: z.string().nonempty(),
subdomain: z.string().optional()
});
function createBaseResourceFormSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.min(1, { message: t("nameRequired") })
.max(255, {
message: t("createInternalResourceDialogNameMaxLength")
}),
http: z.boolean()
});
}
const tcpUdpResourceFormSchema = z.object({
protocol: z.string(),
proxyPort: z.int().min(1).max(65535)
});
function createHttpResourceFormSchema(t: TranslateFn) {
return z.object({
domainId: z.string().min(1, { message: t("domainRequired") }),
subdomain: z.string().optional()
});
}
const sshDaemonPortSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
function createTcpUdpResourceFormSchema(t: TranslateFn) {
return z.object({
protocol: z.string(),
proxyPort: z
.number({ error: t("proxyPortRequired") })
.int({ error: t("healthCheckPortInvalid") })
.min(1, { message: t("healthCheckPortInvalid") })
.max(65535, { message: t("healthCheckPortInvalid") })
});
}
const addTargetSchema = z
.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number<number>().int().positive(),
siteId: z.int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.int().min(1).max(1000).optional()
})
.refine(
(data) => {
if (data.path && !data.pathMatchType) {
return false;
}
if (data.pathMatchType && !data.path) {
return false;
}
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
return data.path.startsWith("/");
case "regex":
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
error: "Invalid path configuration"
}
)
.refine(
(data) => {
if (data.rewritePath && !data.rewritePathType) {
return false;
}
if (data.rewritePathType && !data.rewritePath) {
if (data.rewritePathType !== "stripPrefix") {
function createSshDaemonPortSchema(t: TranslateFn) {
return z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: t("healthCheckPortInvalid") }
)
});
}
function createAddTargetSchema(t: TranslateFn) {
return z
.object({
ip: z.string().refine(isTargetValid, {
message: t("targetErrorInvalidIpDescription")
}),
method: z.string().nullable(),
port: z.coerce
.number<number>({ error: t("targetErrorInvalidPortDescription") })
.int({ error: t("targetErrorInvalidPortDescription") })
.positive({ error: t("targetErrorInvalidPortDescription") }),
siteId: z
.int({ error: t("siteRequired") })
.positive({ error: t("siteRequired") }),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z
.int()
.min(1, { message: t("healthCheckPortInvalid") })
.max(1000, { message: t("healthCheckPortInvalid") })
.optional()
})
.refine(
(data) => {
if (data.path && !data.pathMatchType) {
return false;
}
if (data.pathMatchType && !data.path) {
return false;
}
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
return data.path.startsWith("/");
case "regex":
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
message: t("invalidPathConfiguration")
}
return true;
},
{
error: "Invalid rewrite path configuration"
}
);
)
.refine(
(data) => {
if (data.rewritePath && !data.rewritePathType) {
return false;
}
if (data.rewritePathType && !data.rewritePath) {
if (data.rewritePathType !== "stripPrefix") {
return false;
}
}
return true;
},
{
message: t("invalidRewritePathConfiguration")
}
);
}
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
type CreateBgTargetFormValues = SshSettingsFormValues;
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -268,29 +263,6 @@ export default function Page() {
useState<Selectedsite | null>(null);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
// Browser-gateway targets state (SSH standard, RDP, VNC)
const [bgSelectedSites, setBgSelectedSites] = useState<Selectedsite[]>([]);
const [bgSelectedSite, setBgSelectedSite] = useState<Selectedsite | null>(
null
);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
// Reset BG state when resource type changes
useEffect(() => {
if (resourceType === "rdp") {
setBgDestinationPort("3389");
} else if (resourceType === "vnc") {
setBgDestinationPort("5900");
} else if (resourceType === "ssh") {
setBgDestinationPort("22");
}
setBgDestination("");
setBgSelectedSites([]);
setBgSelectedSite(null);
setNativeSelectedSite(null);
}, [resourceType]);
useEffect(() => {
if (build !== "saas") return;
@@ -323,18 +295,80 @@ export default function Page() {
pamMode === "push" &&
standardDaemonLocation === "remote";
const bgTargetFormSchema = useMemo(() => {
if (resourceType === "ssh" && !isNative) {
return createSshSettingsFormSchema(t, { isNative: false });
}
if (resourceType === "rdp" || resourceType === "vnc") {
return createBrowserGatewayTargetFormSchema(t);
}
return z.object({
selectedSites: z.array(selectedSiteSchema),
selectedSite: selectedSiteSchema.nullable(),
destination: z.string(),
destinationPort: z.string(),
pamMode: z.enum(["passthrough", "push"]),
standardDaemonLocation: z.enum(["site", "remote"])
});
}, [resourceType, isNative, t]);
const bgTargetForm = useForm<CreateBgTargetFormValues>({
resolver: zodResolver(
bgTargetFormSchema
) as unknown as Resolver<CreateBgTargetFormValues>,
defaultValues: {
selectedSites: [],
selectedSite: null,
selectedNativeSite: null,
destination: "",
destinationPort: "22",
pamMode: "passthrough",
standardDaemonLocation: "site",
authDaemonPort: "22123"
}
});
// Whether raw (TCP/UDP) resources are available
const rawResourcesAllowed =
env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0);
const enterpriseModesAllowed =
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = ["http", "ssh", "rdp", "vnc"];
const base: NewResourceType[] = ["http"];
if (enterpriseModesAllowed) {
base.push("ssh", "rdp", "vnc");
}
if (rawResourcesAllowed) {
base.push("tcp", "udp");
}
return base;
}, [rawResourcesAllowed]);
}, [enterpriseModesAllowed, rawResourcesAllowed]);
useEffect(() => {
if (!availableTypes.includes(resourceType)) {
setResourceType("http");
}
}, [availableTypes, resourceType]);
const baseResourceFormSchema = useMemo(
() => createBaseResourceFormSchema(t),
[t]
);
const httpResourceFormSchema = useMemo(
() => createHttpResourceFormSchema(t),
[t]
);
const tcpUdpResourceFormSchema = useMemo(
() => createTcpUdpResourceFormSchema(t),
[t]
);
const sshDaemonPortSchema = useMemo(
() => createSshDaemonPortSchema(t),
[t]
);
const addTargetSchema = useMemo(() => createAddTargetSchema(t), [t]);
const baseForm = useForm({
resolver: zodResolver(baseResourceFormSchema),
@@ -364,6 +398,31 @@ export default function Page() {
}
});
useEffect(() => {
const defaultPort =
resourceType === "rdp"
? "3389"
: resourceType === "vnc"
? "5900"
: "22";
bgTargetForm.reset({
selectedSites: [],
selectedSite: null,
selectedNativeSite: null,
destination: "",
destinationPort: defaultPort,
pamMode,
standardDaemonLocation,
authDaemonPort: sshDaemonPortForm.getValues().authDaemonPort
});
setNativeSelectedSite(null);
}, [resourceType]);
useEffect(() => {
bgTargetForm.setValue("pamMode", pamMode);
bgTargetForm.setValue("standardDaemonLocation", standardDaemonLocation);
}, [pamMode, standardDaemonLocation]);
// Sync form http field with resourceType
useEffect(() => {
baseForm.setValue("http", isHttpResource);
@@ -532,30 +591,35 @@ export default function Page() {
if (isNative) {
if (nativeSelectedSite) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: nativeSelectedSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
}
} else {
const sitesToCreate =
standardDaemonLocation !== "site"
? bgSelectedSites
: bgSelectedSite
? [bgSelectedSite]
: [];
const bgValues = bgTargetForm.getValues();
const useMultiSite =
standardDaemonLocation !== "site" ||
pamMode === "passthrough";
const sitesToCreate = useMultiSite
? bgValues.selectedSites
: bgValues.selectedSite
? [bgValues.selectedSite]
: [];
for (const site of sitesToCreate) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: site.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
mode: "ssh",
ip: bgValues.destination,
port: Number(bgValues.destinationPort),
hcEnabled: false
}
);
}
@@ -565,16 +629,19 @@ export default function Page() {
`/${orgId}/settings/resources/public/${newNiceId}`
);
} else if (resourceType === "rdp" || resourceType === "vnc") {
for (const site of bgSelectedSites) {
const bgValues = bgTargetForm.getValues();
for (const site of bgValues.selectedSites) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: site.siteId,
type: resourceType,
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
mode: resourceType,
ip: bgValues.destination,
port: Number(bgValues.destinationPort),
authToken: null,
hcEnabled: false
}
);
);
}
router.push(
@@ -686,19 +753,25 @@ export default function Page() {
}
];
const typeLabels: Record<NewResourceType, string> = {
let typeLabels: Partial<Record<NewResourceType, string>> = {
http: "HTTP",
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
tcp: "TCP",
udp: "UDP"
};
if (enterpriseModesAllowed) {
typeLabels = {
...typeLabels,
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
}
}
const typeOptions: OptionSelectOption<NewResourceType>[] =
availableTypes.map((type) => ({
value: type,
label: typeLabels[type]
label: typeLabels[type] ?? type.toUpperCase()
}));
return (
@@ -742,7 +815,7 @@ export default function Page() {
e.preventDefault();
}
}}
className="space-y-4"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="base-resource-form"
>
<FormField
@@ -788,32 +861,56 @@ export default function Page() {
{/* Domain/Subdomain (HTTP-based types) */}
{isHttpResource && (
<div className="space-y-2">
<DomainPicker
allowWildcard={true}
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(res) => {
if (!res) return;
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
}}
/>
<p className="text-sm text-muted-foreground">
{t(
"resourceDomainDescription"
<Form {...httpForm}>
<FormField
control={httpForm.control}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
allowWildcard={
true
}
orgId={
orgId as string
}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(
res
) => {
if (!res)
return;
httpForm.setValue(
"subdomain",
res.subdomain,
{
shouldValidate:
true
}
);
httpForm.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
</p>
</div>
/>
</Form>
)}
{/* Proxy Port (TCP/UDP types) */}
@@ -825,7 +922,7 @@ export default function Page() {
e.preventDefault();
}
}}
className="space-y-4"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<FormField
@@ -910,10 +1007,8 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
{/* Mode */}
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshServerMode")}
</p>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
<StrategySelect<
"standard" | "native"
>
@@ -924,12 +1019,8 @@ export default function Page() {
/>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
{t(
"sshAuthenticationMethod"
)}
</p>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<
"passthrough" | "push"
>
@@ -944,12 +1035,8 @@ export default function Page() {
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
{t(
"sshAuthDaemonLocation"
)}
</p>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<
"site" | "remote"
>
@@ -984,49 +1071,55 @@ export default function Page() {
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<Form {...sshDaemonPortForm}>
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="w-full md:w-1/2">
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</Form>
)}
{/* Server Destination */}
<div className="space-y-3">
<div>
<h2 className="text-sm font-semibold">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</h2>
<p className="text-sm text-muted-foreground">
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</p>
</div>
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}
@@ -1038,7 +1131,7 @@ export default function Page() {
<Button
variant="outline"
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">
{nativeSelectedSite?.name ??
@@ -1074,55 +1167,39 @@ export default function Page() {
"site" ||
pamMode ===
"passthrough" ? (
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={true}
selectedSites={
bgSelectedSites
}
onSitesChange={
setBgSelectedSites
}
destination={
bgDestination
}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
) : (
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={false}
selectedSite={
bgSelectedSite
}
onSiteChange={
setBgSelectedSite
}
destination={
bgDestination
}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={
bgTargetForm.control
}
orgId={
orgId as string
}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
)}
</div>
</SettingsSectionForm>
@@ -1160,26 +1237,18 @@ export default function Page() {
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={true}
selectedSites={bgSelectedSites}
onSitesChange={
setBgSelectedSites
}
destination={bgDestination}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={bgTargetForm.control}
orgId={orgId as string}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
@@ -1215,26 +1284,18 @@ export default function Page() {
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId as string}
multiSite={true}
selectedSites={bgSelectedSites}
onSitesChange={
setBgSelectedSites
}
destination={bgDestination}
destinationPort={
bgDestinationPort
}
onDestinationChange={
setBgDestination
}
onDestinationPortChange={
setBgDestinationPort
}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
<Form {...bgTargetForm}>
<BrowserGatewayTargetForm
control={bgTargetForm.control}
orgId={orgId as string}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
@@ -1275,15 +1336,31 @@ export default function Page() {
const tcpValid = !isHttpResource
? await tcpUdpForm.trigger()
: true;
const sshPortValid = showDaemonPort
? await sshDaemonPortForm.trigger()
: true;
if (
resourceType === "ssh" &&
!isNative
) {
bgTargetForm.setValue(
"authDaemonPort",
sshDaemonPortForm.getValues()
.authDaemonPort
);
}
const bgValid =
resourceType === "rdp" ||
resourceType === "vnc" ||
(resourceType === "ssh" &&
!isNative)
? await bgTargetForm.trigger()
: true;
if (
baseValid &&
domainValid &&
tcpValid &&
sshPortValid
bgValid
) {
onSubmit();
}

View File

@@ -1,7 +1,7 @@
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
import type { ResourceRow } from "@app/components/PublicResourcesTable";
import PublicResourcesTable from "@app/components/PublicResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner";
import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
@@ -146,10 +146,10 @@ export default async function ProxyResourcesPage(
description={t("proxyResourceDescription")}
/>
<ProxyResourcesBanner />
<PublicResourcesBanner />
<OrgProvider org={org}>
<ProxyResourcesTable
<PublicResourcesTable
resources={resourceRows}
orgId={params.orgId}
rowCount={pagination.total}

View File

@@ -256,7 +256,7 @@ export default function GeneralPage() {
return (
<FormItem>
<FormControl>
<div className="flex items-center gap-3">
<div className="">
<SwitchInput
id="auto-update-enabled"
label={t(
@@ -285,7 +285,7 @@ export default function GeneralPage() {
type="button"
variant="link"
size="sm"
className="h-auto p-0 pb-2 text-xs"
className="text-sm text-muted-foreground underline px-0"
onClick={() => {
form.setValue(
"autoUpdateOverrideOrg",

View File

@@ -243,10 +243,8 @@ export default function Page() {
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
description={t("idpAutoProvisionConfigureAfterCreate")}
/>
<p className="text-sm text-muted-foreground">
{t("idpAutoProvisionConfigureAfterCreate")}
</p>
</div>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.88 0.004 286.32);
--border: oklch(0.91 0.004 286.32);
--input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--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%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);

View File

@@ -137,7 +137,7 @@ export const orgNavSections = (
}
]
},
...(build !== "oss"
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarPolicies",
@@ -146,7 +146,7 @@ export const orgNavSections = (
items: [
{
title: "sidebarResourcePolicies",
href: "/{orgId}/settings/policies/resource",
href: "/{orgId}/settings/policies/resources/public",
icon: (
<GlobeIcon className="size-4 flex-none" />
)

View File

@@ -1,9 +1,19 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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 type {
UserInteraction,
@@ -22,7 +32,11 @@ import {
CardTitle,
CardDescription
} 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 AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
declare module "react" {
namespace JSX {
@@ -40,7 +54,7 @@ declare module "react" {
}
}
type FormState = {
type RdpCredentialsForm = {
username: string;
password: string;
domain: string;
@@ -49,6 +63,23 @@ type FormState = {
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 => {
return (
typeof error === "object" &&
@@ -60,33 +91,35 @@ const isIronError = (error: unknown): error is IronError => {
export default function RdpClient({
target,
error
error,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
primaryColor?: string | null;
}) {
const t = useTranslations();
const STORAGE_KEY = "pangolin_rdp_credentials";
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
const formSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
password: z.string().min(1, { message: t("passwordRequired") }),
domain: z.string(),
kdcProxyUrl: z.string(),
pcb: z.string(),
enableClipboard: z.boolean()
});
const form = useForm<RdpCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
@@ -138,7 +171,7 @@ export default function RdpClient({
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
title: t("rdpFailedToLoadModule"),
description: `${err}`
});
});
@@ -160,25 +193,17 @@ export default function RdpClient({
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
const startSession = async (values: RdpCredentialsForm) => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
setSubmitError(t("rdpModuleInitializing"));
return;
}
userInteraction.setEnableClipboard(form.enableClipboard);
userInteraction.setEnableClipboard(values.enableClipboard);
// Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection.
@@ -193,7 +218,9 @@ export default function RdpClient({
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
title: t("rdpDownloadingFiles", {
count: downloadable.length
})
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -211,7 +238,9 @@ export default function RdpClient({
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
title: t("rdpDownloadFailed", {
fileName: file.name
}),
description: `${err}`
});
});
@@ -220,7 +249,7 @@ export default function RdpClient({
// Notify when individual uploads complete (remote pasted a 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
@@ -232,11 +261,7 @@ export default function RdpClient({
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
setSubmitError(t("rdpNoConnectionTarget"));
return;
}
@@ -244,13 +269,13 @@ export default function RdpClient({
const builder = userInteraction
.configBuilder()
.withUsername(form.username)
.withPassword(form.password)
.withUsername(values.username)
.withPassword(values.password)
.withDestination(destination)
.withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
)
.withServerDomain(form.domain)
.withServerDomain(values.domain)
.withAuthToken(target.authToken)
.withDesktopSize({
width: window.innerWidth,
@@ -258,18 +283,18 @@ export default function RdpClient({
})
.withExtension(exts.displayControl(true));
if (form.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb));
if (values.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(values.pcb));
}
if (form.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl));
if (values.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl));
}
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
@@ -285,21 +310,18 @@ export default function RdpClient({
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
setSubmitError(err.backtrace());
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
setSubmitError(`${err}`);
}
}
};
const onSubmit = (values: RdpCredentialsForm) => {
setSubmitError(null);
startSession(values);
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
@@ -315,133 +337,115 @@ export default function RdpClient({
if (error) {
return (
<div>
<div className="text-center mb-2">
<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>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>RDP</CardTitle>
<CardTitle>{t("rdpTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{showLogin && (
<div>
<div className="text-center mb-2">
<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>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>Sign in to Remote Desktop</CardTitle>
<CardTitle>
{resourceName
? `${t("rdpSignInTitle")} - ${resourceName}`
: t("rdpSignInTitle")}
</CardTitle>
<CardDescription>
Enter Windows credentials to access xxxx
{resourceName
? `${t("rdpSignInDescription")} (${resourceName})`
: t("rdpSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
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"
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{moduleReady
? "Connect"
: "Loading module..."}
</Button>
</div>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<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>
</Card>
</div>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}
<div
@@ -454,35 +458,35 @@ export default function RdpClient({
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
{t("rdpFit")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
{t("rdpFull")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
{t("rdpReal")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
{t("rdpMeta")}
</Button>
{/* <Button
size="sm"
@@ -504,19 +508,22 @@ export default function RdpClient({
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
title: t("rdpFilesReadyToPaste"),
description: t(
"rdpFilesReadyToPasteDescription",
{ count: files.length }
)
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
title: t("rdpUploadFailed"),
description: `${err}`
});
}
}}
>
Upload files
{t("rdpUploadFiles")}
</Button>
<Button
size="sm"
@@ -526,7 +533,7 @@ export default function RdpClient({
setShowLogin(true);
}}
>
Terminate
{t("sshTerminate")}
</Button>
<label className="ml-2 flex items-center gap-2">
<input
@@ -537,7 +544,7 @@ export default function RdpClient({
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
{t("rdpUnicodeKeyboardMode")}
</label>
</div>
@@ -554,20 +561,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>
);
}

View File

@@ -1,40 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import RdpClient from "./RdpClient";
import AuthFooter from "@app/components/AuthFooter";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("RDP");
}
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
const error: string | null = 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";
}
const t = await getTranslations();
const { target } = await getBrowserTargetForRequest();
const error = target ? null : t("browserGatewayNoResourceForDomain");
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<RdpClient target={target} error={error} />
<RdpClient
target={target}
error={error}
primaryColor={primaryColor}
/>
</div>
</div>
<AuthFooter />

View File

@@ -2,10 +2,19 @@
import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@app/components/ui/button";
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 {
Card,
@@ -15,15 +24,18 @@ import {
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@app/lib/cn";
import { ExternalLink, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { useTranslations } from "next-intl";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
type AuthTab = "password" | "privateKey";
type FormState = {
type SshCredentialsForm = {
username: string;
password: string;
privateKey: string;
@@ -36,32 +48,46 @@ type ConnectCredentials = {
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({
target,
error,
signedKeyData,
privateKey: signedPrivateKey
privateKey: signedPrivateKey,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
primaryColor?: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const t = useTranslations();
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
const passwordTabSchema = z.object({
username: z.string().min(1, { message: t("usernameRequired") }),
password: z.string().min(1, { message: t("passwordRequired") })
});
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>) {
const file = e.target.files?.[0];
@@ -70,11 +96,10 @@ export default function SshClient({
reader.onload = (ev) => {
const text = ev.target?.result;
if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text }));
form.setValue("privateKey", text, { shouldDirty: true });
}
};
reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = "";
}
@@ -126,14 +151,12 @@ export default function SshClient({
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data }));
}
});
// Send resize events.
terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
@@ -142,7 +165,6 @@ export default function SshClient({
}
});
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
@@ -156,14 +178,12 @@ export default function SshClient({
};
}, [connected]);
// Refit terminal when the window resizes.
useEffect(() => {
const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
// Cleanup on unmount.
useEffect(() => {
return () => {
wsRef.current?.close();
@@ -171,7 +191,6 @@ export default function SshClient({
};
}, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
@@ -182,8 +201,10 @@ export default function SshClient({
}
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null);
function connect(
override?: ConnectCredentials,
authMethod: AuthTab = "password"
) {
setConnecting(true);
if (!target) {
@@ -192,12 +213,14 @@ export default function SshClient({
return;
}
const username = override?.username ?? form.username;
const values = form.getValues();
const username = override?.username ?? values.username;
const password =
override?.password ?? (authTab === "password" ? form.password : "");
override?.password ??
(authMethod === "password" ? values.password : "");
const privateKey =
override?.privateKey ??
(authTab === "privateKey" ? form.privateKey : "");
(authMethod === "privateKey" ? values.privateKey : "");
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
@@ -216,16 +239,10 @@ export default function SshClient({
const ws = new WebSocket(url.toString(), ["ssh"]);
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 authErrorShown = false;
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(
JSON.stringify({
type: "auth",
@@ -236,7 +253,10 @@ export default function SshClient({
);
if (!override) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(form.getValues())
);
} catch {
// ignore
}
@@ -260,7 +280,6 @@ export default function SshClient({
xtermRef.current?.write(msg.data);
} else if (msg.type === "error") {
if (!authConfirmed) {
// Auth-phase error — show in the login form.
authErrorShown = true;
setConnecting(false);
setConnectError(
@@ -268,7 +287,7 @@ export default function SshClient({
);
} else {
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`
);
}
}
@@ -281,13 +300,13 @@ export default function SshClient({
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => {
evt.data.text().then((text) => {
if (!authConfirmed) {
authConfirmed = true;
setConnecting(false);
setConnected(true);
}
xtermRef.current?.write(t);
xtermRef.current?.write(text);
});
}
};
@@ -303,11 +322,9 @@ export default function SshClient({
if (authConfirmed) {
setConnected(false);
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) {
setConnectError(t("sshErrorConnectionClosed"));
}
@@ -321,7 +338,40 @@ export default function SshClient({
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) {
return (
<>
@@ -350,7 +400,6 @@ export default function SshClient({
variant="destructive"
className="w-full"
>
<AlertCircle className="h-5 w-5" />
<AlertDescription>
{connectError}
</AlertDescription>
@@ -375,202 +424,203 @@ export default function SshClient({
if (error) {
return (
<div>
<div className="text-center mb-2">
<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>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("sshTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<div>
<div className="text-center mb-2">
<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>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("sshSignInTitle")}</CardTitle>
<CardTitle>
{resourceName
? `${t("sshSignInTitle")} - ${resourceName}`
: t("sshSignInTitle")}
</CardTitle>
<CardDescription>
{t("sshSignInDescription")}
{resourceName
? `${t("sshSignInDescription")} (${resourceName})`
: t("sshSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{/* Tab row */}
<div className="flex space-x-4 border-b mb-4">
{(["password", "privateKey"] as const).map(
(tab) => (
<button
key={tab}
type="button"
onClick={() => setAuthTab(tab)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",
authTab === tab
? "text-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:rounded-full"
: "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"
<Form {...form}>
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{
title: t("sshPasswordTab"),
href: "#"
},
{
title: t("sshPrivateKeyTab"),
href: "#"
}
]}
>
{connecting
? t("sshConnecting")
: t("sshAuthenticate")}
</Button>
</div>
<form
onSubmit={onPasswordSubmit}
className="space-y-4 mt-4 p-1"
>
<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>
</Card>
</div>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}
{connected && (
@@ -594,20 +644,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>
);
}

View File

@@ -1,11 +1,15 @@
import { headers } from "next/headers";
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 { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import SshClient from "./SshClient";
import crypto from "crypto";
import AuthFooter from "@app/components/AuthFooter";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { getTranslations } from "next-intl/server";
const pollInitialDelayMs = 250;
const pollStartIntervalMs = 250;
@@ -99,14 +103,13 @@ function generateEphemeralKeyPair(): {
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("SSH");
}
export default async function SshPage() {
const t = await getTranslations();
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
const cookieHeader = headersList.get("cookie") || "";
let target: GetBrowserTargetResponse | null = null;
@@ -114,51 +117,49 @@ export default async function SshPage() {
let privateKey: string | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
const { target: browserTarget } = await getBrowserTargetForRequest();
target = browserTarget;
if (target.pamMode === "push") {
try {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resourceId: target.resourceId,
type: "public"
},
{
headers: {
Cookie: cookieHeader
}
if (!target) {
error = t("browserGatewayNoResourceForDomain");
} else if (target.pamMode === "push") {
try {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resourceId: target.resourceId,
type: "public"
},
{
headers: {
Cookie: cookieHeader
}
);
signedKeyData = res.data.data;
}
);
signedKeyData = res.data.data;
const messageIds =
signedKeyData.messageIds.length > 0
? signedKeyData.messageIds
: signedKeyData.messageId
? [signedKeyData.messageId]
: [];
const messageIds =
signedKeyData.messageIds.length > 0
? signedKeyData.messageIds
: signedKeyData.messageId
? [signedKeyData.messageId]
: [];
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error =
"Failed to sign SSH key for PAM push authentication. Did you sign in as a user?";
}
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error = t("sshErrorSignKeyFailed");
}
} 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 (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
@@ -168,6 +169,7 @@ export default async function SshPage() {
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
primaryColor={primaryColor}
/>
</div>
</div>

View File

@@ -1,9 +1,19 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
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 { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import {
@@ -13,40 +23,53 @@ import {
CardTitle,
CardDescription
} 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 AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
type FormState = {
type VncCredentialsForm = {
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({
target,
error
error,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
primaryColor?: string | null;
}) {
const t = useTranslations();
const STORAGE_KEY = "pangolin_vnc_credentials";
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
const formSchema = z.object({
password: z.string()
});
const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
const [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(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 = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
@@ -55,28 +78,20 @@ export default function VncClient({
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []);
const connect = async () => {
const connect = async (values: VncCredentialsForm) => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
setConnectError(t("vncNoResourceTarget"));
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
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 (
target: HTMLElement,
url: string,
@@ -89,14 +104,12 @@ export default function VncClient({
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
title: t("vncFailedToLoadNovnc"),
description: `${err}`
});
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 base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
@@ -106,12 +119,11 @@ export default function VncClient({
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
if (values.password) {
options.credentials = { password: values.password };
}
const rfb: any = new RFB(screenRef.current, wsUrl, options);
@@ -121,7 +133,7 @@ export default function VncClient({
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
@@ -139,92 +151,100 @@ export default function VncClient({
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
disconnect();
setConnectError(
e.detail.reason ??
t("vncAuthFailedStatus", {
status: e.detail.status
})
);
}
);
rfbRef.current = rfb;
};
const onSubmit = (values: VncCredentialsForm) => {
setConnectError(null);
connect(values);
};
if (error) {
return (
<div>
<div className="text-center mb-2">
<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>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>VNC</CardTitle>
<CardTitle>{t("vncTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<div>
<div className="text-center mb-2">
<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>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>VNC</CardTitle>
<CardTitle>
{resourceName
? `${t("vncTitle")} - ${resourceName}`
: t("vncTitle")}
</CardTitle>
<CardDescription>
Enter your credentials to access xxxx
{resourceName
? `${t("vncSignInDescription")} (${resourceName})`
: t("vncSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Field
label="Password (optional)"
id="password"
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("vncPasswordOptional")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
<Button type="submit" className="w-full">
{t("browserGatewayConnect")}
</Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</form>
</Form>
</CardContent>
</Card>
</div>
<AuthPageFooterNotices />
</BrandedAuthSurface>
)}
<div
@@ -241,7 +261,7 @@ export default function VncClient({
}
}}
>
Ctrl+Alt+Del
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
@@ -255,18 +275,17 @@ export default function VncClient({
.catch(() => {});
}}
>
Paste clipboard
{t("vncPasteClipboard")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
{t("sshTerminate")}
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
@@ -276,20 +295,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>
);
}

View File

@@ -1,39 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import VncClient from "./VncClient";
import AuthFooter from "@app/components/AuthFooter";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("VNC");
}
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
const error: string | null = 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";
}
const t = await getTranslations();
const { target } = await getBrowserTargetForRequest();
const error = target ? null : t("browserGatewayNoResourceForDomain");
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<VncClient target={target} error={error} />
<VncClient
target={target}
error={error}
primaryColor={primaryColor}
/>
</div>
</div>
<AuthFooter />

View File

@@ -0,0 +1,40 @@
"use client";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
export default function AuthPageFooterNotices() {
const t = useTranslations();
const { supporterStatus } = useSupporterStatusContext();
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
return (
<>
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t("noSupportKey")}
</span>
</div>
)}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("instanceIsUnlicensed")}
</span>
</div>
) : null}
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("loginPageLicenseWatermark")}
</span>
</div>
) : null}
</>
);
}

View File

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

View File

@@ -1,130 +1,173 @@
"use client";
import { cn } from "@app/lib/cn";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import type { Control, FieldValues, Path } from "react-hook-form";
import { useWatch } from "react-hook-form";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type SingleSiteProps = {
multiSite?: false;
selectedSite: Selectedsite | null;
onSiteChange: (site: Selectedsite | null) => void;
};
type MultiSiteProps = {
multiSite: true;
selectedSites: Selectedsite[];
onSitesChange: (sites: Selectedsite[]) => void;
};
export type BrowserGatewayTargetFormProps = {
type BaseProps<T extends FieldValues> = {
control: Control<T>;
orgId: string;
destination: string;
defaultPort: number;
destinationPort: string;
onDestinationChange: (v: string) => void;
onDestinationPortChange: (v: string) => void;
destinationField: Path<T>;
destinationPortField: Path<T>;
learnMoreHref?: string;
} & (SingleSiteProps | MultiSiteProps);
defaultPort: number;
};
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
type MultiSiteFormProps<T extends FieldValues> = BaseProps<T> & {
multiSite: true;
sitesField: Path<T>;
};
type SingleSiteFormProps<T extends FieldValues> = BaseProps<T> & {
multiSite?: false;
siteField: Path<T>;
};
export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
| MultiSiteFormProps<T>
| SingleSiteFormProps<T>;
export function BrowserGatewayTargetForm<T extends FieldValues>(
props: BrowserGatewayTargetFormProps<T>
) {
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const siteSelector =
props.multiSite === true ? (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{formatMultiSitesSelectorLabel(
props.selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiSitesSelector
orgId={props.orgId}
selectedSites={props.selectedSites}
onSelectionChange={props.onSitesChange}
/>
</PopoverContent>
</Popover>
) : (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{props.selectedSite?.name ?? t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={props.orgId}
selectedSite={props.selectedSite}
onSelectSite={(site) => {
props.onSiteChange(site);
setSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
const sitesFieldName =
props.multiSite === true ? props.sitesField : props.siteField;
const watchedSites = useWatch({
control: props.control,
name: sitesFieldName
});
const showMultiSiteDisclaimer =
props.multiSite === true &&
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("sites")}
</label>
{siteSelector}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
placeholder={props.defaultPort.toString()}
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
}
/>
</div>
<div className="grid grid-cols-3 gap-4 items-start">
<FormField
control={props.control}
name={sitesFieldName}
render={({ field }) => (
<FormItem>
<FormLabel>{t("sites")}</FormLabel>
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
props.multiSite === true
? (
field.value as Selectedsite[]
)?.length === 0 &&
"text-muted-foreground"
: !field.value &&
"text-muted-foreground"
)}
>
<span className="truncate">
{props.multiSite === true
? formatMultiSitesSelectorLabel(
(field.value as Selectedsite[]) ??
[],
t
)
: ((
field.value as Selectedsite | null
)?.name ??
t("siteSelect"))}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
{props.multiSite === true ? (
<MultiSitesSelector
orgId={props.orgId}
selectedSites={
(field.value as Selectedsite[]) ??
[]
}
onSelectionChange={field.onChange}
/>
) : (
<SitesSelector
orgId={props.orgId}
selectedSite={
field.value as Selectedsite | null
}
onSelectSite={(site) => {
field.onChange(site);
setSiteOpen(false);
}}
/>
)}
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.control}
name={props.destinationField}
render={({ field }) => (
<FormItem>
<FormLabel>{t("destination")}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={props.control}
name={props.destinationPortField}
render={({ field }) => (
<FormItem>
<FormLabel>{t("port")}</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{props.multiSite === true && props.selectedSites.length > 1 && (
{showMultiSiteDisclaimer && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a

View File

@@ -23,19 +23,22 @@ import {
isHostname,
type InternalResourceFormValues
} from "./PrivateResourceForm";
import type { Selectedsite } from "./site-selector";
type CreateInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
initialSites?: Selectedsite[];
};
export default function CreatePrivateResourceDialog({
open,
setOpen,
orgId,
onSuccess
onSuccess,
initialSites
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
@@ -175,6 +178,7 @@ export default function CreatePrivateResourceDialog({
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
initialSites={initialSites}
/>
</CredenzaBody>
<CredenzaFooter>

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
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
)}
{...props}

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,10 @@ import {
import { Button } from "@app/components/ui/button";
import Link from "next/link";
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 { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
type OrgLoginPageProps = {
loginPage: LoadLoginPageResponse | undefined;
@@ -52,22 +53,8 @@ export default async function OrgLoginPage({
const env = pullEnv();
const t = await getTranslations();
return (
<div>
{build !== "enterprise" || !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>
) : null}
<BrandedAuthSurface primaryColor={branding?.primaryColor ?? null}>
<PoweredByPangolin />
<Card className="w-full max-w-md">
<CardHeader>
{branding?.logoUrl && (
@@ -127,6 +114,6 @@ export default async function OrgLoginPage({
{t("loginBack")}
</Link>
</p>
</div>
</BrandedAuthSurface>
);
}

View File

@@ -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" />;
}

Some files were not shown because too many files have changed in this diff Show More