mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-08 07:05:08 +02:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d082de3d88 | |||
| 1e7863ce4f | |||
| a942871f3d | |||
| da1205d38e | |||
| 92775a51ca | |||
| ea60aa701e | |||
| b1f7ee02b0 | |||
| affa3bbb6a | |||
| dbb1e7b274 | |||
| b2d1a66279 | |||
| 202466792c | |||
| 4dcf684868 | |||
| 2b8f50af6f | |||
| a8c255ef71 | |||
| d9608bd408 | |||
| be193731c1 | |||
| 0c3edc7560 | |||
| 813a3f07dc | |||
| 417438c209 | |||
| beaa5735ce | |||
| 83de8576bf | |||
| e9d424eb80 | |||
| 8cceed91cf | |||
| 633159fb77 | |||
| 4c8bd4db0a | |||
| d742300e82 | |||
| a521db91a2 | |||
| d4549f1c33 | |||
| 71da22328c | |||
| 0b5b732a48 | |||
| a82bae8f93 | |||
| e59176149b | |||
| 2c3151da9b | |||
| f60b8795ad | |||
| 4054976388 | |||
| 11c30b8b27 | |||
| 390c822bb4 | |||
| 2bfc1901a6 | |||
| 5da186b528 | |||
| 600a96c13b | |||
| e4e0da3723 | |||
| 4087d7fb6b | |||
| 9edb86fd73 | |||
| 440ebfe08e | |||
| b399d2a291 | |||
| 811119a9a6 | |||
| b53f80d317 | |||
| 1b1fba60f1 | |||
| a89509c8bd | |||
| f0546eb622 | |||
| 12f9ac94fd | |||
| 1717a99cee | |||
| b93d26f09f | |||
| fc54ad49b5 | |||
| f87e136f6b | |||
| 8d29602929 | |||
| 6c2a8bf6de | |||
| 697be01411 | |||
| 1bd6f240cc | |||
| 278375f7be | |||
| 65c383520b | |||
| e54bd25516 | |||
| 8f0e17774f | |||
| 2ab5540085 | |||
| 4e7328a1cc | |||
| 6380079239 | |||
| b6aba13d3a | |||
| c0a9db92ef | |||
| 3bc5ab2136 | |||
| ce77268c82 | |||
| abc0a41d9e | |||
| a68b57067c | |||
| 444d293a29 |
+114
-7
@@ -266,6 +266,8 @@
|
|||||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
|
"resourcePublicCreate": "Create Public Resource",
|
||||||
|
"resourcePublicCreateDescription": "Follow the steps below to create a new public resource that is accessible through a web browser",
|
||||||
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
"resourceCreateGeneral": "General",
|
"resourceCreateGeneral": "General",
|
||||||
@@ -613,7 +615,8 @@
|
|||||||
"idpNameInternal": "Internal",
|
"idpNameInternal": "Internal",
|
||||||
"emailInvalid": "Invalid email address",
|
"emailInvalid": "Invalid email address",
|
||||||
"inviteValidityDuration": "Please select a duration",
|
"inviteValidityDuration": "Please select a duration",
|
||||||
"accessRoleSelectPlease": "Please select a role",
|
"accessRoleSelectPlease": "A user must belong to at least one role.",
|
||||||
|
"accessRoleRequired": "Role required",
|
||||||
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
||||||
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
||||||
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
||||||
@@ -864,8 +867,8 @@
|
|||||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||||
"policyAuthHeaderAuthSummary": "Header configured",
|
"policyAuthHeaderAuthSummary": "Header configured",
|
||||||
"policyAuthHeaderName": "Header name",
|
"policyAuthHeaderName": "Username",
|
||||||
"policyAuthHeaderValue": "Expected value",
|
"policyAuthHeaderValue": "Password",
|
||||||
"policyAuthSetPasscode": "Set Passcode",
|
"policyAuthSetPasscode": "Set Passcode",
|
||||||
"policyAuthSetPincode": "Set PIN Code",
|
"policyAuthSetPincode": "Set PIN Code",
|
||||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||||
@@ -1431,6 +1434,15 @@
|
|||||||
"actionSetResourcePincode": "Set Resource Pincode",
|
"actionSetResourcePincode": "Set Resource Pincode",
|
||||||
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
||||||
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
||||||
|
"actionGetResourcePolicy": "Get Resource Policy",
|
||||||
|
"actionUpdateResourcePolicy": "Update Resource Policy",
|
||||||
|
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
|
||||||
|
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
|
||||||
|
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
|
||||||
|
"actionSetResourcePolicyPincode": "Set Resource Policy Pincode",
|
||||||
|
"actionSetResourcePolicyHeaderAuth": "Set Resource Policy Header Authentication",
|
||||||
|
"actionSetResourcePolicyWhitelist": "Set Resource Policy Email Whitelist",
|
||||||
|
"actionSetResourcePolicyRules": "Set Resource Policy Rules",
|
||||||
"actionCreateTarget": "Create Target",
|
"actionCreateTarget": "Create Target",
|
||||||
"actionDeleteTarget": "Delete Target",
|
"actionDeleteTarget": "Delete Target",
|
||||||
"actionGetTarget": "Get Target",
|
"actionGetTarget": "Get Target",
|
||||||
@@ -1507,6 +1519,30 @@
|
|||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
"navbarDocsLink": "Documentation",
|
"navbarDocsLink": "Documentation",
|
||||||
|
"commandPaletteTitle": "Command Palette",
|
||||||
|
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
||||||
|
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
||||||
|
"commandPaletteNoResults": "No results found.",
|
||||||
|
"commandPaletteSearching": "Searching...",
|
||||||
|
"commandPaletteNavigation": "Navigation",
|
||||||
|
"commandPaletteOrganizations": "Organizations",
|
||||||
|
"commandPaletteSites": "Sites",
|
||||||
|
"commandPaletteResources": "Resources",
|
||||||
|
"commandPaletteUsers": "Users",
|
||||||
|
"commandPaletteClients": "Machine Clients",
|
||||||
|
"commandPaletteActions": "Actions",
|
||||||
|
"commandPaletteCreateSite": "Create Site",
|
||||||
|
"commandPaletteCreateProxyResource": "Create Public Resource",
|
||||||
|
"commandPaletteCreatePrivateResource": "Create Private Resource",
|
||||||
|
"commandPaletteCreateUser": "Create User",
|
||||||
|
"commandPaletteCreateApiKey": "Create API Key",
|
||||||
|
"commandPaletteCreateMachineClient": "Create Machine Client",
|
||||||
|
"commandPaletteCreateAlertRule": "Create Alert Rule",
|
||||||
|
"commandPaletteCreateIdentityProvider": "Create Identity Provider",
|
||||||
|
"commandPaletteToggleTheme": "Toggle Theme",
|
||||||
|
"commandPaletteChooseOrganization": "Choose Organization",
|
||||||
|
"commandPaletteShortcutMac": "⌘K",
|
||||||
|
"commandPaletteShortcutWindows": "Ctrl K",
|
||||||
"otpErrorEnable": "Unable to enable 2FA",
|
"otpErrorEnable": "Unable to enable 2FA",
|
||||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||||
"otpSetupCheckCode": "Please enter a 6-digit code",
|
"otpSetupCheckCode": "Please enter a 6-digit code",
|
||||||
@@ -1584,6 +1620,45 @@
|
|||||||
"sidebarManagement": "Management",
|
"sidebarManagement": "Management",
|
||||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
|
"commandSites": "Sites",
|
||||||
|
"commandActionModeInfo": "Type \">\" To Open Action Mode",
|
||||||
|
"commandResources": "Resources",
|
||||||
|
"commandProxyResources": "Public Resources",
|
||||||
|
"commandClientResources": "Private Resources",
|
||||||
|
"commandClients": "Clients",
|
||||||
|
"commandUserDevices": "User Devices",
|
||||||
|
"commandMachineClients": "Machine Clients",
|
||||||
|
"commandDomains": "Domains",
|
||||||
|
"commandRemoteExitNodes": "Remote Nodes",
|
||||||
|
"commandTeam": "Team",
|
||||||
|
"commandUsers": "Users",
|
||||||
|
"commandRoles": "Roles",
|
||||||
|
"commandInvitations": "Invitations",
|
||||||
|
"commandPolicies": "Shared Policies",
|
||||||
|
"commandResourcePolicies": "Public Resources Policies",
|
||||||
|
"commandIdentityProviders": "Identity Providers",
|
||||||
|
"commandApprovals": "Approval Requests",
|
||||||
|
"commandShareableLinks": "Shareable Links",
|
||||||
|
"commandOrganization": "Organization",
|
||||||
|
"commandLogsAndAnalytics": "Logs & Analytics",
|
||||||
|
"commandLogsAnalytics": "Analytics",
|
||||||
|
"commandLogsRequest": "HTTP Request Logs",
|
||||||
|
"commandLogsAccess": "Authentication Logs",
|
||||||
|
"commandLogsAction": "Admin Action Logs",
|
||||||
|
"commandLogsConnection": "Network Logs",
|
||||||
|
"commandLogsStreaming": "Event Streaming",
|
||||||
|
"commandManagement": "Management",
|
||||||
|
"commandAlerting": "Alerting",
|
||||||
|
"commandProvisioning": "Provisioning",
|
||||||
|
"commandBluePrints": "Blueprints",
|
||||||
|
"commandApiKeys": "API Keys",
|
||||||
|
"commandBillingAndLicenses": "Billing & Licenses",
|
||||||
|
"commandBilling": "Billing",
|
||||||
|
"commandEnterpriseLicenses": "Licenses",
|
||||||
|
"commandSettings": "Settings",
|
||||||
|
"commandLauncher": "Launcher",
|
||||||
|
"commandResourceLauncher": "Resource Launcher",
|
||||||
|
"commandSearchResults": "Search Results",
|
||||||
"alertingTitle": "Alerting",
|
"alertingTitle": "Alerting",
|
||||||
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
||||||
"alertingRules": "Alert rules",
|
"alertingRules": "Alert rules",
|
||||||
@@ -2317,6 +2392,12 @@
|
|||||||
"createInternalResourceDialogClose": "Close",
|
"createInternalResourceDialogClose": "Close",
|
||||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
||||||
|
"privateResourceCreatePageSeeAll": "See All Private Resources",
|
||||||
|
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
|
||||||
|
"privateResourceNetworkAccess": "Network Access",
|
||||||
|
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
|
||||||
|
"hostSettings": "Host Settings",
|
||||||
|
"cidrSettings": "CIDR Settings",
|
||||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||||
"createInternalResourceDialogName": "Name",
|
"createInternalResourceDialogName": "Name",
|
||||||
"createInternalResourceDialogSite": "Site",
|
"createInternalResourceDialogSite": "Site",
|
||||||
@@ -2465,6 +2546,7 @@
|
|||||||
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
|
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
|
||||||
"exitNode": "Exit Node",
|
"exitNode": "Exit Node",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
|
"countryIsNot": "Country Is Not",
|
||||||
"rulesMatchCountry": "Currently based on source IP",
|
"rulesMatchCountry": "Currently based on source IP",
|
||||||
"region": "Region",
|
"region": "Region",
|
||||||
"selectRegion": "Select region",
|
"selectRegion": "Select region",
|
||||||
@@ -3183,7 +3265,7 @@
|
|||||||
"editInternalResourceDialogAddUsers": "Add Users",
|
"editInternalResourceDialogAddUsers": "Add Users",
|
||||||
"editInternalResourceDialogAddClients": "Add Clients",
|
"editInternalResourceDialogAddClients": "Add Clients",
|
||||||
"editInternalResourceDialogDestinationLabel": "Destination",
|
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||||
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
|
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it",
|
||||||
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||||
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
|
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
|
||||||
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
||||||
@@ -3577,18 +3659,24 @@
|
|||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"resourceLauncherTitle": "Resource Launcher",
|
"resourceLauncherTitle": "Resource Launcher",
|
||||||
"resourceLauncherDescription": "View resource details and launch them from one place",
|
"resourceSidebarLauncherTitle": "Launcher",
|
||||||
"resourceLauncherSearchPlaceholder": "Search all sites...",
|
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||||
|
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||||
"resourceLauncherDefaultView": "Default",
|
"resourceLauncherDefaultView": "Default",
|
||||||
"resourceLauncherSaveView": "Save View",
|
"resourceLauncherSaveView": "Save View",
|
||||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
||||||
|
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
||||||
"resourceLauncherResetView": "Reset View",
|
"resourceLauncherResetView": "Reset View",
|
||||||
|
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
||||||
|
"resourceLauncherSystemDefaultRestored": "System default restored",
|
||||||
|
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
||||||
"resourceLauncherSaveAsNewView": "Save as New View",
|
"resourceLauncherSaveAsNewView": "Save as New View",
|
||||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
||||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
||||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
||||||
"resourceLauncherMakePersonal": "Make Personal",
|
"resourceLauncherMakePersonal": "Make Personal",
|
||||||
"resourceLauncherFilter": "Filter",
|
"resourceLauncherFilter": "Filter",
|
||||||
|
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
||||||
"resourceLauncherSort": "Sort",
|
"resourceLauncherSort": "Sort",
|
||||||
"resourceLauncherSortAscending": "Sort ascending",
|
"resourceLauncherSortAscending": "Sort ascending",
|
||||||
"resourceLauncherSortDescending": "Sort descending",
|
"resourceLauncherSortDescending": "Sort descending",
|
||||||
@@ -3596,6 +3684,7 @@
|
|||||||
"resourceLauncherGroupBy": "Group By",
|
"resourceLauncherGroupBy": "Group By",
|
||||||
"resourceLauncherGroupBySite": "Site",
|
"resourceLauncherGroupBySite": "Site",
|
||||||
"resourceLauncherGroupByLabel": "Label",
|
"resourceLauncherGroupByLabel": "Label",
|
||||||
|
"resourceLauncherGroupByNone": "None",
|
||||||
"resourceLauncherLayout": "Layout",
|
"resourceLauncherLayout": "Layout",
|
||||||
"resourceLauncherLayoutGrid": "Grid",
|
"resourceLauncherLayoutGrid": "Grid",
|
||||||
"resourceLauncherLayoutList": "List",
|
"resourceLauncherLayoutList": "List",
|
||||||
@@ -3603,8 +3692,20 @@
|
|||||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
"resourceLauncherShowSiteTags": "Show Site Tags",
|
||||||
"resourceLauncherShowRecents": "Show Recents",
|
"resourceLauncherShowRecents": "Show Recents",
|
||||||
"resourceLauncherDeleteView": "Delete View",
|
"resourceLauncherDeleteView": "Delete View",
|
||||||
|
"resourceLauncherDeleteViewTitle": "Delete View",
|
||||||
|
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||||
|
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
|
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||||
|
"resourceLauncherResourceDetails": "Resource Details",
|
||||||
|
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||||
|
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||||
|
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||||
|
"resourceLauncherDownloadClient": "Download client",
|
||||||
|
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||||
|
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||||
|
"resourceLauncherTcp": "TCP",
|
||||||
|
"resourceLauncherUdp": "UDP",
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
"resourceLauncherUnlabeled": "Unlabeled",
|
||||||
"resourceLauncherNoSite": "No Site",
|
"resourceLauncherNoSite": "No Site",
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||||
@@ -3613,6 +3714,12 @@
|
|||||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
||||||
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
||||||
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
||||||
|
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
||||||
|
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
||||||
|
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
||||||
|
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
||||||
|
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
||||||
|
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
||||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
"resourceLauncherViewNamePlaceholder": "View name",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import config from "@server/lib/config";
|
||||||
import { Pool, PoolConfig } from "pg";
|
import { Pool, PoolConfig } from "pg";
|
||||||
|
|
||||||
export function createPoolConfig(
|
export function createPoolConfig(
|
||||||
@@ -39,6 +40,28 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Disable JIT compilation for this connection. Our hot-path queries
|
||||||
|
// (e.g. resource-by-domain lookups) join many tables but only ever
|
||||||
|
// return a handful of rows. When planner row estimates drift (e.g.
|
||||||
|
// due to autovacuum lag under write-heavy load), Postgres decides
|
||||||
|
// these plans are expensive enough to JIT-compile, which can add
|
||||||
|
// multiple seconds of pure compilation overhead per query and
|
||||||
|
// saturate the connection pool. JIT never pays off for these
|
||||||
|
// short-lived OLTP queries, so it's disabled outright rather than
|
||||||
|
// relying on statistics staying fresh.
|
||||||
|
//
|
||||||
|
// Set via a runtime SET command rather than the `options: "-c
|
||||||
|
// jit=off"` startup parameter: connections in SaaS mode go through
|
||||||
|
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
|
||||||
|
// options with a protocol_violation (08P01) error.
|
||||||
|
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
|
||||||
|
client.query("SET jit = off").catch((err: Error) => {
|
||||||
|
console.warn(
|
||||||
|
`Failed to set jit=off on ${label} client: ${err.message}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", {
|
|||||||
}),
|
}),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
config: text("config").notNull(),
|
config: text("config").notNull(),
|
||||||
|
isDefault: boolean("isDefault").notNull().default(false),
|
||||||
createdAt: varchar("createdAt").notNull(),
|
createdAt: varchar("createdAt").notNull(),
|
||||||
updatedAt: varchar("updatedAt").notNull()
|
updatedAt: varchar("updatedAt").notNull()
|
||||||
});
|
});
|
||||||
@@ -998,7 +999,17 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
|
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: varchar("match").notNull(), // CIDR, PATH, IP
|
match: varchar("match")
|
||||||
|
.$type<
|
||||||
|
| "CIDR"
|
||||||
|
| "PATH"
|
||||||
|
| "IP"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
|
>()
|
||||||
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1013,7 +1024,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
|||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: varchar("match")
|
match: varchar("match")
|
||||||
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
.$type<
|
||||||
|
| "CIDR"
|
||||||
|
| "PATH"
|
||||||
|
| "IP"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
|
>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
});
|
});
|
||||||
@@ -1593,3 +1612,4 @@ export type LauncherView = InferSelectModel<typeof launcherViews>;
|
|||||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
|
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", {
|
|||||||
}),
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
config: text("config").notNull(),
|
config: text("config").notNull(),
|
||||||
|
isDefault: integer("isDefault", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
createdAt: text("createdAt").notNull(),
|
createdAt: text("createdAt").notNull(),
|
||||||
updatedAt: text("updatedAt").notNull()
|
updatedAt: text("updatedAt").notNull()
|
||||||
});
|
});
|
||||||
@@ -1221,7 +1224,17 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: text("match").notNull(), // CIDR, PATH, IP
|
match: text("match")
|
||||||
|
.$type<
|
||||||
|
| "CIDR"
|
||||||
|
| "PATH"
|
||||||
|
| "IP"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
|
>()
|
||||||
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1268,7 +1281,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: text("match")
|
match: text("match")
|
||||||
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
.$type<
|
||||||
|
| "CIDR"
|
||||||
|
| "PATH"
|
||||||
|
| "IP"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
|
>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export enum TierFeature {
|
|||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
Labels = "labels",
|
|
||||||
NewtAutoUpdate = "newtAutoUpdate",
|
NewtAutoUpdate = "newtAutoUpdate",
|
||||||
ResourcePolicies = "resourcePolicies",
|
ResourcePolicies = "resourcePolicies",
|
||||||
AdvancedPublicResources = "advancedPublicResources",
|
AdvancedPublicResources = "advancedPublicResources",
|
||||||
@@ -31,7 +30,6 @@ export enum TierFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
|
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update database from config: ${error}`);
|
logger.debug(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,56 +1,54 @@
|
|||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
domains,
|
|
||||||
domainNamespaces,
|
domainNamespaces,
|
||||||
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
Resource,
|
Resource,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
|
resources,
|
||||||
resourceWhitelist,
|
resourceWhitelist,
|
||||||
roleActions,
|
roleActions,
|
||||||
|
rolePolicies,
|
||||||
roleResources,
|
roleResources,
|
||||||
roles,
|
roles,
|
||||||
|
sites,
|
||||||
Target,
|
Target,
|
||||||
TargetHealthCheck,
|
TargetHealthCheck,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
|
targets,
|
||||||
Transaction,
|
Transaction,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
|
userPolicies,
|
||||||
userResources,
|
userResources,
|
||||||
users,
|
users,
|
||||||
resourcePolicies,
|
type ResourceRule
|
||||||
resourcePolicyPassword,
|
|
||||||
resourcePolicyPincode,
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
resourcePolicyRules,
|
|
||||||
resourcePolicyWhiteList,
|
|
||||||
rolePolicies,
|
|
||||||
userPolicies
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { resources, targets, sites } from "@server/db";
|
|
||||||
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
Config,
|
|
||||||
ConfigSchema,
|
|
||||||
isTargetsOnlyResource,
|
|
||||||
TargetData
|
|
||||||
} from "./types";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
|
||||||
import { pickPort } from "@server/routers/target/helpers";
|
|
||||||
import { resourcePassword } from "@server/db";
|
|
||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
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";
|
import serverConfig from "@server/lib/config";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
|
import { pickPort } from "@server/routers/target/helpers";
|
||||||
|
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
|
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import next from "next";
|
import next from "next";
|
||||||
@@ -909,7 +907,7 @@ export async function updatePublicResources(
|
|||||||
.update(resourceRules)
|
.update(resourceRules)
|
||||||
.set({
|
.set({
|
||||||
action: getRuleAction(rule.action),
|
action: getRuleAction(rule.action),
|
||||||
match: rule.match.toUpperCase(),
|
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||||
value: getRuleValue(
|
value: getRuleValue(
|
||||||
rule.match.toUpperCase(),
|
rule.match.toUpperCase(),
|
||||||
rule.value
|
rule.value
|
||||||
@@ -928,7 +926,7 @@ export async function updatePublicResources(
|
|||||||
await trx.insert(resourceRules).values({
|
await trx.insert(resourceRules).values({
|
||||||
resourceId: existingResource.resourceId,
|
resourceId: existingResource.resourceId,
|
||||||
action: getRuleAction(rule.action),
|
action: getRuleAction(rule.action),
|
||||||
match: rule.match.toUpperCase(),
|
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||||
value: getRuleValue(
|
value: getRuleValue(
|
||||||
rule.match.toUpperCase(),
|
rule.match.toUpperCase(),
|
||||||
rule.value
|
rule.value
|
||||||
@@ -1011,7 +1009,7 @@ export async function updatePublicResources(
|
|||||||
} else {
|
} else {
|
||||||
// create a brand new resource
|
// create a brand new resource
|
||||||
|
|
||||||
if (build == "saas") {
|
if (build === "saas") {
|
||||||
const usage = await usageService.getUsage(
|
const usage = await usageService.getUsage(
|
||||||
orgId,
|
orgId,
|
||||||
LimitId.PUBLIC_RESOURCES
|
LimitId.PUBLIC_RESOURCES
|
||||||
@@ -1292,7 +1290,7 @@ export async function updatePublicResources(
|
|||||||
await trx.insert(resourceRules).values({
|
await trx.insert(resourceRules).values({
|
||||||
resourceId: newResource.resourceId,
|
resourceId: newResource.resourceId,
|
||||||
action: getRuleAction(rule.action),
|
action: getRuleAction(rule.action),
|
||||||
match: rule.match.toUpperCase(),
|
match: rule.match.toUpperCase() as ResourceRule["match"],
|
||||||
value: getRuleValue(
|
value: getRuleValue(
|
||||||
rule.match.toUpperCase(),
|
rule.match.toUpperCase(),
|
||||||
rule.value
|
rule.value
|
||||||
@@ -1355,7 +1353,7 @@ function getRuleAction(input: string) {
|
|||||||
|
|
||||||
function getRuleValue(match: string, value: string) {
|
function getRuleValue(match: string, value: string) {
|
||||||
// if the match is a country, uppercase the value
|
// if the match is a country, uppercase the value
|
||||||
if (match == "COUNTRY") {
|
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
||||||
return value.toUpperCase();
|
return value.toUpperCase();
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
|||||||
|
|
||||||
function getRuleMatch(
|
function getRuleMatch(
|
||||||
input: string
|
input: string
|
||||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
|
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
|
||||||
return input.toUpperCase() as
|
return input.toUpperCase() as
|
||||||
| "CIDR"
|
| "CIDR"
|
||||||
| "IP"
|
| "IP"
|
||||||
| "PATH"
|
| "PATH"
|
||||||
| "COUNTRY"
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION";
|
| "REGION";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.19.0";
|
export const APP_VERSION = "1.20.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
||||||
|
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
|
||||||
|
* retry: the database guarantees exactly one winner per deadlock pair, so the
|
||||||
|
* loser just needs to try again.
|
||||||
|
*/
|
||||||
|
export function isTransientError(error: any): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const message = (error.message || "").toLowerCase();
|
||||||
|
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||||
|
const code = error.code || error.cause?.code || "";
|
||||||
|
|
||||||
|
// Connection timeout / terminated
|
||||||
|
if (
|
||||||
|
message.includes("connection timeout") ||
|
||||||
|
message.includes("connection terminated") ||
|
||||||
|
message.includes("timeout exceeded when trying to connect") ||
|
||||||
|
causeMessage.includes("connection terminated unexpectedly") ||
|
||||||
|
causeMessage.includes("connection timeout")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||||
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL serialization failure
|
||||||
|
if (code === "40001") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||||
|
if (
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ETIMEDOUT"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
|
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||||
|
*/
|
||||||
|
export async function withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string,
|
||||||
|
maxRetries: number = MAX_RETRIES,
|
||||||
|
baseDelayMs: number = BASE_DELAY_MS
|
||||||
|
): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isTransientError(error) && attempt < maxRetries) {
|
||||||
|
attempt++;
|
||||||
|
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
||||||
|
const jitter = Math.random() * baseDelay;
|
||||||
|
const delay = baseDelay + jitter;
|
||||||
|
logger.warn(
|
||||||
|
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||||
|
{ code: error?.code ?? error?.cause?.code }
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
import { db, exitNodes } from "@server/db";
|
import { db, exitNodes, Transaction } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
export async function getNextAvailableSubnet(): Promise<string> {
|
/**
|
||||||
|
* Reserves the next available exit node subnet.
|
||||||
|
*
|
||||||
|
* Exit node subnets must never overlap with one another - regardless of
|
||||||
|
* which org(s) they belong to - since HA exit nodes can end up routing for
|
||||||
|
* the same org. This acquires a lock that the caller MUST release (via the
|
||||||
|
* returned `release`) only after the chosen address has been durably
|
||||||
|
* persisted (e.g. after the enclosing transaction commits), otherwise
|
||||||
|
* concurrent callers can race and pick the same subnet.
|
||||||
|
*/
|
||||||
|
export async function getNextAvailableSubnet(
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||||
|
const lockKey = "exit-node-subnet-allocation";
|
||||||
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
|
if (!acquired) {
|
||||||
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
|
}
|
||||||
|
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||||
|
|
||||||
|
try {
|
||||||
// Get all existing subnets from routes table
|
// Get all existing subnets from routes table
|
||||||
const existingAddresses = await db
|
const existingAddresses = await trx
|
||||||
.select({
|
.select({
|
||||||
address: exitNodes.address
|
address: exitNodes.address
|
||||||
})
|
})
|
||||||
@@ -26,5 +47,9 @@ export async function getNextAvailableSubnet(): Promise<string> {
|
|||||||
".1" +
|
".1" +
|
||||||
"/" +
|
"/" +
|
||||||
subnet.split("/")[1];
|
subnet.split("/")[1];
|
||||||
return subnet;
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ export const configSchema = z
|
|||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default(5000)
|
.default(5000),
|
||||||
|
jit_mode: z.boolean().default(true)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({})
|
.prefault({})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
||||||
|
import { withRetry, isTransientError } from "@server/lib/dbRetry";
|
||||||
import {
|
import {
|
||||||
checkOrgRebuildRateLimit,
|
checkOrgRebuildRateLimit,
|
||||||
decrementOrgRebuildCount,
|
decrementOrgRebuildCount,
|
||||||
@@ -285,10 +286,20 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
) {
|
) {
|
||||||
await incrementOrgRebuildCount(siteResource.orgId);
|
await incrementOrgRebuildCount(siteResource.orgId);
|
||||||
try {
|
try {
|
||||||
return await lockManager.withLock(
|
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
||||||
|
// actual state each time), so on a transient DB error it's safe to
|
||||||
|
// retry the entire thing rather than just the failed query.
|
||||||
|
return await withRetry(
|
||||||
|
() =>
|
||||||
|
lockManager.withLock(
|
||||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||||
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
|
() =>
|
||||||
|
rebuildClientAssociationsFromSiteResourceImpl(
|
||||||
|
siteResource
|
||||||
|
),
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
),
|
||||||
|
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
@@ -304,6 +315,17 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
});
|
});
|
||||||
return { mergedAllClients: [] };
|
return { mergedAllClients: [] };
|
||||||
}
|
}
|
||||||
|
if (isTransientError(err)) {
|
||||||
|
logger.warn(
|
||||||
|
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
await rebuildQueue.enqueue({
|
||||||
|
type: "site-resource",
|
||||||
|
id: siteResource.siteResourceId
|
||||||
|
});
|
||||||
|
return { mergedAllClients: [] };
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await decrementOrgRebuildCount(siteResource.orgId);
|
await decrementOrgRebuildCount(siteResource.orgId);
|
||||||
@@ -463,6 +485,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
await trx
|
await trx
|
||||||
.insert(clientSiteResourcesAssociationsCache)
|
.insert(clientSiteResourcesAssociationsCache)
|
||||||
.values(clientSiteResourcesToInsert)
|
.values(clientSiteResourcesToInsert)
|
||||||
|
.onConflictDoNothing()
|
||||||
.returning();
|
.returning();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||||
@@ -510,6 +533,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
for (const site of sitesToProcess) {
|
for (const site of sitesToProcess) {
|
||||||
const siteId = site.siteId;
|
const siteId = site.siteId;
|
||||||
|
|
||||||
|
try {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||||
);
|
);
|
||||||
@@ -539,11 +563,14 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
subnet: clients.subnet
|
subnet: clients.subnet
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(inArray(clients.clientId, existingClientSiteIds))
|
.where(
|
||||||
|
inArray(clients.clientId, existingClientSiteIds)
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const otherResourceClientIds =
|
const otherResourceClientIds =
|
||||||
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
clientsFromOtherResourcesBySite.get(siteId) ??
|
||||||
|
new Set<number>();
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||||
@@ -555,10 +582,17 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
? mergedAllClientIds
|
? mergedAllClientIds
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Note: we deliberately do NOT exclude clients covered by another
|
||||||
|
// site resource here (unlike clientSitesToRemove below). Doing so
|
||||||
|
// previously caused a permanent gap: if resource A saw resource B's
|
||||||
|
// cache row and skipped adding (assuming B would maintain it), and
|
||||||
|
// B's own rebuild made the same assumption about A, the site-level
|
||||||
|
// row could end up never inserted by anyone even though both
|
||||||
|
// resources' client associations were otherwise correct.
|
||||||
|
// onConflictDoNothing makes a redundant insert harmless, so there's
|
||||||
|
// no correctness reason to skip here.
|
||||||
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
||||||
(clientId) =>
|
(clientId) => !existingClientSiteIds.includes(clientId)
|
||||||
!existingClientSiteIds.includes(clientId) &&
|
|
||||||
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||||
@@ -577,6 +611,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
await trx
|
await trx
|
||||||
.insert(clientSitesAssociationsCache)
|
.insert(clientSitesAssociationsCache)
|
||||||
.values(clientSitesToInsert)
|
.values(clientSitesToInsert)
|
||||||
|
.onConflictDoNothing()
|
||||||
.returning();
|
.returning();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||||
@@ -625,6 +660,21 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
clientSitesToRemove,
|
clientSitesToRemove,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Don't let a failure on one site abort processing of every
|
||||||
|
// other site queued after it in this run. Since we're not
|
||||||
|
// re-throwing, the outer wrapper's retry/requeue logic never
|
||||||
|
// sees this failure, so explicitly queue this resource for a
|
||||||
|
// follow-up pass to reconcile whatever this site didn't get to.
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
await rebuildQueue.enqueue({
|
||||||
|
type: "site-resource",
|
||||||
|
id: siteResource.siteResourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle subnet proxy target updates for the resource associations
|
// Handle subnet proxy target updates for the resource associations
|
||||||
@@ -657,7 +707,7 @@ async function handleMessagesForSiteClients(
|
|||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -671,14 +721,14 @@ async function handleMessagesForSiteClients(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site.publicKey) {
|
if (!site.publicKey) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -692,7 +742,7 @@ async function handleMessagesForSiteClients(
|
|||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Newt not found for site ${siteId} so cannot add peers to clients`
|
`Newt not found for site ${siteId} so cannot add peers to clients`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -917,7 +967,7 @@ export async function updateClientSiteDestinations(
|
|||||||
|
|
||||||
for (const site of sitesData) {
|
for (const site of sitesData) {
|
||||||
if (!site.sites.subnet) {
|
if (!site.sites.subnet) {
|
||||||
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
|
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,10 +1706,17 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
await incrementOrgRebuildCount(client.orgId);
|
await incrementOrgRebuildCount(client.orgId);
|
||||||
try {
|
try {
|
||||||
const trx = primaryDb;
|
const trx = primaryDb;
|
||||||
return await lockManager.withLock(
|
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
||||||
|
// actual state each time), so on a transient DB error it's safe to
|
||||||
|
// retry the entire thing rather than just the failed query.
|
||||||
|
return await withRetry(
|
||||||
|
() =>
|
||||||
|
lockManager.withLock(
|
||||||
`rebuild-client-associations:client:${client.clientId}`,
|
`rebuild-client-associations:client:${client.clientId}`,
|
||||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
),
|
||||||
|
`rebuildClientAssociationsFromClient:${client.clientId}`
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
@@ -1675,6 +1732,17 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isTransientError(err)) {
|
||||||
|
logger.warn(
|
||||||
|
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
await rebuildQueue.enqueue({
|
||||||
|
type: "client",
|
||||||
|
id: client.clientId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await decrementOrgRebuildCount(client.orgId);
|
await decrementOrgRebuildCount(client.orgId);
|
||||||
@@ -1826,12 +1894,15 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
|
|
||||||
// Insert new associations
|
// Insert new associations
|
||||||
if (resourcesToAdd.length > 0) {
|
if (resourcesToAdd.length > 0) {
|
||||||
await trx.insert(clientSiteResourcesAssociationsCache).values(
|
await trx
|
||||||
|
.insert(clientSiteResourcesAssociationsCache)
|
||||||
|
.values(
|
||||||
resourcesToAdd.map((siteResourceId) => ({
|
resourcesToAdd.map((siteResourceId) => ({
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
siteResourceId
|
siteResourceId
|
||||||
}))
|
}))
|
||||||
);
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old associations
|
// Remove old associations
|
||||||
@@ -1869,12 +1940,15 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
|
|
||||||
// Insert new site associations
|
// Insert new site associations
|
||||||
if (sitesToAdd.length > 0) {
|
if (sitesToAdd.length > 0) {
|
||||||
await trx.insert(clientSitesAssociationsCache).values(
|
await trx
|
||||||
|
.insert(clientSitesAssociationsCache)
|
||||||
|
.values(
|
||||||
sitesToAdd.map((siteId) => ({
|
sitesToAdd.map((siteId) => ({
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
siteId
|
siteId
|
||||||
}))
|
}))
|
||||||
);
|
)
|
||||||
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old site associations
|
// Remove old site associations
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { isTransientError } from "@server/lib/dbRetry";
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
export type RebuildJobType = "site-resource" | "client";
|
||||||
|
|
||||||
export interface RebuildJob {
|
export interface RebuildJob {
|
||||||
type: RebuildJobType;
|
type: RebuildJobType;
|
||||||
id: number;
|
id: number;
|
||||||
|
// Number of times this job has already been re-queued after a transient
|
||||||
|
// failure. Absent/0 means it has not failed yet.
|
||||||
|
attempt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
export interface RebuildJobHandlers {
|
||||||
@@ -24,6 +28,10 @@ export interface RebuildQueueManager {
|
|||||||
// retried shortly after against fresh DB state.
|
// retried shortly after against fresh DB state.
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
const BATCH_SIZE = 5;
|
const BATCH_SIZE = 5;
|
||||||
|
// A job that fails with a transient DB error gets re-queued with backoff
|
||||||
|
// instead of being dropped, up to this many times.
|
||||||
|
const MAX_JOB_ATTEMPTS = 5;
|
||||||
|
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
||||||
|
|
||||||
function dedupeKey(job: RebuildJob): string {
|
function dedupeKey(job: RebuildJob): string {
|
||||||
return `${job.type}:${job.id}`;
|
return `${job.type}:${job.id}`;
|
||||||
@@ -106,12 +114,31 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
|
|||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const attempt = (job.attempt ?? 0) + 1;
|
||||||
|
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
|
||||||
|
const delay =
|
||||||
|
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
||||||
|
logger.warn(
|
||||||
|
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.enqueue({ ...job, attempt }).catch(
|
||||||
|
(enqueueErr) =>
|
||||||
|
logger.error(
|
||||||
|
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
||||||
|
enqueueErr
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [
|
|||||||
"IP",
|
"IP",
|
||||||
"PATH",
|
"PATH",
|
||||||
"COUNTRY",
|
"COUNTRY",
|
||||||
|
"COUNTRY_IS_NOT",
|
||||||
"ASN",
|
"ASN",
|
||||||
"REGION"
|
"REGION"
|
||||||
] as const;
|
] as const;
|
||||||
@@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError(
|
|||||||
case "REGION":
|
case "REGION":
|
||||||
return isValidRegionId(value) ? null : "Invalid region ID provided";
|
return isValidRegionId(value) ? null : "Invalid region ID provided";
|
||||||
case "COUNTRY":
|
case "COUNTRY":
|
||||||
|
case "COUNTRY_IS_NOT":
|
||||||
return COUNTRIES.some((country) => country.code === value)
|
return COUNTRIES.some((country) => country.code === value)
|
||||||
? null
|
? null
|
||||||
: "Invalid country code provided";
|
: "Invalid country code provided";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
|
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
|
||||||
|
|
||||||
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
||||||
// server process dies while holding a rebuild, this ensures the counter key
|
// server process dies while holding a rebuild, this ensures the counter key
|
||||||
|
|||||||
@@ -14,12 +14,16 @@
|
|||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
import { lockManager } from "#private/lib/lock";
|
import { lockManager } from "#private/lib/lock";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { isTransientError } from "@server/lib/dbRetry";
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
export type RebuildJobType = "site-resource" | "client";
|
||||||
|
|
||||||
export interface RebuildJob {
|
export interface RebuildJob {
|
||||||
type: RebuildJobType;
|
type: RebuildJobType;
|
||||||
id: number;
|
id: number;
|
||||||
|
// Number of times this job has already been re-queued after a transient
|
||||||
|
// failure. Absent/0 means it has not failed yet.
|
||||||
|
attempt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
export interface RebuildJobHandlers {
|
||||||
@@ -43,6 +47,11 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
// A job that fails with a transient DB error gets re-queued with backoff
|
||||||
|
// instead of being dropped, up to this many times.
|
||||||
|
const MAX_JOB_ATTEMPTS = 5;
|
||||||
|
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
||||||
|
|
||||||
class RedisRebuildQueue {
|
class RedisRebuildQueue {
|
||||||
private processingStarted = false;
|
private processingStarted = false;
|
||||||
|
|
||||||
@@ -180,12 +189,35 @@ class RedisRebuildQueue {
|
|||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const attempt = (job.attempt ?? 0) + 1;
|
||||||
|
if (
|
||||||
|
isTransientError(err) &&
|
||||||
|
attempt <= MAX_JOB_ATTEMPTS
|
||||||
|
) {
|
||||||
|
const delay =
|
||||||
|
JOB_RETRY_BASE_DELAY_MS *
|
||||||
|
Math.pow(2, attempt - 1);
|
||||||
|
logger.warn(
|
||||||
|
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.enqueue({ ...job, attempt }).catch(
|
||||||
|
(enqueueErr) =>
|
||||||
|
logger.error(
|
||||||
|
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
||||||
|
enqueueErr
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, delay);
|
||||||
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
PROCESSOR_LOCK_TTL_MS
|
PROCESSOR_LOCK_TTL_MS
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-18
@@ -894,6 +894,19 @@ class RegionalRedisManager {
|
|||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
|
||||||
|
// (primary). The replica (redis-1) is only reachable through the
|
||||||
|
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
|
||||||
|
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
|
||||||
|
// if the configured host doesn't match that pattern (e.g. local dev),
|
||||||
|
// in which case callers should fall back to the primary for reads.
|
||||||
|
private getReplicaHost(primaryHost: string): string | null {
|
||||||
|
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const namespace = match[1];
|
||||||
|
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
|
||||||
|
}
|
||||||
|
|
||||||
private initializeClients(): void {
|
private initializeClients(): void {
|
||||||
const cfg = this.getConfig();
|
const cfg = this.getConfig();
|
||||||
const baseOpts = {
|
const baseOpts = {
|
||||||
@@ -907,35 +920,42 @@ class RegionalRedisManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.writeClient = new Redis(baseOpts);
|
this.writeClient = new Redis(baseOpts);
|
||||||
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
|
||||||
this.readClient = new Redis({
|
|
||||||
...baseOpts,
|
|
||||||
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
|
||||||
// Derive replica hostname from the headless service pattern:
|
|
||||||
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
|
||||||
// If it doesn't look like a k8s service, just use the same host
|
|
||||||
return h + rest;
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// For simplicity use same host for both; callers can always read from primary
|
const replicaHost = this.getReplicaHost(cfg.host!);
|
||||||
// The real replica routing is handled by the StatefulSet headless service
|
this.readClient = replicaHost
|
||||||
this.readClient = this.writeClient;
|
? new Redis({ ...baseOpts, host: replicaHost })
|
||||||
|
: this.writeClient;
|
||||||
|
|
||||||
this.writeClient.on("ready", () => {
|
this.writeClient.on("ready", () => {
|
||||||
logger.info("Regional Redis client ready");
|
logger.info("Regional Redis write client ready");
|
||||||
this.isHealthy = true;
|
this.isHealthy = true;
|
||||||
});
|
});
|
||||||
this.writeClient.on("error", (err) => {
|
this.writeClient.on("error", (err) => {
|
||||||
logger.error("Regional Redis client error:", err);
|
logger.error("Regional Redis write client error:", err);
|
||||||
this.isHealthy = false;
|
this.isHealthy = false;
|
||||||
});
|
});
|
||||||
this.writeClient.on("reconnecting", () => {
|
this.writeClient.on("reconnecting", () => {
|
||||||
logger.info("Regional Redis client reconnecting...");
|
logger.info("Regional Redis write client reconnecting...");
|
||||||
this.isHealthy = false;
|
this.isHealthy = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("Regional Redis client initialized");
|
if (this.readClient !== this.writeClient) {
|
||||||
|
this.readClient.on("ready", () => {
|
||||||
|
logger.info("Regional Redis read client ready");
|
||||||
|
});
|
||||||
|
this.readClient.on("error", (err) => {
|
||||||
|
logger.error("Regional Redis read client error:", err);
|
||||||
|
});
|
||||||
|
this.readClient.on("reconnecting", () => {
|
||||||
|
logger.info("Regional Redis read client reconnecting...");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
replicaHost
|
||||||
|
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
|
||||||
|
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to initialize regional Redis client:", error);
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
@@ -1041,11 +1061,14 @@ class RegionalRedisManager {
|
|||||||
|
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (this.readClient && this.readClient !== this.writeClient) {
|
||||||
|
await this.readClient.quit();
|
||||||
|
}
|
||||||
|
this.readClient = null;
|
||||||
if (this.writeClient) {
|
if (this.writeClient) {
|
||||||
await this.writeClient.quit();
|
await this.writeClient.quit();
|
||||||
this.writeClient = null;
|
this.writeClient = null;
|
||||||
}
|
}
|
||||||
this.readClient = null;
|
|
||||||
logger.info("Regional Redis client disconnected");
|
logger.info("Regional Redis client disconnected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error disconnecting regional Redis client:", error);
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
import { Tier } from "@server/types/Tiers";
|
import { Tier } from "@server/types/Tiers";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export function verifyValidSubscription(tiers: Tier[]) {
|
export function verifyValidSubscription(tiers: Tier[]) {
|
||||||
return async function (
|
return async function (
|
||||||
@@ -30,9 +31,9 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orgId =
|
const orgId =
|
||||||
req.params.orgId ||
|
req.params?.orgId ||
|
||||||
req.body.orgId ||
|
req.body?.orgId ||
|
||||||
req.query.orgId ||
|
req.query?.orgId ||
|
||||||
req.userOrgId;
|
req.userOrgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
@@ -65,6 +66,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as labels from "#private/routers/labels";
|
|
||||||
import * as client from "@server/routers/client";
|
import * as client from "@server/routers/client";
|
||||||
import * as resource from "#private/routers/resource";
|
import * as resource from "#private/routers/resource";
|
||||||
import * as policy from "#private/routers/policy";
|
import * as policy from "#private/routers/policy";
|
||||||
@@ -810,59 +809,6 @@ authenticated.get(
|
|||||||
alertRule.getAlertRule
|
alertRule.getAlertRule
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
|
||||||
labels.listOrgLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
|
||||||
labels.createOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.patch(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
|
||||||
labels.updateOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
|
||||||
labels.deleteOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/attach",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
|
||||||
labels.attachLabelToItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/detach",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
|
||||||
labels.detachLabelFromItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/health-checks",
|
"/org/:orgId/health-checks",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export async function createExitNode(
|
|||||||
.where(eq(exitNodes.publicKey, publicKey));
|
.where(eq(exitNodes.publicKey, publicKey));
|
||||||
let exitNode: ExitNode;
|
let exitNode: ExitNode;
|
||||||
if (!exitNodeQuery) {
|
if (!exitNodeQuery) {
|
||||||
const address = await getNextAvailableSubnet();
|
const { value: address, release } = await getNextAvailableSubnet();
|
||||||
|
try {
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
// const listenPort = await getNextAvailablePort();
|
// const listenPort = await getNextAvailablePort();
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
@@ -60,6 +61,9 @@ export async function createExitNode(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// update the reachable at
|
// update the reachable at
|
||||||
[exitNode] = await db
|
[exitNode] = await db
|
||||||
|
|||||||
@@ -1695,6 +1695,7 @@ hybridRouter.get(
|
|||||||
) {
|
) {
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (rule.match == "COUNTRY") {
|
if (rule.match == "COUNTRY") {
|
||||||
|
// @ts-expect-error this is for backward compatibility
|
||||||
rule.match = "GEOIP";
|
rule.match = "GEOIP";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./listOrgLabels";
|
|
||||||
export * from "./createOrgLabel";
|
|
||||||
export * from "./updateOrgLabel";
|
|
||||||
export * from "./attachLabelToItem";
|
|
||||||
export * from "./detachLabelFromItem";
|
|
||||||
export * from "./deleteOrgLabel";
|
|
||||||
@@ -114,8 +114,6 @@ export async function createRemoteExitNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secretHash = await hashPassword(secret);
|
const secretHash = await hashPassword(secret);
|
||||||
// const address = await getNextAvailableSubnet();
|
|
||||||
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
|
|
||||||
|
|
||||||
const [existingRemoteExitNode] = await db
|
const [existingRemoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -191,13 +189,27 @@ export async function createRemoteExitNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this remote exit node isn't already backing an exit node in
|
||||||
|
// another org, we're about to create a brand new one. Reserve a
|
||||||
|
// subnet for it up front so the allocation lock is held across the
|
||||||
|
// whole insert - this guarantees exit node subnets never overlap,
|
||||||
|
// even under concurrent creation, which matters for HA setups.
|
||||||
|
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
||||||
|
let newExitNodeAddress: string | null = null;
|
||||||
|
if (!existingExitNode) {
|
||||||
|
const { value, release } = await getNextAvailableSubnet();
|
||||||
|
newExitNodeAddress = value;
|
||||||
|
releaseSubnetLock = release;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (!existingExitNode) {
|
if (!existingExitNode) {
|
||||||
const [res] = await trx
|
const [res] = await trx
|
||||||
.insert(exitNodes)
|
.insert(exitNodes)
|
||||||
.values({
|
.values({
|
||||||
name: remoteExitNodeId,
|
name: remoteExitNodeId,
|
||||||
address,
|
address: newExitNodeAddress!,
|
||||||
endpoint: "",
|
endpoint: "",
|
||||||
publicKey: "",
|
publicKey: "",
|
||||||
listenPort: 0,
|
listenPort: 0,
|
||||||
@@ -274,6 +286,9 @@ export async function createRemoteExitNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
await releaseSubnetLock?.();
|
||||||
|
}
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||||
|
|||||||
@@ -1054,7 +1054,10 @@ async function checkRules(
|
|||||||
isPathAllowed(rule.value, path)
|
isPathAllowed(rule.value, path)
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
} else if (clientIp && rule.match == "COUNTRY") {
|
} else if (
|
||||||
|
clientIp &&
|
||||||
|
(rule.match === "COUNTRY" || rule.match === "COUNTRY_IS_NOT")
|
||||||
|
) {
|
||||||
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||||
if (
|
if (
|
||||||
rule.value.toUpperCase() === "ALL" &&
|
rule.value.toUpperCase() === "ALL" &&
|
||||||
@@ -1063,7 +1066,10 @@ async function checkRules(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await isIpInGeoIP(ipCC, rule.value)) {
|
const inCountry = await isIpInGeoIP(ipCC, rule.value);
|
||||||
|
const matched = rule.match === "COUNTRY" ? inCountry : !inCountry;
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
} else if (clientIp && rule.match == "ASN") {
|
} else if (clientIp && rule.match == "ASN") {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
|
|||||||
source: "API"
|
source: "API"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update database from config: ${error}`);
|
logger.debug(`Failed to update database from config: ${error}`);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -304,11 +304,6 @@ export async function listClients(
|
|||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const conditions = [
|
const conditions = [
|
||||||
and(
|
and(
|
||||||
@@ -341,7 +336,7 @@ export async function listClients(
|
|||||||
conditions.push(or(...filterAggregates));
|
conditions.push(or(...filterAggregates));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
clients.clientId,
|
clients.clientId,
|
||||||
@@ -361,11 +356,7 @@ export async function listClients(
|
|||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${clients.name})`, q),
|
like(sql`LOWER(${clients.name})`, q),
|
||||||
like(sql`LOWER(${clients.niceId})`, q)
|
like(sql`LOWER(${clients.niceId})`, q),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
clients.clientId,
|
clients.clientId,
|
||||||
db
|
db
|
||||||
@@ -377,8 +368,7 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
@@ -414,7 +404,7 @@ export async function listClients(
|
|||||||
clientId: number;
|
clientId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
if (clientIds.length > 0) {
|
||||||
labelsForClients = await db
|
labelsForClients = await db
|
||||||
.select({
|
.select({
|
||||||
labelId: labels.labelId,
|
labelId: labels.labelId,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { build } from "@server/build";
|
|||||||
import { createStore } from "#dynamic/lib/rateLimitStore";
|
import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||||
import { logActionAudit } from "#dynamic/middlewares";
|
import { logActionAudit } from "#dynamic/middlewares";
|
||||||
import { checkRoundTripMessage } from "./ws";
|
import { checkRoundTripMessage } from "./ws";
|
||||||
|
import * as labels from "@server/routers/labels";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
@@ -327,6 +328,14 @@ authenticated.get(
|
|||||||
siteResource.listAllSiteResourcesByOrg
|
siteResource.listAllSiteResourcesByOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/site-resource/:siteResourceId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSiteResource),
|
||||||
|
siteResource.getSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/site-resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
@@ -470,6 +479,12 @@ authenticated.get(
|
|||||||
launcher.listLauncherGroups
|
launcher.listLauncherGroups
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/launcher/scale",
|
||||||
|
verifyOrgAccess,
|
||||||
|
launcher.listLauncherScale
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/launcher/resources",
|
"/org/:orgId/launcher/resources",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -494,6 +509,12 @@ authenticated.get(
|
|||||||
launcher.listLauncherViews
|
launcher.listLauncherViews
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/launcher/invalidate-cache",
|
||||||
|
verifyOrgAccess,
|
||||||
|
launcher.invalidateLauncherCache
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/launcher/views",
|
"/org/:orgId/launcher/views",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -506,6 +527,18 @@ authenticated.put(
|
|||||||
launcher.updateLauncherView
|
launcher.updateLauncherView
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/launcher/default-view",
|
||||||
|
verifyOrgAccess,
|
||||||
|
launcher.upsertLauncherDefaultView
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/launcher/default-view",
|
||||||
|
verifyOrgAccess,
|
||||||
|
launcher.deleteLauncherDefaultView
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/org/:orgId/launcher/views/:viewId",
|
"/org/:orgId/launcher/views/:viewId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -1303,6 +1336,48 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/labels",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
||||||
|
labels.listOrgLabels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/labels",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
||||||
|
labels.createOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.patch(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
||||||
|
labels.updateOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
||||||
|
labels.deleteOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/label/:labelId/attach",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
||||||
|
labels.attachLabelToItem
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/label/:labelId/detach",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
||||||
|
labels.detachLabelFromItem
|
||||||
|
);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export async function createExitNode(
|
|||||||
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
||||||
let exitNode: ExitNode;
|
let exitNode: ExitNode;
|
||||||
if (!exitNodeQuery) {
|
if (!exitNodeQuery) {
|
||||||
const address = await getNextAvailableSubnet();
|
const { value: address, release } = await getNextAvailableSubnet();
|
||||||
|
try {
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
// const listenPort = await getNextAvailablePort();
|
// const listenPort = await getNextAvailablePort();
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
@@ -44,6 +45,9 @@ export async function createExitNode(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// update the existing exit node
|
// update the existing exit node
|
||||||
[exitNode] = await db
|
[exitNode] = await db
|
||||||
|
|||||||
@@ -895,12 +895,6 @@ authenticated.delete(
|
|||||||
user.removeUserOrg
|
user.removeUserOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
// authenticated.put(
|
|
||||||
// "/newt",
|
|
||||||
// verifyApiKeyHasAction(ActionsEnum.createNewt),
|
|
||||||
// newt.createNewt
|
|
||||||
// );
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
`/org/:orgId/api-keys`,
|
`/org/:orgId/api-keys`,
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
|||||||
-13
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientLabels,
|
clientLabels,
|
||||||
-12
@@ -1,15 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 {
|
import {
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
-12
@@ -1,15 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 { db, labels } from "@server/db";
|
import { db, labels } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
-13
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientLabels,
|
clientLabels,
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./listOrgLabels";
|
||||||
|
export * from "./createOrgLabel";
|
||||||
|
export * from "./updateOrgLabel";
|
||||||
|
export * from "./attachLabelToItem";
|
||||||
|
export * from "./detachLabelFromItem";
|
||||||
|
export * from "./deleteOrgLabel";
|
||||||
-13
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 { db, labels } from "@server/db";
|
import { db, labels } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
-13
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 { db, labels } from "@server/db";
|
import { db, labels } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -79,7 +79,8 @@ export async function createLauncherView(
|
|||||||
),
|
),
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
isOrgWide: created.userId == null
|
isOrgWide: created.userId == null,
|
||||||
|
isDefault: created.isDefault
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||||
|
import {
|
||||||
|
deleteAllDefaultViewOverrides,
|
||||||
|
deleteDefaultViewOverride
|
||||||
|
} from "./launcherDefaultView";
|
||||||
|
|
||||||
|
const deleteLauncherDefaultViewBodySchema = z.strictObject({
|
||||||
|
orgWide: z.boolean().optional().default(false),
|
||||||
|
all: z.boolean().optional().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteLauncherDefaultView(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const orgId = req.userOrgId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromZodError(parsed.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.all) {
|
||||||
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
|
ActionsEnum.createOrgWideLauncherView,
|
||||||
|
req
|
||||||
|
);
|
||||||
|
if (!canManageOrgWide) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have permission perform this action"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAllDefaultViewOverrides(orgId, userId);
|
||||||
|
} else if (parsed.data.orgWide) {
|
||||||
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
|
ActionsEnum.createOrgWideLauncherView,
|
||||||
|
req
|
||||||
|
);
|
||||||
|
if (!canManageOrgWide) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have permission perform this action"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteDefaultViewOverride({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
orgWide: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await deleteDefaultViewOverride({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
orgWide: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Launcher default view reset successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
console.error("Error resetting launcher default view:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,15 @@ export async function deleteLauncherView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.isDefault) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"The default view cannot be deleted from here"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isPersonalView = existing.userId === userId;
|
const isPersonalView = existing.userId === userId;
|
||||||
const isOrgWideView = existing.userId == null;
|
const isOrgWideView = existing.userId == null;
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./types";
|
export * from "./types";
|
||||||
export { listLauncherGroups } from "./listLauncherGroups";
|
export { listLauncherGroups } from "./listLauncherGroups";
|
||||||
|
export { listLauncherScale } from "./listLauncherScale";
|
||||||
export { listLauncherResources } from "./listLauncherResources";
|
export { listLauncherResources } from "./listLauncherResources";
|
||||||
export { listLauncherSites } from "./listLauncherSites";
|
export { listLauncherSites } from "./listLauncherSites";
|
||||||
export { listLauncherLabels } from "./listLauncherLabels";
|
export { listLauncherLabels } from "./listLauncherLabels";
|
||||||
@@ -7,3 +8,6 @@ export { listLauncherViews } from "./listLauncherViews";
|
|||||||
export { createLauncherView } from "./createLauncherView";
|
export { createLauncherView } from "./createLauncherView";
|
||||||
export { updateLauncherView } from "./updateLauncherView";
|
export { updateLauncherView } from "./updateLauncherView";
|
||||||
export { deleteLauncherView } from "./deleteLauncherView";
|
export { deleteLauncherView } from "./deleteLauncherView";
|
||||||
|
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
|
||||||
|
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
|
||||||
|
export { invalidateLauncherCache } from "./invalidateLauncherCache";
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
|
||||||
|
async function invalidateLauncherCacheForUser(
|
||||||
|
orgId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const prefixes = [
|
||||||
|
`launcherAccessibleIds:${orgId}:${userId}:`,
|
||||||
|
`launcher:groups:${orgId}:${userId}:`,
|
||||||
|
`launcher:results:${orgId}:${userId}:`,
|
||||||
|
`launcher:scale:counts:${orgId}:${userId}:`
|
||||||
|
];
|
||||||
|
|
||||||
|
const keys = (
|
||||||
|
await Promise.all(
|
||||||
|
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await cache.del(keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invalidateLauncherCache(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const orgId = req.userOrgId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await invalidateLauncherCacheForUser(orgId, userId);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Launcher cache invalidated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
console.error("Error invalidating launcher cache:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { db, launcherViews } from "@server/db";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import moment from "moment";
|
||||||
|
import {
|
||||||
|
launcherViewConfigSchema,
|
||||||
|
type LauncherDefaultViewOverrides,
|
||||||
|
type LauncherViewConfig,
|
||||||
|
type LauncherViewRecord
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function mapViewRow(
|
||||||
|
row: typeof launcherViews.$inferSelect
|
||||||
|
): LauncherViewRecord {
|
||||||
|
return {
|
||||||
|
viewId: row.viewId,
|
||||||
|
orgId: row.orgId,
|
||||||
|
userId: row.userId,
|
||||||
|
name: row.name,
|
||||||
|
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
isOrgWide: row.userId == null,
|
||||||
|
isDefault: row.isDefault
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDefaultViewOverrides(
|
||||||
|
rows: Array<typeof launcherViews.$inferSelect>
|
||||||
|
): LauncherDefaultViewOverrides {
|
||||||
|
const overrideRows = rows.filter((row) => row.isDefault);
|
||||||
|
|
||||||
|
const personalRow = overrideRows.find((row) => row.userId !== null);
|
||||||
|
const orgWideRow = overrideRows.find((row) => row.userId === null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
personal: personalRow ? mapViewRow(personalRow) : null,
|
||||||
|
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listVisibleLauncherViews(
|
||||||
|
rows: Array<typeof launcherViews.$inferSelect>
|
||||||
|
): LauncherViewRecord[] {
|
||||||
|
return rows.filter((row) => !row.isDefault).map(mapViewRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findDefaultViewOverride(
|
||||||
|
orgId: string,
|
||||||
|
orgWide: boolean,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(launcherViews)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(launcherViews.orgId, orgId),
|
||||||
|
eq(launcherViews.isDefault, true),
|
||||||
|
orgWide
|
||||||
|
? isNull(launcherViews.userId)
|
||||||
|
: eq(launcherViews.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return existing ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertDefaultViewOverride({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
orgWide,
|
||||||
|
config
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
userId: string;
|
||||||
|
orgWide: boolean;
|
||||||
|
config: LauncherViewConfig;
|
||||||
|
}) {
|
||||||
|
const now = moment().toISOString();
|
||||||
|
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(launcherViews)
|
||||||
|
.set({
|
||||||
|
config: JSON.stringify(config),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(eq(launcherViews.viewId, existing.viewId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapViewRow(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(launcherViews)
|
||||||
|
.values({
|
||||||
|
orgId,
|
||||||
|
userId: orgWide ? null : userId,
|
||||||
|
name: "",
|
||||||
|
isDefault: true,
|
||||||
|
config: JSON.stringify(config),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapViewRow(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDefaultViewOverride({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
orgWide
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
userId: string;
|
||||||
|
orgWide: boolean;
|
||||||
|
}) {
|
||||||
|
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(launcherViews)
|
||||||
|
.where(eq(launcherViews.viewId, existing.viewId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllDefaultViewOverrides(
|
||||||
|
orgId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
|
||||||
|
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import {
|
import {
|
||||||
exitNodes,
|
exitNodes,
|
||||||
@@ -31,13 +32,15 @@ import {
|
|||||||
isNull,
|
isNull,
|
||||||
like,
|
like,
|
||||||
or,
|
or,
|
||||||
sql
|
sql,
|
||||||
|
type SQL
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
formatPublicResourceAccess,
|
formatPublicResourceAccess,
|
||||||
formatSiteResourceAccess
|
formatSiteResourceAccess
|
||||||
} from "./formatLauncherAccess";
|
} from "./formatLauncherAccess";
|
||||||
import {
|
import {
|
||||||
|
LAUNCHER_FLAT_GROUP_KEY,
|
||||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||||
LAUNCHER_UNLABELED_GROUP_KEY,
|
LAUNCHER_UNLABELED_GROUP_KEY,
|
||||||
type LauncherFilterListQuery,
|
type LauncherFilterListQuery,
|
||||||
@@ -59,6 +62,68 @@ export type AccessibleIds = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
||||||
|
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
|
||||||
|
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
|
||||||
|
|
||||||
|
type LauncherResourcesCacheEntry = {
|
||||||
|
items: LauncherResource[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LauncherGroupsCacheEntry = {
|
||||||
|
groups: LauncherGroup[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function launcherListQueryHash(
|
||||||
|
userRoleIds: number[],
|
||||||
|
query: LauncherListQuery,
|
||||||
|
extra?: Record<string, string>
|
||||||
|
) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
roles: [...userRoleIds].sort((a, b) => a - b),
|
||||||
|
query: query.query,
|
||||||
|
groupBy: query.groupBy,
|
||||||
|
siteIds: query.siteIds ?? "",
|
||||||
|
labelIds: query.labelIds ?? "",
|
||||||
|
sort_by: query.sort_by,
|
||||||
|
order: query.order,
|
||||||
|
...extra
|
||||||
|
});
|
||||||
|
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcherResourcesQueryHash(
|
||||||
|
userRoleIds: number[],
|
||||||
|
query: LauncherListQuery & { groupKey: string }
|
||||||
|
) {
|
||||||
|
return launcherListQueryHash(userRoleIds, query, {
|
||||||
|
groupKey: query.groupKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcherGroupsQueryHash(
|
||||||
|
userRoleIds: number[],
|
||||||
|
query: LauncherListQuery
|
||||||
|
) {
|
||||||
|
return launcherListQueryHash(userRoleIds, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcherResourcesCacheKey(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
queryHash: string
|
||||||
|
) {
|
||||||
|
return `launcher:results:${orgId}:${userId}:${queryHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcherGroupsCacheKey(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
queryHash: string
|
||||||
|
) {
|
||||||
|
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
function launcherAccessibleIdsCacheKey(
|
function launcherAccessibleIdsCacheKey(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
@@ -194,10 +259,22 @@ function searchPattern(query: string) {
|
|||||||
return `%${query.trim()}%`;
|
return `%${query.trim()}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchConditionForPublic(
|
function combineOrConditions(
|
||||||
query: string,
|
...conditions: (SQL | undefined)[]
|
||||||
labelsFeatureEnabled: boolean
|
): SQL | undefined {
|
||||||
) {
|
const parts = conditions.filter(
|
||||||
|
(condition): condition is SQL => !!condition
|
||||||
|
);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
return or(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchConditionForPublic(query: string) {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -205,32 +282,31 @@ function buildSearchConditionForPublic(
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${resources.name})`, pattern),
|
like(sql`LOWER(${resources.name})`, pattern),
|
||||||
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
||||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
|
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
|
||||||
];
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
if (labelsFeatureEnabled) {
|
db
|
||||||
queryList.push(
|
.select({ id: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||||
|
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
|
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||||
|
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
|
||||||
|
),
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
db
|
db
|
||||||
.select({ id: resourceLabels.resourceId })
|
.select({ id: resourceLabels.resourceId })
|
||||||
.from(resourceLabels)
|
.from(resourceLabels)
|
||||||
.innerJoin(
|
.innerJoin(labels, eq(labels.labelId, resourceLabels.labelId))
|
||||||
labels,
|
|
||||||
eq(labels.labelId, resourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, pattern))
|
.where(like(sql`LOWER(${labels.name})`, pattern))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchConditionForSiteResource(
|
function buildSearchConditionForSiteResource(query: string) {
|
||||||
query: string,
|
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
) {
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -238,13 +314,14 @@ function buildSearchConditionForSiteResource(
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${siteResources.name})`, pattern),
|
like(sql`LOWER(${siteResources.name})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.destination})`, pattern),
|
like(sql`LOWER(${siteResources.destination})`, pattern),
|
||||||
|
like(
|
||||||
|
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
|
||||||
|
pattern
|
||||||
|
),
|
||||||
|
like(sql`LOWER(${siteResources.scheme})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.alias})`, pattern),
|
like(sql`LOWER(${siteResources.alias})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
|
like(sql`LOWER(${siteResources.aliasAddress})`, pattern),
|
||||||
];
|
|
||||||
|
|
||||||
if (labelsFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
db
|
db
|
||||||
@@ -256,14 +333,80 @@ function buildSearchConditionForSiteResource(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, pattern))
|
.where(like(sql`LOWER(${labels.name})`, pattern))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function labelsEnabled(orgId: string): Promise<boolean> {
|
async function filterPublicResourceIdsByTextSearch(
|
||||||
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
orgId: string,
|
||||||
|
resourceIds: number[],
|
||||||
|
query: string
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (!query.trim() || resourceIds.length === 0) {
|
||||||
|
return resourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMatch = combineOrConditions(
|
||||||
|
buildSearchConditionForPublic(query),
|
||||||
|
buildSiteNameSearchCondition(query)
|
||||||
|
);
|
||||||
|
if (!textMatch) {
|
||||||
|
return resourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ resourceId: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||||
|
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, resourceIds),
|
||||||
|
eq(resources.orgId, orgId),
|
||||||
|
eq(resources.enabled, true),
|
||||||
|
textMatch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterSiteResourceIdsByTextSearch(
|
||||||
|
orgId: string,
|
||||||
|
siteResourceIds: number[],
|
||||||
|
query: string
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (!query.trim() || siteResourceIds.length === 0) {
|
||||||
|
return siteResourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textMatch = combineOrConditions(
|
||||||
|
buildSearchConditionForSiteResource(query),
|
||||||
|
buildSiteNameSearchCondition(query)
|
||||||
|
);
|
||||||
|
if (!textMatch) {
|
||||||
|
return siteResourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.leftJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
|
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
textMatch
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.siteResourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLabelsForResources(
|
async function fetchLabelsForResources(
|
||||||
@@ -277,10 +420,6 @@ async function fetchLabelsForResources(
|
|||||||
const byResourceId = new Map<number, LauncherLabel[]>();
|
const byResourceId = new Map<number, LauncherLabel[]>();
|
||||||
const bySiteResourceId = new Map<number, LauncherLabel[]>();
|
const bySiteResourceId = new Map<number, LauncherLabel[]>();
|
||||||
|
|
||||||
if (!(await labelsEnabled(orgId))) {
|
|
||||||
return { byResourceId, bySiteResourceId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
|
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
|
||||||
resourceIds.length === 0
|
resourceIds.length === 0
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
@@ -356,15 +495,8 @@ async function listSiteGroups(
|
|||||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||||
const labelFilterIds = parseIdListParam(query.labelIds);
|
const labelFilterIds = parseIdListParam(query.labelIds);
|
||||||
const labelsFeatureEnabled = await labelsEnabled(orgId);
|
const searchPublic = buildSearchConditionForPublic(query.query);
|
||||||
const searchPublic = buildSearchConditionForPublic(
|
const searchSite = buildSearchConditionForSiteResource(query.query);
|
||||||
query.query,
|
|
||||||
labelsFeatureEnabled
|
|
||||||
);
|
|
||||||
const searchSite = buildSearchConditionForSiteResource(
|
|
||||||
query.query,
|
|
||||||
labelsFeatureEnabled
|
|
||||||
);
|
|
||||||
const siteCountMap = new Map<number, SiteGroupRow>();
|
const siteCountMap = new Map<number, SiteGroupRow>();
|
||||||
|
|
||||||
if (accessible.resourceIds.length > 0) {
|
if (accessible.resourceIds.length > 0) {
|
||||||
@@ -588,9 +720,8 @@ async function listSiteGroups(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const total = groups.length;
|
const total = groups.length;
|
||||||
const offset = (query.page - 1) * query.pageSize;
|
|
||||||
return {
|
return {
|
||||||
groups: groups.slice(offset, offset + query.pageSize),
|
groups,
|
||||||
total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -608,10 +739,6 @@ async function listLabelGroups(
|
|||||||
>();
|
>();
|
||||||
let unlabeledCount = 0;
|
let unlabeledCount = 0;
|
||||||
|
|
||||||
if (!(await labelsEnabled(orgId))) {
|
|
||||||
return { groups: [], total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchesLabelFilters = (labelId: number) =>
|
const matchesLabelFilters = (labelId: number) =>
|
||||||
labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
|
labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
|
||||||
|
|
||||||
@@ -621,7 +748,7 @@ async function listLabelGroups(
|
|||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true)
|
||||||
];
|
];
|
||||||
const searchPublic = buildSearchConditionForPublic(query.query, true);
|
const searchPublic = buildSearchConditionForPublic(query.query);
|
||||||
if (searchPublic) {
|
if (searchPublic) {
|
||||||
publicConditions.push(searchPublic);
|
publicConditions.push(searchPublic);
|
||||||
}
|
}
|
||||||
@@ -685,10 +812,7 @@ async function listLabelGroups(
|
|||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true)
|
||||||
];
|
];
|
||||||
const searchSite = buildSearchConditionForSiteResource(
|
const searchSite = buildSearchConditionForSiteResource(query.query);
|
||||||
query.query,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (searchSite) {
|
if (searchSite) {
|
||||||
siteConditions.push(searchSite);
|
siteConditions.push(searchSite);
|
||||||
}
|
}
|
||||||
@@ -788,26 +912,53 @@ async function listLabelGroups(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const total = groups.length;
|
const total = groups.length;
|
||||||
const offset = (query.page - 1) * query.pageSize;
|
|
||||||
return {
|
return {
|
||||||
groups: groups.slice(offset, offset + query.pageSize),
|
groups,
|
||||||
total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listLauncherGroupsForUserUncached(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
userRoleIds: number[],
|
||||||
|
query: LauncherListQuery
|
||||||
|
): Promise<LauncherGroupsCacheEntry> {
|
||||||
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
|
|
||||||
|
if (query.groupBy === "label") {
|
||||||
|
return listLabelGroups(orgId, accessible, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listSiteGroups(orgId, accessible, query);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listLauncherGroupsForUser(
|
export async function listLauncherGroupsForUser(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
userRoleIds: number[],
|
userRoleIds: number[],
|
||||||
query: LauncherListQuery
|
query: LauncherListQuery
|
||||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
|
||||||
|
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
|
||||||
|
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
|
||||||
|
|
||||||
if (query.groupBy === "label") {
|
let result = cached;
|
||||||
return listLabelGroups(orgId, accessible, query);
|
if (!result) {
|
||||||
|
result = await listLauncherGroupsForUserUncached(
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
userRoleIds,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
|
||||||
}
|
}
|
||||||
|
|
||||||
return listSiteGroups(orgId, accessible, query);
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
|
return {
|
||||||
|
groups: result.groups.slice(offset, offset + query.pageSize),
|
||||||
|
total: result.total
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapPublicResources(
|
async function mapPublicResources(
|
||||||
@@ -1018,26 +1169,6 @@ function filterResourcesByLabel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterResourcesBySearch(
|
|
||||||
items: LauncherResource[],
|
|
||||||
query: string
|
|
||||||
): LauncherResource[] {
|
|
||||||
if (!query.trim()) {
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
const pattern = query.trim().toLowerCase();
|
|
||||||
return items.filter(
|
|
||||||
(item) =>
|
|
||||||
item.name.toLowerCase().includes(pattern) ||
|
|
||||||
item.accessDisplay.toLowerCase().includes(pattern) ||
|
|
||||||
item.accessCopyValue.toLowerCase().includes(pattern) ||
|
|
||||||
item.labels.some((label) =>
|
|
||||||
label.name.toLowerCase().includes(pattern)
|
|
||||||
) ||
|
|
||||||
item.site?.name.toLowerCase().includes(pattern)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortLauncherResources(
|
function sortLauncherResources(
|
||||||
items: LauncherResource[],
|
items: LauncherResource[],
|
||||||
order: "asc" | "desc"
|
order: "asc" | "desc"
|
||||||
@@ -1050,12 +1181,12 @@ function sortLauncherResources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLauncherResourcesForUser(
|
async function listLauncherResourcesForUserUncached(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
userRoleIds: number[],
|
userRoleIds: number[],
|
||||||
query: LauncherListQuery & { groupKey: string }
|
query: LauncherListQuery & { groupKey: string }
|
||||||
): Promise<{ resources: LauncherResource[]; total: number }> {
|
): Promise<LauncherResourcesCacheEntry> {
|
||||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
|
|
||||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||||
@@ -1128,6 +1259,23 @@ export async function listLauncherResourcesForUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.query.trim()) {
|
||||||
|
if (filteredResourceIds.length > 0) {
|
||||||
|
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
||||||
|
orgId,
|
||||||
|
filteredResourceIds,
|
||||||
|
query.query
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filteredSiteResourceIds.length > 0) {
|
||||||
|
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
||||||
|
orgId,
|
||||||
|
filteredSiteResourceIds,
|
||||||
|
query.query
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const labelMaps = await fetchLabelsForResources(
|
const labelMaps = await fetchLabelsForResources(
|
||||||
orgId,
|
orgId,
|
||||||
filteredResourceIds,
|
filteredResourceIds,
|
||||||
@@ -1159,21 +1307,48 @@ export async function listLauncherResourcesForUser(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let items = [...publicItems, ...siteItems];
|
let items = [...publicItems, ...siteItems];
|
||||||
items = filterResourcesBySearch(items, query.query);
|
|
||||||
|
|
||||||
|
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
|
||||||
if (query.groupBy === "label") {
|
if (query.groupBy === "label") {
|
||||||
items = filterResourcesByLabel(items, query.groupKey);
|
items = filterResourcesByLabel(items, query.groupKey);
|
||||||
} else if (query.groupBy === "site") {
|
} else if (query.groupBy === "site") {
|
||||||
items = filterResourcesBySite(items, query.groupKey);
|
items = filterResourcesBySite(items, query.groupKey);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items = sortLauncherResources(items, query.order);
|
items = sortLauncherResources(items, query.order);
|
||||||
|
|
||||||
const total = items.length;
|
return {
|
||||||
|
items,
|
||||||
|
total: items.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLauncherResourcesForUser(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
userRoleIds: number[],
|
||||||
|
query: LauncherListQuery & { groupKey: string }
|
||||||
|
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||||
|
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
|
||||||
|
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
|
||||||
|
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
|
||||||
|
|
||||||
|
let result = cached;
|
||||||
|
if (!result) {
|
||||||
|
result = await listLauncherResourcesForUserUncached(
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
userRoleIds,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
|
||||||
|
}
|
||||||
|
|
||||||
const offset = (query.page - 1) * query.pageSize;
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
resources: items.slice(offset, offset + query.pageSize),
|
resources: result.items.slice(offset, offset + query.pageSize),
|
||||||
total
|
total: result.total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1296,10 +1471,6 @@ async function collectAccessibleLabels(
|
|||||||
): Promise<Map<number, LauncherLabel>> {
|
): Promise<Map<number, LauncherLabel>> {
|
||||||
const labelMap = new Map<number, LauncherLabel>();
|
const labelMap = new Map<number, LauncherLabel>();
|
||||||
|
|
||||||
if (!(await labelsEnabled(orgId))) {
|
|
||||||
return labelMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessible.resourceIds.length > 0) {
|
if (accessible.resourceIds.length > 0) {
|
||||||
const publicConditions = [
|
const publicConditions = [
|
||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
import {
|
||||||
|
listLauncherGroupsForUser,
|
||||||
|
resolveAccessibleIds
|
||||||
|
} from "./launcherResourceAccess";
|
||||||
|
import {
|
||||||
|
parseIdListParam,
|
||||||
|
type LauncherScaleInfo,
|
||||||
|
type LauncherScaleQuery
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
|
||||||
|
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
|
||||||
|
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
|
||||||
|
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
|
||||||
|
|
||||||
|
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
|
||||||
|
|
||||||
|
type LauncherScaleCountsCacheEntry = {
|
||||||
|
resourceCount: number;
|
||||||
|
siteGroupCount: number;
|
||||||
|
labelGroupCount: number;
|
||||||
|
mode: LauncherScaleInfo["mode"];
|
||||||
|
};
|
||||||
|
|
||||||
|
function launcherScaleCountsCacheKey(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
roleIds: number[]
|
||||||
|
) {
|
||||||
|
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
|
||||||
|
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScaleCapabilities(
|
||||||
|
counts: LauncherScaleCountsCacheEntry,
|
||||||
|
query: LauncherScaleQuery
|
||||||
|
): LauncherScaleInfo["capabilities"] {
|
||||||
|
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||||
|
const labelFilterIds = parseIdListParam(query.labelIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowSiteGrouping:
|
||||||
|
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
|
||||||
|
(siteFilterIds.length > 0 &&
|
||||||
|
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
||||||
|
allowLabelGrouping:
|
||||||
|
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
|
||||||
|
(labelFilterIds.length > 0 &&
|
||||||
|
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
||||||
|
requireSearchOrFilter:
|
||||||
|
counts.mode === "compact" &&
|
||||||
|
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLauncherScaleCountsForUser(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
userRoleIds: number[]
|
||||||
|
): Promise<LauncherScaleCountsCacheEntry> {
|
||||||
|
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
|
||||||
|
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
|
const resourceCount =
|
||||||
|
accessible.resourceIds.length + accessible.siteResourceIds.length;
|
||||||
|
|
||||||
|
const baselineQuery = {
|
||||||
|
query: "",
|
||||||
|
groupBy: "site" as const,
|
||||||
|
siteIds: undefined,
|
||||||
|
labelIds: undefined,
|
||||||
|
sort_by: "name" as const,
|
||||||
|
order: "asc" as const,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
|
||||||
|
await Promise.all([
|
||||||
|
listLauncherGroupsForUser(
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
userRoleIds,
|
||||||
|
baselineQuery
|
||||||
|
),
|
||||||
|
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
|
||||||
|
...baselineQuery,
|
||||||
|
groupBy: "label"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mode =
|
||||||
|
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
|
||||||
|
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
|
||||||
|
? "full"
|
||||||
|
: "compact";
|
||||||
|
|
||||||
|
const result: LauncherScaleCountsCacheEntry = {
|
||||||
|
resourceCount,
|
||||||
|
siteGroupCount,
|
||||||
|
labelGroupCount,
|
||||||
|
mode
|
||||||
|
};
|
||||||
|
|
||||||
|
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLauncherScaleForUser(
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
userRoleIds: number[],
|
||||||
|
query: LauncherScaleQuery
|
||||||
|
): Promise<LauncherScaleInfo> {
|
||||||
|
const counts = await getLauncherScaleCountsForUser(
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
userRoleIds
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: counts.mode,
|
||||||
|
resourceCount: counts.resourceCount,
|
||||||
|
siteGroupCount: counts.siteGroupCount,
|
||||||
|
labelGroupCount: counts.labelGroupCount,
|
||||||
|
capabilities: buildScaleCapabilities(counts, query)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import { getLauncherScaleForUser } from "./launcherScale";
|
||||||
|
import { launcherScaleQuerySchema } from "./types";
|
||||||
|
|
||||||
|
export async function listLauncherScale(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const orgId = req.userOrgId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = launcherScaleQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromZodError(parsed.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = await getLauncherScaleForUser(
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
req.userOrgRoleIds ?? [],
|
||||||
|
parsed.data
|
||||||
|
);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: { scale },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Launcher scale retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
console.error("Error listing launcher scale:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
import { and, eq, isNull, or } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
import {
|
||||||
|
extractDefaultViewOverrides,
|
||||||
function mapViewRow(
|
listVisibleLauncherViews
|
||||||
row: typeof launcherViews.$inferSelect
|
} from "./launcherDefaultView";
|
||||||
): LauncherViewRecord {
|
|
||||||
return {
|
|
||||||
viewId: row.viewId,
|
|
||||||
orgId: row.orgId,
|
|
||||||
userId: row.userId,
|
|
||||||
name: row.name,
|
|
||||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
updatedAt: row.updatedAt,
|
|
||||||
isOrgWide: row.userId == null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLauncherViews(
|
export async function listLauncherViews(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -51,7 +39,8 @@ export async function listLauncherViews(
|
|||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {
|
data: {
|
||||||
views: rows.map(mapViewRow)
|
views: listVisibleLauncherViews(rows),
|
||||||
|
defaultViewOverrides: extractDefaultViewOverrides(rows)
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
||||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
||||||
|
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
|
||||||
|
|
||||||
export const launcherViewConfigSchema = z.object({
|
export const launcherViewConfigSchema = z.object({
|
||||||
groupBy: z.enum(["site", "label"]).default("site"),
|
groupBy: z.enum(["site", "label", "none"]).default("site"),
|
||||||
layout: z.enum(["grid", "list"]).default("grid"),
|
layout: z.enum(["grid", "list"]).default("grid"),
|
||||||
sortBy: z.literal("name").default("name"),
|
sortBy: z.literal("name").default("name"),
|
||||||
order: z.enum(["asc", "desc"]).default("asc"),
|
order: z.enum(["asc", "desc"]).default("asc"),
|
||||||
@@ -88,10 +89,31 @@ export type LauncherViewRecord = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isOrgWide: boolean;
|
isOrgWide: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LauncherDefaultViewOverrides = {
|
||||||
|
personal: LauncherViewRecord | null;
|
||||||
|
orgWide: LauncherViewRecord | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEffectiveDefaultLauncherConfig(
|
||||||
|
overrides: LauncherDefaultViewOverrides
|
||||||
|
): LauncherViewConfig {
|
||||||
|
if (overrides.personal) {
|
||||||
|
return overrides.personal.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.orgWide) {
|
||||||
|
return overrides.orgWide.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultLauncherViewConfig;
|
||||||
|
}
|
||||||
|
|
||||||
export type ListLauncherViewsResponse = {
|
export type ListLauncherViewsResponse = {
|
||||||
views: LauncherViewRecord[];
|
views: LauncherViewRecord[];
|
||||||
|
defaultViewOverrides: LauncherDefaultViewOverrides;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const launcherFilterListQuerySchema = z.strictObject({
|
export const launcherFilterListQuerySchema = z.strictObject({
|
||||||
@@ -100,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.catch(500)
|
.catch(20)
|
||||||
.default(500),
|
.default(20),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default("")
|
query: z.string().optional().default("")
|
||||||
});
|
});
|
||||||
@@ -138,7 +160,7 @@ export const launcherListQuerySchema = z.strictObject({
|
|||||||
.default(20),
|
.default(20),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default(""),
|
query: z.string().optional().default(""),
|
||||||
groupBy: z.enum(["site", "label"]).optional().default("site"),
|
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
||||||
groupKey: z.string().optional(),
|
groupKey: z.string().optional(),
|
||||||
siteIds: z.string().optional(),
|
siteIds: z.string().optional(),
|
||||||
labelIds: z.string().optional(),
|
labelIds: z.string().optional(),
|
||||||
@@ -163,3 +185,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
|||||||
export type LauncherViewSelection =
|
export type LauncherViewSelection =
|
||||||
| { type: "default" }
|
| { type: "default" }
|
||||||
| { type: "saved"; viewId: number };
|
| { type: "saved"; viewId: number };
|
||||||
|
|
||||||
|
export type LauncherScaleCapabilities = {
|
||||||
|
allowSiteGrouping: boolean;
|
||||||
|
allowLabelGrouping: boolean;
|
||||||
|
requireSearchOrFilter: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LauncherScaleInfo = {
|
||||||
|
mode: "full" | "compact";
|
||||||
|
resourceCount: number;
|
||||||
|
siteGroupCount: number;
|
||||||
|
labelGroupCount: number;
|
||||||
|
capabilities: LauncherScaleCapabilities;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListLauncherScaleResponse = {
|
||||||
|
scale: LauncherScaleInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const launcherScaleQuerySchema = z.strictObject({
|
||||||
|
query: z.string().optional().default(""),
|
||||||
|
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
||||||
|
siteIds: z.string().optional(),
|
||||||
|
labelIds: z.string().optional(),
|
||||||
|
sort_by: z.literal("name").optional().default("name"),
|
||||||
|
order: z.enum(["asc", "desc"]).optional().default("asc")
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ export async function updateLauncherView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.isDefault) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Use the default view save endpoint for this view"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isPersonalView = existing.userId === userId;
|
const isPersonalView = existing.userId === userId;
|
||||||
const isOrgWideView = existing.userId == null;
|
const isOrgWideView = existing.userId == null;
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
@@ -135,7 +144,8 @@ export async function updateLauncherView(
|
|||||||
),
|
),
|
||||||
createdAt: updated.createdAt,
|
createdAt: updated.createdAt,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
isOrgWide: updated.userId == null
|
isOrgWide: updated.userId == null,
|
||||||
|
isDefault: updated.isDefault
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||||
|
import { upsertDefaultViewOverride } from "./launcherDefaultView";
|
||||||
|
import { launcherViewConfigSchema } from "./types";
|
||||||
|
|
||||||
|
const upsertLauncherDefaultViewBodySchema = z.strictObject({
|
||||||
|
config: launcherViewConfigSchema,
|
||||||
|
orgWide: z.boolean().optional().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function upsertLauncherDefaultView(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const orgId = req.userOrgId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromZodError(parsed.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.orgWide) {
|
||||||
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
|
ActionsEnum.createOrgWideLauncherView,
|
||||||
|
req
|
||||||
|
);
|
||||||
|
if (!canManageOrgWide) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have permission perform this action"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = await upsertDefaultViewOverride({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
orgWide: parsed.data.orgWide,
|
||||||
|
config: parsed.data.config
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: view,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Launcher default view saved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
console.error("Error saving launcher default view:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
clientsRes
|
clientsRes
|
||||||
.filter((client) => {
|
.filter((client) => {
|
||||||
if (!client.clients.pubKey) {
|
if (!client.clients.pubKey) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Client ${client.clients.clientId} has no public key, skipping`
|
`Client ${client.clients.clientId} has no public key, skipping`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!client.clients.subnet) {
|
if (!client.clients.subnet) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`Client ${client.clients.clientId} has no subnet, skipping`
|
`Client ${client.clients.clientId} has no subnet, skipping`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update database from config: ${error}`);
|
logger.debug(`Failed to update database from config: ${error}`);
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!newt.siteId) {
|
if (!newt.siteId) {
|
||||||
logger.warn("Newt has no client ID!");
|
logger.warn("Newt has no site ID!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +34,12 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
.where(eq(sites.siteId, newt.siteId!))
|
.where(eq(sites.siteId, newt.siteId!))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await fireSiteOfflineAlert(
|
await fireSiteOfflineAlert(
|
||||||
site.orgId,
|
site.orgId,
|
||||||
site.siteId,
|
site.siteId,
|
||||||
@@ -43,6 +49,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling disconnecting message", { error });
|
logger.error("Error handling site disconnecting message", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { sites, clients, olms } from "@server/db";
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||||
|
import { withRetry } from "@server/lib/dbRetry";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping Accumulator
|
* Ping Accumulator
|
||||||
@@ -22,8 +23,6 @@ import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||||
const MAX_RETRIES = 5;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||||
// Map of siteId -> latest ping timestamp (unix seconds)
|
// Map of siteId -> latest ping timestamp (unix seconds)
|
||||||
@@ -266,85 +265,7 @@ export async function flushPingsToDb(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
||||||
|
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
|
||||||
/**
|
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
|
||||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
|
||||||
*
|
|
||||||
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
|
|
||||||
* guarantees exactly one winner per deadlock pair, so the loser just needs
|
|
||||||
* to try again. MAX_RETRIES is intentionally higher than typical connection
|
|
||||||
* retry budgets to give deadlock victims enough chances to succeed.
|
|
||||||
*/
|
|
||||||
async function withRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
context: string
|
|
||||||
): Promise<T> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isTransientError(error) && attempt < MAX_RETRIES) {
|
|
||||||
attempt++;
|
|
||||||
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
|
||||||
const jitter = Math.random() * baseDelay;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
logger.warn(
|
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
|
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect transient errors that are safe to retry.
|
|
||||||
*/
|
|
||||||
function isTransientError(error: any): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
|
||||||
const code = error.code || error.cause?.code || "";
|
|
||||||
|
|
||||||
// Connection timeout / terminated
|
|
||||||
if (
|
|
||||||
message.includes("connection timeout") ||
|
|
||||||
message.includes("connection terminated") ||
|
|
||||||
message.includes("timeout exceeded when trying to connect") ||
|
|
||||||
causeMessage.includes("connection terminated unexpectedly") ||
|
|
||||||
causeMessage.includes("connection timeout")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL serialization failure
|
|
||||||
if (code === "40001") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
|
||||||
if (
|
|
||||||
code === "ECONNRESET" ||
|
|
||||||
code === "ECONNREFUSED" ||
|
|
||||||
code === "EPIPE" ||
|
|
||||||
code === "ETIMEDOUT"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!site.subnet) {
|
if (!site.subnet) {
|
||||||
logger.warn(`Site ${site.siteId} has no subnet, skipping`);
|
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import logger from "@server/logger";
|
|||||||
/**
|
/**
|
||||||
* Handles disconnecting messages from clients to show disconnected in the ui
|
* Handles disconnecting messages from clients to show disconnected in the ui
|
||||||
*/
|
*/
|
||||||
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
|
export const handleOlmDisconnectingMessage: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
@@ -29,6 +31,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (context) =>
|
|||||||
})
|
})
|
||||||
.where(eq(clients.clientId, olm.clientId));
|
.where(eq(clients.clientId, olm.clientId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling disconnecting message", { error });
|
logger.error("Error handling client disconnecting message", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources, resourceWhitelist } from "@server/db";
|
import {
|
||||||
|
resources,
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyWhiteList
|
||||||
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -103,6 +108,64 @@ export async function addEmailToResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A shared policy takes precedence over the resource's inline
|
||||||
|
// (default) policy, which takes precedence over the resource's own
|
||||||
|
// direct whitelist fields. This mirrors the precedence used at
|
||||||
|
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
||||||
|
const policyId =
|
||||||
|
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
||||||
|
|
||||||
|
if (policyId !== null) {
|
||||||
|
const [policy] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Resource policy not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.emailWhitelistEnabled) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email whitelist is not enabled for this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
resourcePolicyWhiteList.resourcePolicyId,
|
||||||
|
policyId
|
||||||
|
),
|
||||||
|
eq(resourcePolicyWhiteList.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Email already exists in whitelist"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(resourcePolicyWhiteList).values({
|
||||||
|
email,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
if (!resource.emailWhitelistEnabled) {
|
if (!resource.emailWhitelistEnabled) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -136,6 +199,7 @@ export async function addEmailToResourceWhitelist(
|
|||||||
email,
|
email,
|
||||||
resourceId
|
resourceId
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -96,12 +96,16 @@ export async function getResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInlinePolicy =
|
// A shared policy takes precedence over the resource's inline
|
||||||
resource.resourcePolicyId === null &&
|
// (default) policy, which takes precedence over the resource's own
|
||||||
resource.defaultResourcePolicyId !== null;
|
// direct whitelist fields. This mirrors the precedence used at
|
||||||
|
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
||||||
|
const policyId =
|
||||||
|
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
||||||
|
|
||||||
const whitelist = isInlinePolicy
|
const whitelist =
|
||||||
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
|
policyId !== null
|
||||||
|
? await queryPolicyWhitelist(policyId)
|
||||||
: await queryWhitelist(resourceId);
|
: await queryWhitelist(resourceId);
|
||||||
|
|
||||||
return response<GetResourceWhitelistResponse>(res, {
|
return response<GetResourceWhitelistResponse>(res, {
|
||||||
|
|||||||
@@ -363,11 +363,6 @@ export async function getUserResources(
|
|||||||
(r) => r.siteResourceId
|
(r) => r.siteResourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
let labelsForResources: Array<{
|
let labelsForResources: Array<{
|
||||||
labelId: number;
|
labelId: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -381,7 +376,6 @@ export async function getUserResources(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
@@ -397,9 +391,7 @@ export async function getUserResources(
|
|||||||
resourceLabels,
|
resourceLabels,
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
)
|
)
|
||||||
.where(
|
.where(inArray(resourceLabels.resourceId, resourceIdList))
|
||||||
inArray(resourceLabels.resourceId, resourceIdList)
|
|
||||||
)
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId)),
|
.orderBy(asc(resourceLabels.resourceLabelId)),
|
||||||
siteResourceIdList.length === 0
|
siteResourceIdList.length === 0
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
@@ -423,7 +415,6 @@ export async function getUserResources(
|
|||||||
)
|
)
|
||||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
|
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
// Check for password, pincode, and whitelist protection for each resource
|
// Check for password, pincode, and whitelist protection for each resource
|
||||||
const resourcesWithAuth = await Promise.all(
|
const resourcesWithAuth = await Promise.all(
|
||||||
|
|||||||
@@ -484,11 +484,6 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
let accessibleResources: Array<{ resourceId: number }>;
|
let accessibleResources: Array<{ resourceId: number }>;
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
accessibleResources = await db
|
accessibleResources = await db
|
||||||
@@ -616,8 +611,8 @@ export async function listResources(
|
|||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
or(
|
or(
|
||||||
eq(effectiveSso, true),
|
effectiveSso,
|
||||||
eq(effectiveWhitelist, true),
|
effectiveWhitelist,
|
||||||
not(isNull(effectiveHeaderAuthId)),
|
not(isNull(effectiveHeaderAuthId)),
|
||||||
not(isNull(effectivePincodeId)),
|
not(isNull(effectivePincodeId)),
|
||||||
not(isNull(effectivePasswordId))
|
not(isNull(effectivePasswordId))
|
||||||
@@ -629,8 +624,8 @@ export async function listResources(
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
not(eq(effectiveSso, true)),
|
not(effectiveSso),
|
||||||
not(eq(effectiveWhitelist, true)),
|
not(effectiveWhitelist),
|
||||||
isNull(effectiveHeaderAuthId),
|
isNull(effectiveHeaderAuthId),
|
||||||
isNull(effectivePincodeId),
|
isNull(effectivePincodeId),
|
||||||
isNull(effectivePasswordId)
|
isNull(effectivePasswordId)
|
||||||
@@ -676,7 +671,7 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
@@ -697,11 +692,7 @@ export async function listResources(
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${resources.name})`, q),
|
like(sql`LOWER(${resources.name})`, q),
|
||||||
like(sql`LOWER(${resources.niceId})`, q),
|
like(sql`LOWER(${resources.niceId})`, q),
|
||||||
like(sql`LOWER(${resources.fullDomain})`, q)
|
like(sql`LOWER(${resources.fullDomain})`, q),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
db
|
db
|
||||||
@@ -713,8 +704,7 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
@@ -747,7 +737,6 @@ export async function listResources(
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForResources =
|
labelsForResources =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -763,11 +752,8 @@ export async function listResources(
|
|||||||
resourceLabels,
|
resourceLabels,
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
)
|
)
|
||||||
.where(
|
.where(inArray(resourceLabels.resourceId, resourceIdList))
|
||||||
inArray(resourceLabels.resourceId, resourceIdList)
|
|
||||||
)
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
.orderBy(asc(resourceLabels.resourceLabelId));
|
||||||
}
|
|
||||||
|
|
||||||
const allResourceTargets =
|
const allResourceTargets =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
|
|||||||
@@ -248,11 +248,6 @@ export async function listUserResourceAliases(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const whereConditions = [
|
const whereConditions = [
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
@@ -262,7 +257,7 @@ export async function listUserResourceAliases(
|
|||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
@@ -310,7 +305,7 @@ export async function listUserResourceAliases(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
if (siteResourceIdList.length > 0) {
|
||||||
labelsForSiteResources = await db
|
labelsForSiteResources = await db
|
||||||
.select({
|
.select({
|
||||||
name: labels.name,
|
name: labels.name,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources, resourceWhitelist } from "@server/db";
|
import {
|
||||||
|
resources,
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyWhiteList
|
||||||
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -102,6 +107,71 @@ export async function removeEmailFromResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A shared policy takes precedence over the resource's inline
|
||||||
|
// (default) policy, which takes precedence over the resource's own
|
||||||
|
// direct whitelist fields. This mirrors the precedence used at
|
||||||
|
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
||||||
|
const policyId =
|
||||||
|
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
||||||
|
|
||||||
|
if (policyId !== null) {
|
||||||
|
const [policy] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Resource policy not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!policy.emailWhitelistEnabled) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email whitelist is not enabled for this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
resourcePolicyWhiteList.resourcePolicyId,
|
||||||
|
policyId
|
||||||
|
),
|
||||||
|
eq(resourcePolicyWhiteList.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Email not found in whitelist"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
resourcePolicyWhiteList.resourcePolicyId,
|
||||||
|
policyId
|
||||||
|
),
|
||||||
|
eq(resourcePolicyWhiteList.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
if (!resource.emailWhitelistEnabled) {
|
if (!resource.emailWhitelistEnabled) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -139,6 +209,7 @@ export async function removeEmailFromResourceWhitelist(
|
|||||||
eq(resourceWhitelist.email, email)
|
eq(resourceWhitelist.email, email)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -109,13 +109,14 @@ export async function setResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInlinePolicy =
|
// A shared policy takes precedence over the resource's inline
|
||||||
resource.resourcePolicyId === null &&
|
// (default) policy, which takes precedence over the resource's own
|
||||||
resource.defaultResourcePolicyId !== null;
|
// direct whitelist fields. This mirrors the precedence used at
|
||||||
|
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
||||||
if (isInlinePolicy) {
|
const policyId =
|
||||||
const policyId = resource.defaultResourcePolicyId!;
|
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
||||||
|
|
||||||
|
if (policyId !== null) {
|
||||||
const [policy] = await db
|
const [policy] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourcePolicies)
|
.from(resourcePolicies)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const resourceRuleMatchSchema = z.enum([
|
|||||||
"IP",
|
"IP",
|
||||||
"PATH",
|
"PATH",
|
||||||
"COUNTRY",
|
"COUNTRY",
|
||||||
|
"COUNTRY_IS_NOT",
|
||||||
"ASN",
|
"ASN",
|
||||||
"REGION"
|
"REGION"
|
||||||
]);
|
]);
|
||||||
@@ -144,6 +145,7 @@ export async function updateResourceRule(
|
|||||||
| "IP"
|
| "IP"
|
||||||
| "PATH"
|
| "PATH"
|
||||||
| "COUNTRY"
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION";
|
| "REGION";
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,11 @@ export async function createSite(
|
|||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
try {
|
try {
|
||||||
if (subnet && exitNodeId) {
|
if (type === "wireguard" && subnet && exitNodeId) {
|
||||||
|
// Only wireguard sites actually persist the provided subnet/exitNodeId.
|
||||||
|
// Newt sites have their subnet/exit node chosen (under a lock) when the
|
||||||
|
// newt connects, so validating them here is both unnecessary and racy,
|
||||||
|
// since pickSiteDefaults does not lock the subnet it suggests.
|
||||||
//make sure the subnet is in the range of the exit node if provided
|
//make sure the subnet is in the range of the exit node if provided
|
||||||
const [exitNode] = await db
|
const [exitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -235,11 +235,6 @@ export async function listSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
@@ -291,7 +286,7 @@ export async function listSites(
|
|||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
sites.siteId,
|
sites.siteId,
|
||||||
@@ -311,11 +306,7 @@ export async function listSites(
|
|||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${sites.name})`, q),
|
like(sql`LOWER(${sites.name})`, q),
|
||||||
like(sql`LOWER(${sites.niceId})`, q)
|
like(sql`LOWER(${sites.niceId})`, q),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
sites.siteId,
|
sites.siteId,
|
||||||
db
|
db
|
||||||
@@ -327,8 +318,8 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
conditions.push(or(...queryList)!);
|
conditions.push(or(...queryList)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +357,6 @@ export async function listSites(
|
|||||||
siteId: number;
|
siteId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForSites =
|
labelsForSites =
|
||||||
siteIds.length === 0
|
siteIds.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -384,7 +374,6 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
.where(inArray(siteLabels.siteId, siteIds))
|
.where(inArray(siteLabels.siteId, siteIds))
|
||||||
.orderBy(asc(siteLabels.siteLabelId));
|
.orderBy(asc(siteLabels.siteLabelId));
|
||||||
}
|
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const createSiteResourceSchema = z
|
|||||||
siteIds: z.array(z.int()).optional(),
|
siteIds: z.array(z.int()).optional(),
|
||||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||||
destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1).nullish(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
@@ -161,7 +161,8 @@ const createSiteResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
data.destination !== undefined && data.destination.trim() !== ""
|
data.destination !== undefined &&
|
||||||
|
data.destination?.trim() !== ""
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -286,11 +286,6 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
labels: labelFilter
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
@@ -320,7 +315,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
conditions.push(eq(siteResources.mode, mode));
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
@@ -344,11 +339,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
like(sql`LOWER(${siteResources.destination})`, q),
|
like(sql`LOWER(${siteResources.destination})`, q),
|
||||||
like(sql`LOWER(${siteResources.alias})`, q),
|
like(sql`LOWER(${siteResources.alias})`, q),
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
||||||
like(sql`LOWER(${sites.name})`, q)
|
like(sql`LOWER(${sites.name})`, q),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
db
|
db
|
||||||
@@ -360,8 +351,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
@@ -403,7 +393,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
if (siteResourceIdList.length > 0) {
|
||||||
labelsForSiteResources = await db
|
labelsForSiteResources = await db
|
||||||
.select({
|
.select({
|
||||||
labelId: labels.labelId,
|
labelId: labels.labelId,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const updateSiteResourceSchema = z
|
|||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).nullish(),
|
scheme: z.enum(["http", "https"]).nullish(),
|
||||||
destinationPort: z.int().positive().nullish(),
|
destinationPort: z.int().positive().nullish(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1).nullish(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
@@ -68,9 +68,9 @@ const updateSiteResourceSchema = z
|
|||||||
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
|
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
|
||||||
example: "service.example.internal"
|
example: "service.example.internal"
|
||||||
}),
|
}),
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()).optional(),
|
||||||
roleIds: z.array(z.int()),
|
roleIds: z.array(z.int()).optional(),
|
||||||
clientIds: z.array(z.int()),
|
clientIds: z.array(z.int()).optional(),
|
||||||
tcpPortRangeString: portRangeStringSchema,
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
udpPortRangeString: portRangeStringSchema,
|
udpPortRangeString: portRangeStringSchema,
|
||||||
disableIcmp: z.boolean().optional(),
|
disableIcmp: z.boolean().optional(),
|
||||||
@@ -157,7 +157,8 @@ const updateSiteResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
data.destination !== undefined && data.destination.trim() !== ""
|
data.destination !== undefined &&
|
||||||
|
data.destination?.trim() !== ""
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -167,6 +168,10 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
// if neither is provided, the existing site associations are left unchanged
|
||||||
|
if (data.siteIds === undefined && data.siteId === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||||
data.siteId !== undefined
|
data.siteId !== undefined
|
||||||
@@ -263,7 +268,7 @@ export async function updateSiteResource(
|
|||||||
const { siteResourceId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
siteIds: siteIdsInput = [], // because it can change
|
siteIds: siteIdsInput,
|
||||||
siteId,
|
siteId,
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
@@ -287,10 +292,15 @@ export async function updateSiteResource(
|
|||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Backward compatibility: merge deprecated siteId into siteIds array
|
// Backward compatibility: merge deprecated siteId into siteIds array
|
||||||
const siteIds = [...siteIdsInput];
|
const siteIdsProvided =
|
||||||
|
siteIdsInput !== undefined || siteId !== undefined;
|
||||||
|
let siteIds: number[] | undefined;
|
||||||
|
if (siteIdsProvided) {
|
||||||
|
siteIds = [...(siteIdsInput ?? [])];
|
||||||
if (siteId !== undefined && !siteIds.includes(siteId)) {
|
if (siteId !== undefined && !siteIds.includes(siteId)) {
|
||||||
siteIds.push(siteId);
|
siteIds.push(siteId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if site resource exists
|
// Check if site resource exists
|
||||||
const [existingSiteResource] = await db
|
const [existingSiteResource] = await db
|
||||||
@@ -356,6 +366,7 @@ export async function updateSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
|
if (siteIds !== undefined) {
|
||||||
const sitesToAssign = await db
|
const sitesToAssign = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
@@ -371,6 +382,7 @@ export async function updateSiteResource(
|
|||||||
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only check if destination is an IP address
|
// Only check if destination is an IP address
|
||||||
const isIp = z
|
const isIp = z
|
||||||
@@ -463,7 +475,8 @@ export async function updateSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let updatedSiteResource: SiteResource | undefined;
|
let updatedSiteResource: SiteResource | undefined;
|
||||||
let updatedSiteIds: number[] = [];
|
// defaults to the existing sites; only overwritten below if siteIds/siteId was provided
|
||||||
|
let updatedSiteIds: number[] = [...existingSiteIds];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Update the site resource
|
// Update the site resource
|
||||||
const sshPamSet =
|
const sshPamSet =
|
||||||
@@ -522,13 +535,18 @@ export async function updateSiteResource(
|
|||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
if (siteIds !== undefined) {
|
||||||
// delete the site - site resources associations
|
// delete the site - site resources associations
|
||||||
await trx
|
await trx
|
||||||
.delete(siteNetworks)
|
.delete(siteNetworks)
|
||||||
.where(
|
.where(
|
||||||
eq(siteNetworks.networkId, updatedSiteResource.networkId!)
|
eq(
|
||||||
|
siteNetworks.networkId,
|
||||||
|
updatedSiteResource.networkId!
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
updatedSiteIds = [];
|
||||||
for (const siteId of siteIds) {
|
for (const siteId of siteIds) {
|
||||||
await trx.insert(siteNetworks).values({
|
await trx.insert(siteNetworks).values({
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
@@ -536,10 +554,14 @@ export async function updateSiteResource(
|
|||||||
});
|
});
|
||||||
updatedSiteIds.push(siteId);
|
updatedSiteIds.push(siteId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientIds !== undefined) {
|
||||||
await trx
|
await trx
|
||||||
.delete(clientSiteResources)
|
.delete(clientSiteResources)
|
||||||
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
.where(
|
||||||
|
eq(clientSiteResources.siteResourceId, siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
if (clientIds.length > 0) {
|
if (clientIds.length > 0) {
|
||||||
await trx.insert(clientSiteResources).values(
|
await trx.insert(clientSiteResources).values(
|
||||||
@@ -549,10 +571,14 @@ export async function updateSiteResource(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds !== undefined) {
|
||||||
await trx
|
await trx
|
||||||
.delete(userSiteResources)
|
.delete(userSiteResources)
|
||||||
.where(eq(userSiteResources.siteResourceId, siteResourceId));
|
.where(
|
||||||
|
eq(userSiteResources.siteResourceId, siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
await trx.insert(userSiteResources).values(
|
await trx.insert(userSiteResources).values(
|
||||||
@@ -562,7 +588,9 @@ export async function updateSiteResource(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleIds !== undefined) {
|
||||||
// Get all admin role IDs for this org to exclude from deletion
|
// Get all admin role IDs for this org to exclude from deletion
|
||||||
const adminRoles = await trx
|
const adminRoles = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -578,7 +606,10 @@ export async function updateSiteResource(
|
|||||||
if (adminRoleIds.length > 0) {
|
if (adminRoleIds.length > 0) {
|
||||||
await trx.delete(roleSiteResources).where(
|
await trx.delete(roleSiteResources).where(
|
||||||
and(
|
and(
|
||||||
eq(roleSiteResources.siteResourceId, siteResourceId),
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
siteResourceId
|
||||||
|
),
|
||||||
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -598,6 +629,7 @@ export async function updateSiteResource(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Updated site resource ${siteResourceId}`);
|
logger.info(`Updated site resource ${siteResourceId}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import m17 from "./scriptsPg/1.18.0";
|
|||||||
import m18 from "./scriptsPg/1.18.3";
|
import m18 from "./scriptsPg/1.18.3";
|
||||||
import m19 from "./scriptsPg/1.18.4";
|
import m19 from "./scriptsPg/1.18.4";
|
||||||
import m20 from "./scriptsPg/1.19.0";
|
import m20 from "./scriptsPg/1.19.0";
|
||||||
|
import m21 from "./scriptsPg/1.20.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -51,7 +52,8 @@ const migrations = [
|
|||||||
{ version: "1.18.0", run: m17 },
|
{ version: "1.18.0", run: m17 },
|
||||||
{ version: "1.18.3", run: m18 },
|
{ version: "1.18.3", run: m18 },
|
||||||
{ version: "1.18.4", run: m19 },
|
{ version: "1.18.4", run: m19 },
|
||||||
{ version: "1.19.0", run: m20 }
|
{ version: "1.19.0", run: m20 },
|
||||||
|
{ version: "1.20.0", run: m21 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import m39 from "./scriptsSqlite/1.18.3";
|
|||||||
import m40 from "./scriptsSqlite/1.18.4";
|
import m40 from "./scriptsSqlite/1.18.4";
|
||||||
import m41 from "./scriptsSqlite/1.19.0";
|
import m41 from "./scriptsSqlite/1.19.0";
|
||||||
import m42 from "./scriptsSqlite/1.19.1";
|
import m42 from "./scriptsSqlite/1.19.1";
|
||||||
import m43 from "./scriptsSqlite/1.19.5";
|
import m43 from "./scriptsSqlite/1.20.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -89,7 +89,7 @@ const migrations = [
|
|||||||
{ version: "1.18.4", run: m40 },
|
{ version: "1.18.4", run: m40 },
|
||||||
{ version: "1.19.0", run: m41 },
|
{ version: "1.19.0", run: m41 },
|
||||||
{ version: "1.19.1", run: m42 },
|
{ version: "1.19.1", run: m42 },
|
||||||
{ version: "1.19.5", run: m43 }
|
{ version: "1.20.0", run: m43 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { db } from "@server/db/pg/driver";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.20.0";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(sql`BEGIN`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "remoteExitNodePreferenceLabels" (
|
||||||
|
"remoteExitNodePreferenceLabelId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"remoteExitNodeId" varchar NOT NULL,
|
||||||
|
"labelId" integer NOT NULL,
|
||||||
|
CONSTRAINT "remote_exit_node_preference_label_uniq" UNIQUE("remoteExitNodeId","labelId")
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "remoteExitNodeResources" (
|
||||||
|
"remoteExitNodeResourceId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"remoteExitNodeId" varchar NOT NULL,
|
||||||
|
"destination" varchar NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "launcherViews" (
|
||||||
|
"viewId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"orgId" varchar NOT NULL,
|
||||||
|
"userId" varchar,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"config" text NOT NULL,
|
||||||
|
"isDefault" boolean DEFAULT false NOT NULL,
|
||||||
|
"createdAt" varchar NOT NULL,
|
||||||
|
"updatedAt" varchar NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "domains" ADD COLUMN "lastCheckedAt" integer;`
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "remoteExitNodePreferenceLabels" ADD CONSTRAINT "remoteExitNodePreferenceLabels_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "remoteExitNodePreferenceLabels" ADD CONSTRAINT "remoteExitNodePreferenceLabels_labelId_labels_labelId_fk" FOREIGN KEY ("labelId") REFERENCES "public"."labels"("labelId") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "remoteExitNodeResources" ADD CONSTRAINT "remoteExitNodeResources_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "launcherViews" ADD CONSTRAINT "launcherViews_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "launcherViews" ADD CONSTRAINT "launcherViews_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_clients_orgid_niceid" ON "clients" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_networks_orgid" ON "networks" USING btree ("orgId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_resourcepolicies_orgid_niceid" ON "resourcePolicies" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_resources_niceid" ON "resources" USING btree ("niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_resources_orgid_niceid" ON "resources" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_siteresources_orgid_niceid" ON "siteResources" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_sites_orgid_niceid" ON "sites" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`COMMIT`);
|
||||||
|
console.log("Migrated database");
|
||||||
|
} catch (e) {
|
||||||
|
await db.execute(sql`ROLLBACK`);
|
||||||
|
console.log("Unable to migrate database");
|
||||||
|
console.log(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { APP_PATH } from "@server/lib/consts";
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const version = "1.19.5";
|
const version = "1.20.0";
|
||||||
|
|
||||||
export default async function migration() {
|
export default async function migration() {
|
||||||
console.log(`Running setup script ${version}...`);
|
console.log(`Running setup script ${version}...`);
|
||||||
@@ -92,6 +92,64 @@ export default async function migration() {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';`
|
`ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';`
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'remoteExitNodePreferenceLabels' (
|
||||||
|
'remoteExitNodePreferenceLabelId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'remoteExitNodeId' text NOT NULL,
|
||||||
|
'labelId' integer NOT NULL,
|
||||||
|
FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY ('labelId') REFERENCES 'labels'('labelId') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE UNIQUE INDEX 'remote_exit_node_preference_label_uniq' ON 'remoteExitNodePreferenceLabels' ('remoteExitNodeId','labelId');
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'remoteExitNodeResources' (
|
||||||
|
'remoteExitNodeResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'remoteExitNodeId' text NOT NULL,
|
||||||
|
'destination' text NOT NULL,
|
||||||
|
FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'launcherViews' (
|
||||||
|
'viewId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'orgId' text NOT NULL,
|
||||||
|
'userId' text,
|
||||||
|
'name' text NOT NULL,
|
||||||
|
'config' text NOT NULL,
|
||||||
|
'isDefault' integer DEFAULT false NOT NULL,
|
||||||
|
'createdAt' text NOT NULL,
|
||||||
|
'updatedAt' text NOT NULL,
|
||||||
|
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'domains' ADD 'customCertResolver' text;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'domains' ADD 'lastCheckedAt' integer;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
|
||||||
|
import { commandBarNavSections } from "@app/app/navigation";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
|
import { fetchLauncherPageData } from "@app/lib/launcherServerData";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
|
||||||
@@ -57,6 +59,8 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||||
|
const env = pullEnv();
|
||||||
|
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
const launcherData = overview
|
const launcherData = overview
|
||||||
@@ -73,6 +77,9 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
navItems={[]}
|
navItems={[]}
|
||||||
|
commandNavItems={commandBarNavSections(env, {
|
||||||
|
isPrimaryOrg: primaryOrg
|
||||||
|
})}
|
||||||
showSidebar={false}
|
showSidebar={false}
|
||||||
launcherMode
|
launcherMode
|
||||||
showViewAsAdmin={isAdminOrOwner}
|
showViewAsAdmin={isAdminOrOwner}
|
||||||
@@ -82,9 +89,11 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
isAdmin={isAdminOrOwner}
|
isAdmin={isAdminOrOwner}
|
||||||
views={launcherData.views}
|
views={launcherData.views}
|
||||||
|
defaultViewOverrides={launcherData.defaultViewOverrides}
|
||||||
activeViewId={launcherData.activeViewId}
|
activeViewId={launcherData.activeViewId}
|
||||||
config={launcherData.config}
|
config={launcherData.config}
|
||||||
savedConfig={launcherData.savedConfig}
|
savedConfig={launcherData.savedConfig}
|
||||||
|
scale={launcherData.scale}
|
||||||
groups={launcherData.groups}
|
groups={launcherData.groups}
|
||||||
groupsPagination={launcherData.groupsPagination}
|
groupsPagination={launcherData.groupsPagination}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -290,7 +290,13 @@ export default function BillingPage() {
|
|||||||
setHasSubscription(
|
setHasSubscription(
|
||||||
tierSub.subscription.status === "active"
|
tierSub.subscription.status === "active"
|
||||||
);
|
);
|
||||||
setIsTrial(tierSub.subscription.expiresAt != null);
|
// expiresAt is only meaningful while the trial hasn't
|
||||||
|
// actually run out yet; a stale row with a past
|
||||||
|
// expiresAt should no longer be treated as a live trial
|
||||||
|
const expiresAt = tierSub.subscription.expiresAt;
|
||||||
|
setIsTrial(
|
||||||
|
expiresAt != null && expiresAt * 1000 > Date.now()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find license subscription
|
// Find license subscription
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function AccessControlsPage() {
|
|||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("accessRoleErrorAdd"),
|
title: t("accessRoleRequired"),
|
||||||
description: t("accessRoleSelectPlease")
|
description: t("accessRoleSelectPlease")
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -173,15 +173,13 @@ export default function AccessControlsPage() {
|
|||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("accessRoleErrorAdd"),
|
title: t("accessRoleRequired"),
|
||||||
description: t("accessRoleSelectPlease")
|
description: t("accessRoleSelectPlease")
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const willHaveAdminRole = values.roles.some(
|
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
|
||||||
(r) => r.isAdmin === true
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRemovingOwnAdmin =
|
const isRemovingOwnAdmin =
|
||||||
sessionUser.userId === user.userId &&
|
sessionUser.userId === user.userId &&
|
||||||
@@ -226,7 +224,9 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => void handleAccessControlsSubmit(e)}
|
onSubmit={(e) =>
|
||||||
|
void handleAccessControlsSubmit(e)
|
||||||
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="access-controls-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -195,9 +195,9 @@ export default function GeneralPage() {
|
|||||||
if (agent in latestPlatformVersions) {
|
if (agent in latestPlatformVersions) {
|
||||||
const agentVersion = latestPlatformVersions[agent];
|
const agentVersion = latestPlatformVersions[agent];
|
||||||
|
|
||||||
updateAvailable = semver.lt(
|
updateAvailable = Boolean(
|
||||||
client.olmVersion,
|
semver.valid(client.olmVersion) &&
|
||||||
agentVersion.latestVersion
|
semver.lt(client.olmVersion, agentVersion.latestVersion)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -3,9 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -51,8 +49,6 @@ export default async function LabelsPage({ params, searchParams }: Props) {
|
|||||||
description={t("orgLabelsDescription")}
|
description={t("orgLabelsDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
|
||||||
|
|
||||||
<OrgLabelsTable
|
<OrgLabelsTable
|
||||||
labels={labels}
|
labels={labels}
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
@@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider";
|
|||||||
import { Layout } from "@app/components/Layout";
|
import { Layout } from "@app/components/Layout";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { orgNavSections } from "@app/app/navigation";
|
import { commandBarNavSections, orgNavSections } from "@app/app/navigation";
|
||||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
navItems={orgNavSections(env, {
|
navItems={orgNavSections(env, {
|
||||||
isPrimaryOrg: primaryOrg
|
isPrimaryOrg: primaryOrg
|
||||||
})}
|
})}
|
||||||
|
commandNavItems={commandBarNavSections(env, {
|
||||||
|
isPrimaryOrg: primaryOrg
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
@@ -343,7 +344,10 @@ export default function GeneralPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
row.original.siteResourceId != null
|
row.original.siteResourceId != null
|
||||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
? getPrivateResourceSettingsHref(
|
||||||
|
row.original.orgId,
|
||||||
|
row.original.resourceNiceId
|
||||||
|
)
|
||||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import { logQueries } from "@app/lib/queries";
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -323,7 +324,10 @@ export default function ConnectionLogsPage() {
|
|||||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
|
href={getPrivateResourceSettingsHref(
|
||||||
|
row.original.orgId,
|
||||||
|
row.original.resourceNiceId
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
{row.original.resourceName}
|
{row.original.resourceName}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import { logQueries } from "@app/lib/queries";
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -395,7 +396,10 @@ export default function GeneralPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
? getPrivateResourceSettingsHref(
|
||||||
|
row.original.orgId,
|
||||||
|
row.original.resourceNiceId
|
||||||
|
)
|
||||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||||
}
|
}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MachinesSelector } from "@app/components/machines-selector";
|
||||||
|
import { RolesSelector } from "@app/components/roles-selector";
|
||||||
|
import { UsersSelector } from "@app/components/users-selector";
|
||||||
|
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||||
|
import type { Tag } from "@app/components/tags/tag-input";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Control } from "react-hook-form";
|
||||||
|
|
||||||
|
type AccessFormValues = {
|
||||||
|
roles?: Tag[];
|
||||||
|
users?: Tag[];
|
||||||
|
clients?: PrivateResourceClient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PrivateResourceAccessFieldsProps = {
|
||||||
|
control: Control<AccessFormValues>;
|
||||||
|
orgId: string;
|
||||||
|
loading?: boolean;
|
||||||
|
hasMachineClients?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceAccessFields({
|
||||||
|
control,
|
||||||
|
orgId,
|
||||||
|
loading = false,
|
||||||
|
hasMachineClients = false
|
||||||
|
}: PrivateResourceAccessFieldsProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">{t("loading")}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="roles"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("roles")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RolesSelector
|
||||||
|
selectedRoles={field.value ?? []}
|
||||||
|
orgId={orgId}
|
||||||
|
restrictAdminRole
|
||||||
|
onSelectRoles={(newRoles) => {
|
||||||
|
field.onChange(newRoles);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="users"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("users")}</FormLabel>
|
||||||
|
<UsersSelector
|
||||||
|
selectedUsers={field.value ?? []}
|
||||||
|
orgId={orgId}
|
||||||
|
onSelectUsers={(newUsers) => {
|
||||||
|
field.onChange(newUsers);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
{hasMachineClients && (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="clients"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col items-start">
|
||||||
|
<FormLabel>{t("machineClients")}</FormLabel>
|
||||||
|
<MachinesSelector
|
||||||
|
selectedMachines={field.value ?? []}
|
||||||
|
orgId={orgId}
|
||||||
|
onSelectMachines={(machines) => {
|
||||||
|
field.onChange(machines);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
</SettingsFormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Control, UseFormWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
type PrivateResourceAliasFieldProps = {
|
||||||
|
control: Control<any>;
|
||||||
|
watch: UseFormWatch<any>;
|
||||||
|
labelPrefix?: "create" | "edit";
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceAliasField({
|
||||||
|
control,
|
||||||
|
watch,
|
||||||
|
labelPrefix = "edit",
|
||||||
|
disabled = false
|
||||||
|
}: PrivateResourceAliasFieldProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const aliasLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogAlias"
|
||||||
|
: "editInternalResourceDialogAlias";
|
||||||
|
const aliasDescriptionKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogAliasDescription"
|
||||||
|
: "editInternalResourceDialogAliasDescription";
|
||||||
|
|
||||||
|
const aliasValue = watch("alias");
|
||||||
|
const aliasEndsWithLocal =
|
||||||
|
typeof aliasValue === "string" &&
|
||||||
|
aliasValue.trim().toLowerCase().endsWith(".local");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="alias"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(aliasLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{aliasEndsWithLocal && (
|
||||||
|
<p className="text-xs text-amber-700/80 mt-1">
|
||||||
|
{t("internalResourceAliasLocalWarning")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrivateResourceHostDestinationFieldsProps = {
|
||||||
|
control: Control<any>;
|
||||||
|
watch: UseFormWatch<any>;
|
||||||
|
labelPrefix?: "create" | "edit";
|
||||||
|
hideAlias?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceHostDestinationFields({
|
||||||
|
control,
|
||||||
|
watch,
|
||||||
|
labelPrefix = "edit",
|
||||||
|
hideAlias = false
|
||||||
|
}: PrivateResourceHostDestinationFieldsProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const destinationLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogDestination"
|
||||||
|
: "editInternalResourceDialogDestination";
|
||||||
|
|
||||||
|
const destinationField = (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="destination"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value === ""
|
||||||
|
? null
|
||||||
|
: e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hideAlias) {
|
||||||
|
return destinationField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceAliasField
|
||||||
|
control={control}
|
||||||
|
watch={watch}
|
||||||
|
labelPrefix={labelPrefix}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateResourceCidrDestinationField({
|
||||||
|
control,
|
||||||
|
labelPrefix = "edit"
|
||||||
|
}: {
|
||||||
|
control: Control<any>;
|
||||||
|
labelPrefix?: "create" | "edit";
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const destinationLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogDestination"
|
||||||
|
: "editInternalResourceDialogDestination";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="destination"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value === ""
|
||||||
|
? null
|
||||||
|
: e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import {
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
type PrivateResourceHttpFieldsProps = {
|
||||||
|
control: Control<any>;
|
||||||
|
setValue: UseFormSetValue<any>;
|
||||||
|
orgId: string;
|
||||||
|
watch: UseFormWatch<any>;
|
||||||
|
disabled?: boolean;
|
||||||
|
siteResourceId?: number;
|
||||||
|
labelPrefix?: "create" | "edit";
|
||||||
|
hideDomainPicker?: boolean;
|
||||||
|
hidePaidFeaturesAlert?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceHttpFields({
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
orgId,
|
||||||
|
watch,
|
||||||
|
disabled = false,
|
||||||
|
siteResourceId,
|
||||||
|
labelPrefix = "edit",
|
||||||
|
hideDomainPicker = false,
|
||||||
|
hidePaidFeaturesAlert = false
|
||||||
|
}: PrivateResourceHttpFieldsProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const schemeLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogScheme"
|
||||||
|
: "editInternalResourceDialogScheme";
|
||||||
|
const destinationLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogDestination"
|
||||||
|
: "editInternalResourceDialogDestination";
|
||||||
|
const destinationPortLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogModePort"
|
||||||
|
: "editInternalResourceDialogModePort";
|
||||||
|
const httpConfigurationTitleKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogHttpConfiguration"
|
||||||
|
: "editInternalResourceDialogHttpConfiguration";
|
||||||
|
const httpConfigurationDescriptionKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||||
|
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||||
|
const enableSslLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogEnableSsl"
|
||||||
|
: "editInternalResourceDialogEnableSsl";
|
||||||
|
const enableSslDescriptionKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogEnableSslDescription"
|
||||||
|
: "editInternalResourceDialogEnableSslDescription";
|
||||||
|
|
||||||
|
const httpConfigSubdomain = watch("httpConfigSubdomain");
|
||||||
|
const httpConfigDomainId = watch("httpConfigDomainId");
|
||||||
|
const httpConfigFullDomain = watch("httpConfigFullDomain");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsFormGrid>
|
||||||
|
{!hidePaidFeaturesAlert && (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.advancedPrivateResources}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsFormCell span="quarter">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="scheme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(schemeLabelKey)}</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value ?? "http"}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="http">http</SelectItem>
|
||||||
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="destination"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value === ""
|
||||||
|
? null
|
||||||
|
: e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="quarter">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="destinationPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (raw === "") {
|
||||||
|
field.onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
field.onChange(
|
||||||
|
Number.isFinite(n) ? n : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
{!hideDomainPicker && (
|
||||||
|
<>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<SettingsSubsectionHeader>
|
||||||
|
<SettingsSubsectionTitle>
|
||||||
|
{t(httpConfigurationTitleKey)}
|
||||||
|
</SettingsSubsectionTitle>
|
||||||
|
<SettingsSubsectionDescription>
|
||||||
|
{t(httpConfigurationDescriptionKey)}
|
||||||
|
</SettingsSubsectionDescription>
|
||||||
|
</SettingsSubsectionHeader>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
disabled
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DomainPicker
|
||||||
|
key={
|
||||||
|
siteResourceId
|
||||||
|
? `http-domain-${siteResourceId}`
|
||||||
|
: "http-domain-create"
|
||||||
|
}
|
||||||
|
orgId={orgId}
|
||||||
|
cols={2}
|
||||||
|
hideFreeDomain
|
||||||
|
defaultSubdomain={
|
||||||
|
httpConfigSubdomain ?? undefined
|
||||||
|
}
|
||||||
|
defaultDomainId={
|
||||||
|
httpConfigDomainId ?? undefined
|
||||||
|
}
|
||||||
|
defaultFullDomain={
|
||||||
|
httpConfigFullDomain ?? undefined
|
||||||
|
}
|
||||||
|
onDomainChange={(res) => {
|
||||||
|
if (res === null) {
|
||||||
|
setValue("httpConfigSubdomain", null);
|
||||||
|
setValue("httpConfigDomainId", null);
|
||||||
|
setValue("httpConfigFullDomain", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValue(
|
||||||
|
"httpConfigSubdomain",
|
||||||
|
res.subdomain ?? null
|
||||||
|
);
|
||||||
|
setValue(
|
||||||
|
"httpConfigDomainId",
|
||||||
|
res.domainId
|
||||||
|
);
|
||||||
|
setValue(
|
||||||
|
"httpConfigFullDomain",
|
||||||
|
res.fullDomain
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="ssl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="private-resource-ssl"
|
||||||
|
label={t(enableSslLabelKey)}
|
||||||
|
description={t(
|
||||||
|
enableSslDescriptionKey
|
||||||
|
)}
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hideDomainPicker && (
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="ssl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="private-resource-ssl"
|
||||||
|
label={t(enableSslLabelKey)}
|
||||||
|
description={t(enableSslDescriptionKey)}
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
</SettingsFormGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export function PrivateResourceMultiSiteRoutingHelp() {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import {
|
||||||
|
getPortModeFromString,
|
||||||
|
getPortStringFromMode,
|
||||||
|
type PortMode
|
||||||
|
} from "@app/lib/privateResourceForm";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||||
|
|
||||||
|
type PrivateResourceNetworkAccessFieldsProps = {
|
||||||
|
control: Control<any>;
|
||||||
|
setValue: UseFormSetValue<any>;
|
||||||
|
showPortRanges?: boolean;
|
||||||
|
initialTcp?: string | null;
|
||||||
|
initialUdp?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
icmpId?: string;
|
||||||
|
embedInParentGrid?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceAllowIcmpField({
|
||||||
|
control,
|
||||||
|
id = "private-resource-allow-icmp",
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
control: Control<any>;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="disableIcmp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id={id}
|
||||||
|
label={t("privateResourceAllowIcmpPing")}
|
||||||
|
checked={!field.value}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
field.onChange(!checked)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivateResourceNetworkAccessHeader() {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<SettingsSubsectionHeader>
|
||||||
|
<SettingsSubsectionTitle>
|
||||||
|
{t("privateResourceNetworkAccess")}
|
||||||
|
</SettingsSubsectionTitle>
|
||||||
|
<SettingsSubsectionDescription>
|
||||||
|
{t("privateResourceNetworkAccessDescription")}
|
||||||
|
</SettingsSubsectionDescription>
|
||||||
|
</SettingsSubsectionHeader>
|
||||||
|
</SettingsFormCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateResourceNetworkAccessFields({
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
showPortRanges = true,
|
||||||
|
initialTcp,
|
||||||
|
initialUdp,
|
||||||
|
disabled = false,
|
||||||
|
icmpId = "private-resource-allow-icmp",
|
||||||
|
embedInParentGrid = false
|
||||||
|
}: PrivateResourceNetworkAccessFieldsProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
|
||||||
|
getPortModeFromString(initialTcp)
|
||||||
|
);
|
||||||
|
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
|
||||||
|
getPortModeFromString(initialUdp)
|
||||||
|
);
|
||||||
|
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
|
||||||
|
initialTcp && initialTcp !== "*" ? initialTcp : ""
|
||||||
|
);
|
||||||
|
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
|
||||||
|
initialUdp && initialUdp !== "*" ? initialUdp : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPortRanges) return;
|
||||||
|
|
||||||
|
setValue(
|
||||||
|
"tcpPortRangeString",
|
||||||
|
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
|
||||||
|
);
|
||||||
|
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPortRanges) return;
|
||||||
|
|
||||||
|
setValue(
|
||||||
|
"udpPortRangeString",
|
||||||
|
getPortStringFromMode(udpPortMode, udpCustomPorts)
|
||||||
|
);
|
||||||
|
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
|
||||||
|
|
||||||
|
const content: ReactNode = (
|
||||||
|
<>
|
||||||
|
<PrivateResourceNetworkAccessHeader />
|
||||||
|
|
||||||
|
{showPortRanges ? (
|
||||||
|
<>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="tcpPortRangeString"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("editInternalResourceDialogTcp")}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={tcpPortMode}
|
||||||
|
onValueChange={(v: PortMode) =>
|
||||||
|
setTcpPortMode(v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{t("allPorts")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="blocked">
|
||||||
|
{t("blocked")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
{t("custom")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{tcpPortMode === "custom" ? (
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
placeholder="80,443,8000-9000"
|
||||||
|
value={tcpCustomPorts}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTcpCustomPorts(
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
disabled
|
||||||
|
placeholder={
|
||||||
|
tcpPortMode === "all"
|
||||||
|
? t("allPortsAllowed")
|
||||||
|
: t("allPortsBlocked")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="udpPortRangeString"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("editInternalResourceDialogUdp")}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={udpPortMode}
|
||||||
|
onValueChange={(v: PortMode) =>
|
||||||
|
setUdpPortMode(v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="w-[110px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{t("allPorts")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="blocked">
|
||||||
|
{t("blocked")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
{t("custom")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{udpPortMode === "custom" ? (
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
placeholder="53,123,500-600"
|
||||||
|
value={udpCustomPorts}
|
||||||
|
onChange={(e) =>
|
||||||
|
setUdpCustomPorts(
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
disabled
|
||||||
|
placeholder={
|
||||||
|
udpPortMode === "all"
|
||||||
|
? t("allPortsAllowed")
|
||||||
|
: t("allPortsBlocked")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourceAllowIcmpField
|
||||||
|
control={control}
|
||||||
|
id={icmpId}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedInParentGrid) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateResourcePortRanges(
|
||||||
|
props: Omit<
|
||||||
|
PrivateResourceNetworkAccessFieldsProps,
|
||||||
|
"showPortRanges" | "embedInParentGrid"
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MultiSitesSelector,
|
||||||
|
formatMultiSitesSelectorLabel
|
||||||
|
} from "@app/components/multi-site-selector";
|
||||||
|
import { SitesSelector } from "@app/components/site-selector";
|
||||||
|
import type { Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||||
|
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||||
|
|
||||||
|
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||||
|
control: Control<T>;
|
||||||
|
orgId: string;
|
||||||
|
selectedSites: Selectedsite[];
|
||||||
|
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||||
|
siteIdsFieldName?: FieldPath<T>;
|
||||||
|
singleSite?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceSitesField<T extends FieldValues>({
|
||||||
|
control,
|
||||||
|
orgId,
|
||||||
|
selectedSites,
|
||||||
|
onSelectedSitesChange,
|
||||||
|
siteIdsFieldName = "siteIds" as FieldPath<T>,
|
||||||
|
singleSite = false
|
||||||
|
}: PrivateResourceSitesFieldProps<T>) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name={siteIdsFieldName}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>{t("sites")}</FormLabel>
|
||||||
|
{singleSite ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between",
|
||||||
|
selectedSites.length === 0 &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-left">
|
||||||
|
{selectedSites[0]?.name ??
|
||||||
|
t("selectSite")}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<SitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSite={selectedSites[0] ?? null}
|
||||||
|
filterTypes={["newt"]}
|
||||||
|
onSelectSite={(site) => {
|
||||||
|
onSelectedSitesChange([site]);
|
||||||
|
field.onChange([site.siteId]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between",
|
||||||
|
selectedSites.length === 0 &&
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-left">
|
||||||
|
{formatMultiSitesSelectorLabel(
|
||||||
|
selectedSites,
|
||||||
|
t
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<MultiSitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
filterTypes={["newt"]}
|
||||||
|
onSelectionChange={(sites) => {
|
||||||
|
onSelectedSitesChange(sites);
|
||||||
|
field.onChange(
|
||||||
|
sites.map((s) => s.siteId)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
{!singleSite && selectedSites.length > 1 ? (
|
||||||
|
<PrivateResourceMultiSiteRoutingHelp />
|
||||||
|
) : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSubsectionDescription,
|
||||||
|
SettingsSubsectionHeader,
|
||||||
|
SettingsSubsectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||||
|
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||||
|
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||||
|
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||||
|
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||||
|
import type { Selectedsite } from "@app/components/site-selector";
|
||||||
|
|
||||||
|
type PrivateResourceSshFieldsProps = {
|
||||||
|
control: Control<any>;
|
||||||
|
setValue: UseFormSetValue<any>;
|
||||||
|
watch: UseFormWatch<any>;
|
||||||
|
orgId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
selectedSites: Selectedsite[];
|
||||||
|
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||||
|
labelPrefix?: "create" | "edit";
|
||||||
|
showSshSettings?: boolean;
|
||||||
|
layout?: "default" | "wizard";
|
||||||
|
showPaidFeaturesAlert?: boolean;
|
||||||
|
hideAlias?: boolean;
|
||||||
|
embedInParentGrid?: boolean;
|
||||||
|
isNativeSsh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrivateResourceSshFields({
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
orgId,
|
||||||
|
disabled = false,
|
||||||
|
selectedSites,
|
||||||
|
onSelectedSitesChange,
|
||||||
|
labelPrefix = "edit",
|
||||||
|
showSshSettings = true,
|
||||||
|
layout = "default",
|
||||||
|
showPaidFeaturesAlert = true,
|
||||||
|
hideAlias = false,
|
||||||
|
embedInParentGrid = false,
|
||||||
|
isNativeSsh: isNativeSshProp
|
||||||
|
}: PrivateResourceSshFieldsProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const destinationLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogDestination"
|
||||||
|
: "editInternalResourceDialogDestination";
|
||||||
|
const destinationPortLabelKey =
|
||||||
|
labelPrefix === "create"
|
||||||
|
? "createInternalResourceDialogModePort"
|
||||||
|
: "editInternalResourceDialogModePort";
|
||||||
|
|
||||||
|
const authDaemonMode = watch("authDaemonMode") ?? "site";
|
||||||
|
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
|
||||||
|
const standardDaemonLocation =
|
||||||
|
watch("standardDaemonLocation") ??
|
||||||
|
(authDaemonMode === "remote" ? "remote" : "site");
|
||||||
|
const formAuthDaemonPort = watch("authDaemonPort");
|
||||||
|
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
|
||||||
|
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
|
||||||
|
);
|
||||||
|
const isEditLayout = layout === "default";
|
||||||
|
|
||||||
|
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||||
|
() => (authDaemonMode === "native" ? "native" : "standard")
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNative =
|
||||||
|
isNativeSshProp ??
|
||||||
|
(isEditLayout
|
||||||
|
? authDaemonMode === "native"
|
||||||
|
: sshServerMode === "native");
|
||||||
|
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
|
||||||
|
isNative,
|
||||||
|
authDaemonMode,
|
||||||
|
pamMode
|
||||||
|
);
|
||||||
|
|
||||||
|
function trimSitesToFirst() {
|
||||||
|
if (selectedSites.length <= 1) return;
|
||||||
|
|
||||||
|
const first = selectedSites.slice(0, 1);
|
||||||
|
onSelectedSitesChange(first);
|
||||||
|
setValue(
|
||||||
|
"siteIds",
|
||||||
|
first.map((s: Selectedsite) => s.siteId),
|
||||||
|
{ shouldValidate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePamModeChange(value: "passthrough" | "push") {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
setValue("pamMode", value, { shouldValidate: true });
|
||||||
|
|
||||||
|
if (value === "passthrough") {
|
||||||
|
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||||
|
setAuthDaemonPortInput("22123");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
|
||||||
|
trimSitesToFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
setValue("standardDaemonLocation", value, { shouldValidate: true });
|
||||||
|
setValue("authDaemonMode", value, { shouldValidate: true });
|
||||||
|
|
||||||
|
if (value === "site") {
|
||||||
|
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||||
|
setAuthDaemonPortInput("22123");
|
||||||
|
trimSitesToFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthDaemonPortChange(value: string) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
setAuthDaemonPortInput(value);
|
||||||
|
const trimmed = value.trim();
|
||||||
|
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServerModeChange(mode: "standard" | "native") {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
setSshServerMode(mode);
|
||||||
|
if (mode === "native") {
|
||||||
|
setValue("authDaemonMode", "native", { shouldValidate: true });
|
||||||
|
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||||
|
setValue("destination", null, { shouldValidate: true });
|
||||||
|
setValue("destinationPort", null, { shouldValidate: true });
|
||||||
|
setAuthDaemonPortInput("22123");
|
||||||
|
trimSitesToFirst();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue("authDaemonMode", standardDaemonLocation, {
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
setValue("destinationPort", 22, { shouldValidate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliasField = hideAlias ? null : (
|
||||||
|
<PrivateResourceAliasField
|
||||||
|
control={control}
|
||||||
|
watch={watch}
|
||||||
|
labelPrefix={labelPrefix}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const standardSshTargetRow =
|
||||||
|
orgId && !isNative ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4 items-start">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={control}
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={onSelectedSitesChange}
|
||||||
|
singleSite={!useMultiSiteTargetForm}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="destination"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value === ""
|
||||||
|
? null
|
||||||
|
: e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="destinationPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (raw === "") {
|
||||||
|
field.onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
field.onChange(
|
||||||
|
Number.isFinite(n) ? n : null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const sshSettingsFields = showSshSettings ? (
|
||||||
|
<SshServerSettingsFields
|
||||||
|
idPrefix={
|
||||||
|
layout === "wizard"
|
||||||
|
? "private-ssh-create"
|
||||||
|
: "private-ssh-fields"
|
||||||
|
}
|
||||||
|
pamMode={pamMode}
|
||||||
|
standardDaemonLocation={standardDaemonLocation}
|
||||||
|
authDaemonPort={authDaemonPortInput}
|
||||||
|
onPamModeChange={handlePamModeChange}
|
||||||
|
onStandardDaemonLocationChange={handleDaemonLocationChange}
|
||||||
|
onAuthDaemonPortChange={handleAuthDaemonPortChange}
|
||||||
|
sshServerMode={sshServerMode}
|
||||||
|
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
|
||||||
|
onServerModeChange={handleServerModeChange}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const destinationSection = (
|
||||||
|
<>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<SettingsSubsectionHeader>
|
||||||
|
<SettingsSubsectionTitle>
|
||||||
|
{t("sshServerDestination")}
|
||||||
|
</SettingsSubsectionTitle>
|
||||||
|
<SettingsSubsectionDescription>
|
||||||
|
{t("sshServerDestinationDescription")}
|
||||||
|
</SettingsSubsectionDescription>
|
||||||
|
</SettingsSubsectionHeader>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
{isNative && orgId ? (
|
||||||
|
<>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={control}
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={onSelectedSitesChange}
|
||||||
|
singleSite
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceAliasField
|
||||||
|
control={control}
|
||||||
|
watch={watch}
|
||||||
|
labelPrefix={labelPrefix}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isNative && orgId ? (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
{standardSshTargetRow}
|
||||||
|
</SettingsFormCell>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isNative && !hideAlias ? (
|
||||||
|
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content: ReactNode = (
|
||||||
|
<>
|
||||||
|
{showPaidFeaturesAlert && layout === "default" && (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.advancedPrivateResources}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
{sshSettingsFields}
|
||||||
|
{destinationSection}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedInParentGrid) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Form } from "@app/components/ui/form";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import {
|
||||||
|
accessTagsToIds,
|
||||||
|
buildUpdateSiteResourcePayload,
|
||||||
|
createAccessFormSchema,
|
||||||
|
mergeFormValuesWithResource
|
||||||
|
} from "@app/lib/privateResourceForm";
|
||||||
|
import { resourceQueries, orgQueries } from "@app/lib/queries";
|
||||||
|
import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||||
|
|
||||||
|
export default function PrivateResourceAccessPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { siteResource, setAccess } = useSiteResourceContext();
|
||||||
|
const { loading, roles, users, clients, hasMachineClients } =
|
||||||
|
useAccessFormDefaults(siteResource.orgId, siteResource.id);
|
||||||
|
|
||||||
|
const machineClientsQuery = useQuery(
|
||||||
|
orgQueries.machineClients({
|
||||||
|
orgId: siteResource.orgId,
|
||||||
|
perPage: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const hasMachineClientsResolved =
|
||||||
|
(machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0;
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(createAccessFormSchema()),
|
||||||
|
defaultValues: {
|
||||||
|
roles: [] as typeof roles,
|
||||||
|
users: [] as typeof users,
|
||||||
|
clients: [] as typeof clients
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
form.reset({ roles, users, clients });
|
||||||
|
}
|
||||||
|
}, [loading, roles, users, clients, form]);
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
const access = accessTagsToIds({
|
||||||
|
roles: data.roles,
|
||||||
|
users: data.users,
|
||||||
|
clients: data.clients
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeFormValuesWithResource(siteResource, {});
|
||||||
|
const payload = buildUpdateSiteResourcePayload(merged, access);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/site-resource/${siteResource.id}`, payload);
|
||||||
|
setAccess(access);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
resourceQueries.siteResourceRoles({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
),
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
resourceQueries.siteResourceUsers({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
),
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
resourceQueries.siteResourceClients({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("editInternalResourceDialogSuccess"),
|
||||||
|
description: t(
|
||||||
|
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("editInternalResourceDialogError"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t(
|
||||||
|
"editInternalResourceDialogFailedToUpdateInternalResource"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("authentication")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogAccessControlDescription"
|
||||||
|
)}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
id="private-resource-access-form"
|
||||||
|
>
|
||||||
|
<PrivateResourceAccessFields
|
||||||
|
control={form.control}
|
||||||
|
orgId={siteResource.orgId}
|
||||||
|
loading={loading}
|
||||||
|
hasMachineClients={
|
||||||
|
hasMachineClients ||
|
||||||
|
hasMachineClientsResolved
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="private-resource-access-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Form } from "@app/components/ui/form";
|
||||||
|
import { createCidrFormSchema } from "@app/lib/privateResourceForm";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||||
|
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||||
|
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||||
|
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||||
|
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||||
|
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||||
|
|
||||||
|
export default function PrivateResourceCidrPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { save, siteResource } = useSaveSiteResource();
|
||||||
|
const [selectedSites, setSelectedSites] = useState(() =>
|
||||||
|
buildSelectedSitesForResource(siteResource)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = useMemo(() => createCidrFormSchema(t), [t]);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
siteIds: siteResource.siteIds,
|
||||||
|
mode: "cidr",
|
||||||
|
destination: siteResource.destination ?? "",
|
||||||
|
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||||
|
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||||
|
disableIcmp: siteResource.disableIcmp ?? false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
await save({
|
||||||
|
siteIds: data.siteIds,
|
||||||
|
mode: "cidr",
|
||||||
|
destination: data.destination,
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("cidrSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogDestinationCidrDescription"
|
||||||
|
)}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
id="private-resource-cidr-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={form.control}
|
||||||
|
orgId={siteResource.orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceCidrDestinationField
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourcePortRanges
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
initialTcp={
|
||||||
|
siteResource.tcpPortRangeString
|
||||||
|
}
|
||||||
|
initialUdp={
|
||||||
|
siteResource.udpPortRangeString
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="private-resource-cidr-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useMemo } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||||
|
|
||||||
|
export default function PrivateResourceGeneralPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { save, siteResource } = useSaveSiteResource();
|
||||||
|
|
||||||
|
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: siteResource.name,
|
||||||
|
niceId: siteResource.niceId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
await save({
|
||||||
|
name: data.name,
|
||||||
|
niceId: data.niceId
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceGeneral")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourceGeneralDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
id="private-resource-general-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogName"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="niceId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("identifier")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="private-resource-general-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Form } from "@app/components/ui/form";
|
||||||
|
import { createHostFormSchema } from "@app/lib/privateResourceForm";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||||
|
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||||
|
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||||
|
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||||
|
import {
|
||||||
|
asAnyControl,
|
||||||
|
asAnySetValue,
|
||||||
|
asAnyWatch
|
||||||
|
} from "../../formControlUtils";
|
||||||
|
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||||
|
|
||||||
|
export default function PrivateResourceHostPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { save, siteResource } = useSaveSiteResource();
|
||||||
|
const [selectedSites, setSelectedSites] = useState(() =>
|
||||||
|
buildSelectedSitesForResource(siteResource)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = useMemo(() => createHostFormSchema(t), [t]);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
siteIds: siteResource.siteIds,
|
||||||
|
mode: "host",
|
||||||
|
destination: siteResource.destination ?? "",
|
||||||
|
alias: siteResource.alias ?? null,
|
||||||
|
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||||
|
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
|
||||||
|
disableIcmp: siteResource.disableIcmp ?? false,
|
||||||
|
authDaemonMode: siteResource.authDaemonMode ?? "site",
|
||||||
|
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
await save({
|
||||||
|
siteIds: data.siteIds,
|
||||||
|
mode: "host",
|
||||||
|
destination: data.destination,
|
||||||
|
alias: data.alias,
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp,
|
||||||
|
authDaemonMode: data.authDaemonMode,
|
||||||
|
authDaemonPort: data.authDaemonPort
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("hostSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("editInternalResourceDialogDestinationDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
id="private-resource-host-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={form.control}
|
||||||
|
orgId={siteResource.orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourceHostDestinationFields
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourcePortRanges
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
initialTcp={
|
||||||
|
siteResource.tcpPortRangeString
|
||||||
|
}
|
||||||
|
initialUdp={
|
||||||
|
siteResource.udpPortRangeString
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="private-resource-host-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Form } from "@app/components/ui/form";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { createHttpFormSchema } from "@app/lib/privateResourceForm";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||||
|
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||||
|
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||||
|
import {
|
||||||
|
asAnyControl,
|
||||||
|
asAnySetValue,
|
||||||
|
asAnyWatch
|
||||||
|
} from "../../formControlUtils";
|
||||||
|
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||||
|
|
||||||
|
export default function PrivateResourceHttpPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { save, siteResource } = useSaveSiteResource();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const httpSectionDisabled = !isPaidUser(
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
const [selectedSites, setSelectedSites] = useState(() =>
|
||||||
|
buildSelectedSitesForResource(siteResource)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = useMemo(() => createHttpFormSchema(t), [t]);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
siteIds: siteResource.siteIds,
|
||||||
|
mode: "http",
|
||||||
|
destination: siteResource.destination ?? "",
|
||||||
|
destinationPort: siteResource.destinationPort ?? null,
|
||||||
|
scheme: siteResource.scheme ?? "http",
|
||||||
|
ssl: siteResource.ssl ?? false,
|
||||||
|
httpConfigSubdomain: siteResource.subdomain ?? null,
|
||||||
|
httpConfigDomainId: siteResource.domainId ?? null,
|
||||||
|
httpConfigFullDomain: siteResource.fullDomain ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
await save({
|
||||||
|
siteIds: data.siteIds,
|
||||||
|
mode: "http",
|
||||||
|
destination: data.destination,
|
||||||
|
destinationPort: data.destinationPort,
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl,
|
||||||
|
httpConfigSubdomain: data.httpConfigSubdomain,
|
||||||
|
httpConfigDomainId: data.httpConfigDomainId,
|
||||||
|
httpConfigFullDomain: data.httpConfigFullDomain
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("httpSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogHttpConfigurationDescription"
|
||||||
|
)}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
id="private-resource-http-form"
|
||||||
|
>
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={form.control}
|
||||||
|
orgId={siteResource.orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourceHttpFields
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
orgId={siteResource.orgId}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
|
siteResourceId={siteResource.id}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="private-resource-http-form"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
|
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
|
||||||
|
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Private Resource"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type PrivateResourceLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PrivateResourceLayout(
|
||||||
|
props: PrivateResourceLayoutProps
|
||||||
|
) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const siteResource = await fetchSiteResourceByNiceId(
|
||||||
|
params.orgId,
|
||||||
|
params.niceId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!siteResource) {
|
||||||
|
redirect(`/${params.orgId}/settings/resources/private`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let org = null;
|
||||||
|
try {
|
||||||
|
const res = await getCachedOrg(params.orgId);
|
||||||
|
org = res.data.data;
|
||||||
|
} catch {
|
||||||
|
redirect(`/${params.orgId}/settings/resources/private`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
redirect(`/${params.orgId}/settings/resources/private`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeSettingsKey = `${siteResource.mode}Settings` as
|
||||||
|
| "hostSettings"
|
||||||
|
| "cidrSettings"
|
||||||
|
| "httpSettings"
|
||||||
|
| "sshSettings";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: t("general"),
|
||||||
|
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t(modeSettingsKey),
|
||||||
|
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("authentication"),
|
||||||
|
href: `/{orgId}/settings/resources/private/{niceId}/access`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("resourceSetting", {
|
||||||
|
resourceName: siteResource.name
|
||||||
|
})}
|
||||||
|
description={t("resourceSettingDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<SiteResourceProvider siteResource={siteResource}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SiteResourceInfoBox />
|
||||||
|
<HorizontalTabs items={navItems}>
|
||||||
|
{children}
|
||||||
|
</HorizontalTabs>
|
||||||
|
</div>
|
||||||
|
</SiteResourceProvider>
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Private Resource"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PrivateResourcePage(props: {
|
||||||
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(
|
||||||
|
`/${params.orgId}/settings/resources/private/${params.niceId}/general`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle,
|
||||||
|
SettingsFormGrid
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Form } from "@app/components/ui/form";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import {
|
||||||
|
createSshFormSchema,
|
||||||
|
inferSshPamMode
|
||||||
|
} from "@app/lib/privateResourceForm";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||||
|
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||||
|
import {
|
||||||
|
asAnyControl,
|
||||||
|
asAnySetValue,
|
||||||
|
asAnyWatch
|
||||||
|
} from "../../formControlUtils";
|
||||||
|
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||||
|
import type { Selectedsite } from "@app/components/site-selector";
|
||||||
|
|
||||||
|
export default function PrivateResourceSshPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { save, siteResource } = useSaveSiteResource();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||||
|
const isNative = siteResource.authDaemonMode === "native";
|
||||||
|
const [sshServerMode] = useState<"standard" | "native">(
|
||||||
|
isNative ? "native" : "standard"
|
||||||
|
);
|
||||||
|
const [selectedSites, setSelectedSites] = useState(() =>
|
||||||
|
buildSelectedSitesForResource(siteResource)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema = useMemo(
|
||||||
|
() => createSshFormSchema(t, { isNative }),
|
||||||
|
[t, isNative]
|
||||||
|
);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
siteIds: siteResource.siteIds,
|
||||||
|
mode: "ssh",
|
||||||
|
destination: siteResource.destination ?? "",
|
||||||
|
alias: siteResource.alias ?? null,
|
||||||
|
destinationPort: siteResource.destinationPort ?? null,
|
||||||
|
pamMode: inferSshPamMode(
|
||||||
|
siteResource.authDaemonMode,
|
||||||
|
siteResource.pamMode
|
||||||
|
),
|
||||||
|
standardDaemonLocation: isNative
|
||||||
|
? "site"
|
||||||
|
: siteResource.authDaemonMode === "remote"
|
||||||
|
? "remote"
|
||||||
|
: "site",
|
||||||
|
authDaemonPort: siteResource.authDaemonPort
|
||||||
|
? String(siteResource.authDaemonPort)
|
||||||
|
: "22123"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pamMode = form.watch("pamMode");
|
||||||
|
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||||
|
const authDaemonPort = form.watch("authDaemonPort");
|
||||||
|
|
||||||
|
function trimSitesToFirst() {
|
||||||
|
if (selectedSites.length <= 1) return;
|
||||||
|
|
||||||
|
const first = selectedSites.slice(0, 1);
|
||||||
|
setSelectedSites(first);
|
||||||
|
form.setValue(
|
||||||
|
"siteIds",
|
||||||
|
first.map((s: Selectedsite) => s.siteId),
|
||||||
|
{ shouldValidate: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePamModeChange(value: "passthrough" | "push") {
|
||||||
|
form.setValue("pamMode", value, { shouldValidate: true });
|
||||||
|
|
||||||
|
if (value === "push") {
|
||||||
|
if (
|
||||||
|
standardDaemonLocation !== "remote" &&
|
||||||
|
selectedSites.length > 1
|
||||||
|
) {
|
||||||
|
trimSitesToFirst();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||||
|
form.setValue("standardDaemonLocation", value, {
|
||||||
|
shouldValidate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value === "site") {
|
||||||
|
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
|
||||||
|
trimSitesToFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, formAction, saveLoading] = useActionState(async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
const effectiveAuthDaemonMode = isNative
|
||||||
|
? "native"
|
||||||
|
: data.standardDaemonLocation;
|
||||||
|
const effectiveAuthDaemonPort =
|
||||||
|
!isNative &&
|
||||||
|
data.pamMode === "push" &&
|
||||||
|
data.standardDaemonLocation === "remote"
|
||||||
|
? Number(data.authDaemonPort)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await save({
|
||||||
|
siteIds: data.siteIds,
|
||||||
|
mode: "ssh",
|
||||||
|
destination: isNative ? null : data.destination,
|
||||||
|
alias: data.alias,
|
||||||
|
destinationPort: isNative ? null : data.destinationPort,
|
||||||
|
authDaemonMode: effectiveAuthDaemonMode,
|
||||||
|
authDaemonPort: effectiveAuthDaemonPort,
|
||||||
|
pamMode: data.pamMode
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("sshSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("editInternalResourceDialogDestinationDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
disabled={sshSectionDisabled}
|
||||||
|
className={
|
||||||
|
sshSectionDisabled
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SshServerSettingsFields
|
||||||
|
idPrefix="private-ssh-edit"
|
||||||
|
pamMode={pamMode}
|
||||||
|
standardDaemonLocation={
|
||||||
|
standardDaemonLocation
|
||||||
|
}
|
||||||
|
authDaemonPort={authDaemonPort}
|
||||||
|
onPamModeChange={handlePamModeChange}
|
||||||
|
onStandardDaemonLocationChange={
|
||||||
|
handleDaemonLocationChange
|
||||||
|
}
|
||||||
|
onAuthDaemonPortChange={(value) =>
|
||||||
|
form.setValue(
|
||||||
|
"authDaemonPort",
|
||||||
|
value,
|
||||||
|
{ shouldValidate: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
authDaemonPortError={
|
||||||
|
form.formState.errors.authDaemonPort
|
||||||
|
?.message
|
||||||
|
}
|
||||||
|
sshServerMode={sshServerMode}
|
||||||
|
serverModeDisplay="badge"
|
||||||
|
/>
|
||||||
|
<PrivateResourceSshFields
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
setValue={asAnySetValue(form.setValue)}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
orgId={siteResource.orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={setSelectedSites}
|
||||||
|
showSshSettings={false}
|
||||||
|
embedInParentGrid
|
||||||
|
showPaidFeaturesAlert={false}
|
||||||
|
isNativeSsh={isNative}
|
||||||
|
/>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<form action={formAction}>
|
||||||
|
<Button type="submit" loading={saveLoading}>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</Form>
|
||||||
|
</fieldset>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import {
|
||||||
|
OptionSelect,
|
||||||
|
type OptionSelectOption
|
||||||
|
} from "@app/components/OptionSelect";
|
||||||
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import type { Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import {
|
||||||
|
buildCreateSiteResourcePayload,
|
||||||
|
createCreateFormSchema,
|
||||||
|
type PrivateResourceMode
|
||||||
|
} from "@app/lib/privateResourceForm";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { SiteResource } from "@server/db";
|
||||||
|
import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||||
|
import type ResponseT from "@server/types/Response";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||||
|
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||||
|
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||||
|
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
||||||
|
import {
|
||||||
|
PrivateResourceAliasField,
|
||||||
|
PrivateResourceCidrDestinationField,
|
||||||
|
PrivateResourceHostDestinationFields
|
||||||
|
} from "../PrivateResourceDestinationFields";
|
||||||
|
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||||
|
|
||||||
|
export default function CreatePrivateResourcePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const orgId = params.orgId as string;
|
||||||
|
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const httpSectionDisabled = !isPaidUser(
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||||
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const siteIdParam = searchParams.get("siteId");
|
||||||
|
const siteIdNumber =
|
||||||
|
siteIdParam && Number.isInteger(Number(siteIdParam))
|
||||||
|
? Number(siteIdParam)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||||
|
|
||||||
|
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
siteIds: [],
|
||||||
|
mode: "host",
|
||||||
|
destination: "",
|
||||||
|
alias: null,
|
||||||
|
destinationPort: null,
|
||||||
|
scheme: "http",
|
||||||
|
ssl: true,
|
||||||
|
httpConfigSubdomain: null,
|
||||||
|
httpConfigDomainId: null,
|
||||||
|
httpConfigFullDomain: null,
|
||||||
|
authDaemonMode: "native",
|
||||||
|
standardDaemonLocation: "site",
|
||||||
|
authDaemonPort: null,
|
||||||
|
pamMode: "passthrough",
|
||||||
|
tcpPortRangeString: "*",
|
||||||
|
udpPortRangeString: "*",
|
||||||
|
disableIcmp: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!siteIdNumber) return;
|
||||||
|
|
||||||
|
void api
|
||||||
|
.get<ResponseT<GetSiteResponse>>(`/site/${siteIdNumber}`)
|
||||||
|
.then((res) => {
|
||||||
|
const site = res.data.data;
|
||||||
|
if (!site || site.orgId !== orgId) return;
|
||||||
|
const selected: Selectedsite = {
|
||||||
|
siteId: site.siteId,
|
||||||
|
name: site.name,
|
||||||
|
type: site.type as Selectedsite["type"]
|
||||||
|
};
|
||||||
|
setSelectedSites([selected]);
|
||||||
|
form.setValue("siteIds", [site.siteId]);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [api, form, orgId, siteIdNumber]);
|
||||||
|
|
||||||
|
const mode = form.watch("mode");
|
||||||
|
const authDaemonMode = form.watch("authDaemonMode");
|
||||||
|
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
|
||||||
|
|
||||||
|
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
|
||||||
|
{ value: "host", label: t("createInternalResourceDialogModeHost") },
|
||||||
|
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
|
||||||
|
...(!disableEnterpriseFeatures
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: "http" as const,
|
||||||
|
label: t("createInternalResourceDialogModeHttp")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "ssh" as const,
|
||||||
|
label: t("createInternalResourceDialogModeSsh")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
];
|
||||||
|
|
||||||
|
const submitDisabled =
|
||||||
|
isSubmitting ||
|
||||||
|
(mode === "http" && httpSectionDisabled) ||
|
||||||
|
(mode === "ssh" && sshSectionDisabled);
|
||||||
|
|
||||||
|
function onSubmit(values: FormValues) {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.put<
|
||||||
|
AxiosResponse<ResponseT<SiteResource>>
|
||||||
|
>(
|
||||||
|
`/org/${orgId}/site-resource`,
|
||||||
|
buildCreateSiteResourcePayload({
|
||||||
|
...values,
|
||||||
|
destination:
|
||||||
|
values.destination?.trim() &&
|
||||||
|
values.destination.trim().length > 0
|
||||||
|
? values.destination.trim()
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
|
description: t(
|
||||||
|
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = (res.data as unknown as ResponseT<SiteResource>)
|
||||||
|
.data;
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Failed to create private resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("createInternalResourceDialogError"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t(
|
||||||
|
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<HeaderTitle
|
||||||
|
title={t(
|
||||||
|
"createInternalResourceDialogCreateClientResource"
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"createInternalResourceDialogCreateClientResourceDescription"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/${orgId}/settings/resources/private`}>
|
||||||
|
{t("privateResourceCreatePageSeeAll")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="create-private-resource-form"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* General */}
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceCreateGeneral")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourceCreateGeneralDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("name")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"resourceNameDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("type")}
|
||||||
|
</FormLabel>
|
||||||
|
<OptionSelect<PrivateResourceMode>
|
||||||
|
options={modeOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(newMode) => {
|
||||||
|
field.onChange(
|
||||||
|
newMode
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
newMode ===
|
||||||
|
"ssh"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"authDaemonMode",
|
||||||
|
"native"
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"standardDaemonLocation",
|
||||||
|
"site"
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"destination",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"destinationPort",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
newMode ===
|
||||||
|
"http"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"destinationPort",
|
||||||
|
443
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
form.setValue(
|
||||||
|
"destinationPort",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
cols={4}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
{mode === "http" && (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormItem>
|
||||||
|
<DomainPicker
|
||||||
|
orgId={orgId}
|
||||||
|
cols={2}
|
||||||
|
hideFreeDomain
|
||||||
|
onDomainChange={(res) => {
|
||||||
|
if (!res) {
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigSubdomain",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigDomainId",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigFullDomain",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigSubdomain",
|
||||||
|
res.subdomain ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigDomainId",
|
||||||
|
res.domainId
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigFullDomain",
|
||||||
|
res.fullDomain
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"resourceDomainDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(mode === "host" ||
|
||||||
|
(mode === "ssh" && !isNativeSsh)) && (
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceAliasField
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
labelPrefix="create"
|
||||||
|
disabled={
|
||||||
|
mode === "ssh" &&
|
||||||
|
sshSectionDisabled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Host destination */}
|
||||||
|
{mode === "host" && (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("hostSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogDestinationDescription"
|
||||||
|
)}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={form.control}
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceHostDestinationFields
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
labelPrefix="create"
|
||||||
|
hideAlias
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourcePortRanges
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CIDR destination */}
|
||||||
|
{mode === "cidr" && (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("cidrSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogDestinationCidrDescription"
|
||||||
|
)}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={form.control}
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceCidrDestinationField
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
labelPrefix="create"
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourcePortRanges
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTTP configuration */}
|
||||||
|
{mode === "http" && (
|
||||||
|
<SettingsSection>
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.advancedPrivateResources}
|
||||||
|
/>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("httpSettings")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t(
|
||||||
|
"editInternalResourceDialogHttpConfigurationDescription"
|
||||||
|
)}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<fieldset
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
|
className={
|
||||||
|
httpSectionDisabled
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<PrivateResourceSitesField
|
||||||
|
control={form.control}
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={
|
||||||
|
selectedSites
|
||||||
|
}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<PrivateResourceHttpFields
|
||||||
|
control={asAnyControl(
|
||||||
|
form.control
|
||||||
|
)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
orgId={orgId}
|
||||||
|
watch={asAnyWatch(
|
||||||
|
form.watch
|
||||||
|
)}
|
||||||
|
disabled={
|
||||||
|
httpSectionDisabled
|
||||||
|
}
|
||||||
|
labelPrefix="create"
|
||||||
|
hideDomainPicker
|
||||||
|
hidePaidFeaturesAlert
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
</SettingsFormGrid>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</fieldset>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SSH server */}
|
||||||
|
{mode === "ssh" && (
|
||||||
|
<SettingsSection>
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.advancedPrivateResources}
|
||||||
|
/>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("sshServer")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("sshServerDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<fieldset
|
||||||
|
disabled={sshSectionDisabled}
|
||||||
|
className={
|
||||||
|
sshSectionDisabled
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm variant="half">
|
||||||
|
<PrivateResourceSshFields
|
||||||
|
control={asAnyControl(form.control)}
|
||||||
|
setValue={asAnySetValue(
|
||||||
|
form.setValue
|
||||||
|
)}
|
||||||
|
watch={asAnyWatch(form.watch)}
|
||||||
|
orgId={orgId}
|
||||||
|
disabled={sshSectionDisabled}
|
||||||
|
selectedSites={selectedSites}
|
||||||
|
onSelectedSitesChange={
|
||||||
|
setSelectedSites
|
||||||
|
}
|
||||||
|
labelPrefix="create"
|
||||||
|
showSshSettings={true}
|
||||||
|
layout="wizard"
|
||||||
|
showPaidFeaturesAlert={false}
|
||||||
|
hideAlias
|
||||||
|
/>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</fieldset>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/resources/private`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("createInternalResourceDialogCancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="create-private-resource-form"
|
||||||
|
disabled={submitDisabled}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("createInternalResourceDialogCreateResource")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type {
|
||||||
|
Control,
|
||||||
|
FieldValues,
|
||||||
|
UseFormSetValue,
|
||||||
|
UseFormWatch
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
export function asAnyControl<T extends FieldValues>(
|
||||||
|
control: Control<T>
|
||||||
|
): Control<any> {
|
||||||
|
return control as Control<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asAnySetValue<T extends FieldValues>(
|
||||||
|
setValue: UseFormSetValue<T>
|
||||||
|
): UseFormSetValue<any> {
|
||||||
|
return setValue as UseFormSetValue<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asAnyWatch<T extends FieldValues>(
|
||||||
|
watch: UseFormWatch<T>
|
||||||
|
): UseFormWatch<any> {
|
||||||
|
return watch as UseFormWatch<any>;
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
|
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
||||||
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||||
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
|
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
|
||||||
import { GetSiteResponse } from "@server/routers/site/getSite";
|
|
||||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
import type ResponseT from "@server/types/Response";
|
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -24,13 +21,6 @@ export interface ClientResourcesPageProps {
|
|||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePositiveInt(s: string | undefined): number | undefined {
|
|
||||||
if (!s) return undefined;
|
|
||||||
const n = Number(s);
|
|
||||||
if (!Number.isInteger(n) || n <= 0) return undefined;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ClientResourcesPage(
|
export default async function ClientResourcesPage(
|
||||||
props: ClientResourcesPageProps
|
props: ClientResourcesPageProps
|
||||||
) {
|
) {
|
||||||
@@ -39,7 +29,7 @@ export default async function ClientResourcesPage(
|
|||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
let pagination: ListResourcesResponse["pagination"] = {
|
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||||
total: 0,
|
total: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20
|
pageSize: 20
|
||||||
@@ -56,34 +46,6 @@ export default async function ClientResourcesPage(
|
|||||||
pagination = responseData.pagination;
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const siteIdParam = parsePositiveInt(
|
|
||||||
searchParams.get("siteId") ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
let initialFilterSite: {
|
|
||||||
siteId: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
} | null = null;
|
|
||||||
if (siteIdParam) {
|
|
||||||
try {
|
|
||||||
const siteRes = await internal.get(
|
|
||||||
`/site/${siteIdParam}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
|
|
||||||
if (s && s.orgId === params.orgId) {
|
|
||||||
initialFilterSite = {
|
|
||||||
siteId: s.siteId,
|
|
||||||
name: s.name,
|
|
||||||
type: s.type
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// leave null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const res = await getCachedOrg(params.orgId);
|
const res = await getCachedOrg(params.orgId);
|
||||||
@@ -122,6 +84,7 @@ export default async function ClientResourcesPage(
|
|||||||
aliasAddress: siteResource.aliasAddress || null,
|
aliasAddress: siteResource.aliasAddress || null,
|
||||||
siteNiceIds: siteResource.siteNiceIds,
|
siteNiceIds: siteResource.siteNiceIds,
|
||||||
niceId: siteResource.niceId,
|
niceId: siteResource.niceId,
|
||||||
|
enabled: siteResource.enabled,
|
||||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||||
disableIcmp: siteResource.disableIcmp || false,
|
disableIcmp: siteResource.disableIcmp || false,
|
||||||
@@ -153,7 +116,6 @@ export default async function ClientResourcesPage(
|
|||||||
pageIndex: pagination.page - 1,
|
pageIndex: pagination.page - 1,
|
||||||
pageSize: pagination.pageSize
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
initialFilterSite={initialFilterSite}
|
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user