From 253ba554a2c6aa1815a3f80b5b0b7e5476b3039c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Jun 2026 11:46:30 -0700 Subject: [PATCH 01/28] fix resources cell styling --- messages/en-US.json | 3 +- .../routers/policy/listResourcePolicies.ts | 1 + server/routers/resource/types.ts | 2 +- src/components/ResourcePoliciesTable.tsx | 69 +++++++++---------- 4 files changed, 36 insertions(+), 39 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 6690a126d..55970a332 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -212,8 +212,9 @@ "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", "resourcePoliciesTitle": "Manage Resource Policies", - "resourcePoliciesAttachedResourcesColumnTitle": "Attached resources", + "resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResources": "{count} resource(s)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# resources}}", "resourcePoliciesAttachedResourcesEmpty": "no resources", "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", "resourcePoliciesSearch": "Search policies...", diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index beb1b68c3..a5a52d9c8 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -216,6 +216,7 @@ export async function listResourcePolicies( : await db .select({ resourceId: resources.resourceId, + niceId: resources.niceId, name: resources.name, fullDomain: resources.fullDomain, resourcePolicyId: resources.resourcePolicyId diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index eee70bd35..edfe49b00 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -14,7 +14,7 @@ export type GetMaintenanceInfoResponse = { export type AttachedResource = Pick< Resource, - "resourceId" | "name" | "fullDomain" + "resourceId" | "niceId" | "name" | "fullDomain" >; export type ResourcePolicyWithResources = Pick< diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 3039c821c..48235e79f 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -8,12 +8,7 @@ import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { PaginationState } from "@tanstack/react-table"; -import { - ArrowRight, - ChevronDown, - MoreHorizontal, - Waypoints -} from "lucide-react"; +import { ArrowRight, ChevronDown, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -98,55 +93,50 @@ export function ResourcePoliciesTable({ }; function ResourceListCell({ + orgId, resources }: { + orgId: string; resources?: AttachedResource[]; }) { if (!resources || resources.length === 0) { - return ( -
- - - {t("resourcePoliciesAttachedResourcesEmpty")} - -
- ); + return -; } + const countLabel = t("resourcePoliciesAttachedResourcesCount", { + count: resources.length + }); + return ( - + {resources.map((resource) => ( - -
- {resource.name} -
- + - {resource.fullDomain} - +
+ + {resource.name} + +
+ + {resource.fullDomain} + +
))}
@@ -182,7 +172,12 @@ export function ResourcePoliciesTable({ ), cell: ({ row }) => { - return ; + return ( + + ); } }, { From 2da4987cd368e793ba60e72823ce5e18afe785ac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Jun 2026 11:52:51 -0700 Subject: [PATCH 02/28] rename policies --- messages/en-US.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 55970a332..13f4d8723 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -211,16 +211,16 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", - "resourcePoliciesTitle": "Manage Resource Policies", + "resourcePoliciesTitle": "Manage Public Resource Policies", "resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResources": "{count} resource(s)", "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# resources}}", "resourcePoliciesAttachedResourcesEmpty": "no resources", - "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", + "resourcePoliciesDescription": "Create and manage authentication policies to control access to your public resources", "resourcePoliciesSearch": "Search policies...", "resourcePoliciesAdd": "Add Policy", "resourcePoliciesDefaultBadgeText": "Default policy", - "resourcePoliciesCreate": "Create Resource Policy", + "resourcePoliciesCreate": "Create Public Resource Policy", "resourcePoliciesCreateDescription": "Follow the steps below to create a new policy", "resourcePolicyName": "Policy Name", "resourcePolicyNameDescription": "Give this policy a name to identify it across your resources", @@ -1467,7 +1467,7 @@ "sidebarProxyResources": "Public", "sidebarClientResources": "Private", "sidebarPolicies": "Policies", - "sidebarResourcePolicies": "Resources", + "sidebarResourcePolicies": "Public Resources", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarTeam": "Team", From f23142336b4e5b78c398c279078ef4c08724f8fc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Jun 2026 11:57:55 -0700 Subject: [PATCH 03/28] enable editing resource policy niceID --- .../EditPolicyNameSectionForm.tsx | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx index e3a2a156f..2a3e1bdd3 100644 --- a/src/components/resource-policy/EditPolicyNameSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -11,6 +11,7 @@ import { } from "@app/components/Settings"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; @@ -40,21 +41,28 @@ import { useForm } from "react-hook-form"; // ─── PolicyNameSection ────────────────────────────────────────────────── -export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) { +const PolicyNameFormSchema = z.object({ + name: z.string(), + niceId: z.string().min(1).max(255).optional() +}); + +export function EditPolicyNameSectionForm({ + readonly +}: { + readonly?: boolean; +}) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const { org } = useOrgContext(); - const { policy } = useResourcePolicyContext(); + const { policy, updatePolicy } = useResourcePolicyContext(); const form = useForm({ - resolver: zodResolver( - z.object({ - name: z.string() - }) - ), + resolver: zodResolver(PolicyNameFormSchema), defaultValues: { - name: policy.name + name: policy.name, + niceId: policy.niceId || "" } }); @@ -73,7 +81,8 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) .put>( `/resource-policy/${policy.resourcePolicyId}`, { - name: payload.name + name: payload.name, + niceId: payload.niceId } ) .catch((e) => { @@ -88,10 +97,22 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) }); if (res && res.status === 200) { + updatePolicy({ + name: payload.name, + niceId: payload.niceId + }); + toast({ title: t("success"), description: t("policyUpdatedSuccess") }); + + if (payload.niceId && payload.niceId !== policy.niceId) { + router.replace( + `/${org.org.orgId}/settings/policies/resource/${payload.niceId}` + ); + } + router.refresh(); } } catch (e) { @@ -136,6 +157,26 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) )} /> + ( + + {t("identifier")} + + + + + + )} + /> From 65470fb64b29ccf61da2cf94f5da4cf35257e74c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Jun 2026 12:04:33 -0700 Subject: [PATCH 04/28] adjust styles --- messages/en-US.json | 4 ++-- src/app/globals.css | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 13f4d8723..42864ab31 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -314,7 +314,7 @@ "rules": "Rules", "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", - "resourcePolicySettingDescription": "Configure the settings on the resource policy", + "resourcePolicySettingDescription": "Configure the settings on this public resource policy", "resourcePolicySetting": "{policyName} Settings", "alwaysAllow": "Bypass Auth", "alwaysDeny": "Block Access", @@ -1475,7 +1475,7 @@ "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", - "sidebarShareableLinks": "Links", + "sidebarShareableLinks": "Share Links", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", diff --git a/src/app/globals.css b/src/app/globals.css index 65e21806b..b5a231e11 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,7 +22,7 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.92 0.004 286.32); + --border: oklch(0.91 0.004 286.32); --input: oklch(0.88 0.004 286.32); --ring: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116); From ad0e800d8dbf4301df167a06f11c44be5306bf0d Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 11:20:32 -0700 Subject: [PATCH 05/28] Fix validation error and bring alias back to table --- server/routers/siteResource/createSiteResource.ts | 1 + src/components/PrivateResourcesTable.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 3f38cc7e1..509ee6e1e 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -142,6 +142,7 @@ const createSiteResourceSchema = z data.destinationPort <= 65535) ); } + return true; }, { message: diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 0a356a059..b529358d1 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -426,7 +426,7 @@ export default function PrivateResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - if (resourceRow.mode === "host" && resourceRow.alias) { + if (resourceRow.alias) { return ( Date: Fri, 5 Jun 2026 11:32:49 -0700 Subject: [PATCH 06/28] Add watermark on missing login pages --- src/app/rdp/RdpClient.tsx | 2 ++ src/app/ssh/SshClient.tsx | 2 ++ src/app/vnc/VncClient.tsx | 2 ++ src/components/AuthPageFooterNotices.tsx | 40 ++++++++++++++++++++++++ src/components/ResourceAuthPortal.tsx | 28 ++--------------- 5 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 src/components/AuthPageFooterNotices.tsx diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 03162ddd8..f5ad0dc1d 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -35,6 +35,7 @@ import { import { Alert, AlertDescription } from "@app/components/ui/alert"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { useTranslations } from "next-intl"; declare module "react" { @@ -443,6 +444,7 @@ export default function RdpClient({ + )} diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 9ffdf53ce..8d97b970b 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -31,6 +31,7 @@ import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; type AuthTab = "password" | "privateKey"; @@ -618,6 +619,7 @@ export default function SshClient({ + )} diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 037e30814..71f4ae8da 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -26,6 +26,7 @@ import { import { Alert, AlertDescription } from "@app/components/ui/alert"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { useTranslations } from "next-intl"; type VncCredentialsForm = { @@ -242,6 +243,7 @@ export default function VncClient({ + )} diff --git a/src/components/AuthPageFooterNotices.tsx b/src/components/AuthPageFooterNotices.tsx new file mode 100644 index 000000000..af9125953 --- /dev/null +++ b/src/components/AuthPageFooterNotices.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +export default function AuthPageFooterNotices() { + const t = useTranslations(); + const { supporterStatus } = useSupporterStatusContext(); + const { isUnlocked, licenseStatus } = useLicenseStatusContext(); + + return ( + <> + {supporterStatus?.visible && ( +
+ + {t("noSupportKey")} + +
+ )} + {build === "enterprise" && !isUnlocked() ? ( +
+ + {t("instanceIsUnlicensed")} + +
+ ) : null} + {build === "enterprise" && + isUnlocked() && + licenseStatus?.tier === "personal" ? ( +
+ + {t("loginPageLicenseWatermark")} + +
+ ) : null} + + ); +} diff --git a/src/components/ResourceAuthPortal.tsx b/src/components/ResourceAuthPortal.tsx index 018a08179..c7d947510 100644 --- a/src/components/ResourceAuthPortal.tsx +++ b/src/components/ResourceAuthPortal.tsx @@ -44,7 +44,7 @@ import { toast } from "@app/hooks/useToast"; import BrandingLogo from "@app/components/BrandingLogo"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; -import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; +import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; @@ -124,8 +124,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const { env } = useEnvContext(); - const { supporterStatus } = useSupporterStatusContext(); - function getDefaultSelectedMethod() { if (props.methods.sso) { return "sso"; @@ -727,29 +725,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { - {supporterStatus?.visible && ( -
- - {t("noSupportKey")} - -
- )} - {build === "enterprise" && !isUnlocked() ? ( -
- - {t("instanceIsUnlicensed")} - -
- ) : null} - {build === "enterprise" && - isUnlocked() && - licenseStatus?.tier === "personal" ? ( -
- - {t("loginPageLicenseWatermark")} - -
- ) : null} + ) : ( From f2b5cff3f9e2e9ff40ec9fb24a129ef0f8133d88 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 12:12:01 -0700 Subject: [PATCH 07/28] Fix resource protection status showing wrong --- server/routers/resource/listResources.ts | 291 +++++++++++++++++++---- 1 file changed, 248 insertions(+), 43 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 57d7a40d0..c982cab9f 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,9 +1,9 @@ import { + alias, browserGatewayTarget, db, labels, resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, resourceLabels, resourcePassword, resourcePincode, @@ -187,16 +187,98 @@ export type ResourceWithTargets = { }; function queryResourcesBase() { + const sharedPolicy = alias(resourcePolicies, "sharedPolicy"); + const defaultPolicy = alias(resourcePolicies, "defaultPolicy"); + const sharedPolicyPincode = alias( + resourcePolicyPincode, + "sharedPolicyPincode" + ); + const defaultPolicyPincode = alias( + resourcePolicyPincode, + "defaultPolicyPincode" + ); + const sharedPolicyPassword = alias( + resourcePolicyPassword, + "sharedPolicyPassword" + ); + const defaultPolicyPassword = alias( + resourcePolicyPassword, + "defaultPolicyPassword" + ); + const sharedPolicyHeaderAuth = alias( + resourcePolicyHeaderAuth, + "sharedPolicyHeaderAuth" + ); + const defaultPolicyHeaderAuth = alias( + resourcePolicyHeaderAuth, + "defaultPolicyHeaderAuth" + ); + + const effectivePasswordId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId} + ELSE ${defaultPolicyPassword.passwordId} + END, + ${resourcePassword.passwordId} + ) + `; + const effectivePincodeId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId} + ELSE ${defaultPolicyPincode.pincodeId} + END, + ${resourcePincode.pincodeId} + ) + `; + const effectiveHeaderAuthId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId} + ELSE ${defaultPolicyHeaderAuth.headerAuthId} + END, + ${resourceHeaderAuth.headerAuthId} + ) + `; + const effectiveSso = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso} + ELSE ${defaultPolicy.sso} + END, + false + ) + `; + const effectiveWhitelist = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled} + ELSE ${defaultPolicy.emailWhitelistEnabled} + END, + false + ) + `; + const effectiveHeaderAuthExtendedCompatibility = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.extendedCompatibility} + ELSE ${defaultPolicyHeaderAuth.extendedCompatibility} + END, + false + ) + `; + return db .select({ resourceId: resources.resourceId, name: resources.name, ssl: resources.ssl, fullDomain: resources.fullDomain, - passwordId: resourcePolicyPassword.passwordId, - sso: resourcePolicies.sso, - pincodeId: resourcePolicyPincode.pincodeId, - whitelist: resourcePolicies.emailWhitelistEnabled, + passwordId: effectivePasswordId, + sso: effectiveSso, + pincodeId: effectivePincodeId, + whitelist: effectiveWhitelist, proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, @@ -204,44 +286,74 @@ function queryResourcesBase() { wildcard: resources.wildcard, mode: resources.mode, health: resources.health, - headerAuthId: resourcePolicyHeaderAuth.headerAuthId, + headerAuthId: effectiveHeaderAuthId, headerAuthExtendedCompatibility: - resourcePolicyHeaderAuth.extendedCompatibility + effectiveHeaderAuthExtendedCompatibility }) .from(resources) .leftJoin( - resourcePolicies, - or( - eq( - resourcePolicies.resourcePolicyId, - resources.resourcePolicyId - ), - eq( - resourcePolicies.resourcePolicyId, - resources.defaultResourcePolicyId - ) - ) + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) ) - .leftJoin( - resourcePolicyPassword, + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .leftJoin( + resourceHeaderAuth, + eq(resourceHeaderAuth.resourceId, resources.resourceId) + ) + .leftJoin( + sharedPolicy, + eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + sharedPolicyPincode, eq( - resourcePolicyPassword.resourcePolicyId, - resourcePolicies.resourcePolicyId + sharedPolicyPincode.resourcePolicyId, + sharedPolicy.resourcePolicyId ) ) .leftJoin( - resourcePolicyPincode, + sharedPolicyPassword, eq( - resourcePolicyPincode.resourcePolicyId, - resourcePolicies.resourcePolicyId + sharedPolicyPassword.resourcePolicyId, + sharedPolicy.resourcePolicyId ) ) .leftJoin( - resourcePolicyHeaderAuth, + sharedPolicyHeaderAuth, eq( - resourcePolicyHeaderAuth.resourcePolicyId, - resourcePolicies.resourcePolicyId + sharedPolicyHeaderAuth.resourcePolicyId, + sharedPolicy.resourcePolicyId + ) + ) + .leftJoin( + defaultPolicy, + eq( + defaultPolicy.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) + .leftJoin( + defaultPolicyPincode, + eq( + defaultPolicyPincode.resourcePolicyId, + defaultPolicy.resourcePolicyId + ) + ) + .leftJoin( + defaultPolicyPassword, + eq( + defaultPolicyPassword.resourcePolicyId, + defaultPolicy.resourcePolicyId + ) + ) + .leftJoin( + defaultPolicyHeaderAuth, + eq( + defaultPolicyHeaderAuth.resourcePolicyId, + defaultPolicy.resourcePolicyId ) ) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) @@ -251,10 +363,23 @@ function queryResourcesBase() { ) .groupBy( resources.resourceId, - resourcePolicies.resourcePolicyId, - resourcePolicyPassword.passwordId, - resourcePolicyPincode.pincodeId, - resourcePolicyHeaderAuth.headerAuthId + resourcePincode.pincodeId, + resourcePassword.passwordId, + resourceHeaderAuth.headerAuthId, + sharedPolicy.resourcePolicyId, + sharedPolicy.sso, + sharedPolicy.emailWhitelistEnabled, + sharedPolicyPincode.pincodeId, + sharedPolicyPassword.passwordId, + sharedPolicyHeaderAuth.headerAuthId, + sharedPolicyHeaderAuth.extendedCompatibility, + defaultPolicy.resourcePolicyId, + defaultPolicy.sso, + defaultPolicy.emailWhitelistEnabled, + defaultPolicyPincode.pincodeId, + defaultPolicyPassword.passwordId, + defaultPolicyHeaderAuth.headerAuthId, + defaultPolicyHeaderAuth.extendedCompatibility ); } @@ -396,6 +521,80 @@ export async function listResources( } if (typeof authState !== "undefined") { + const sharedPolicy = alias(resourcePolicies, "sharedPolicy"); + const defaultPolicy = alias(resourcePolicies, "defaultPolicy"); + const sharedPolicyPincode = alias( + resourcePolicyPincode, + "sharedPolicyPincode" + ); + const defaultPolicyPincode = alias( + resourcePolicyPincode, + "defaultPolicyPincode" + ); + const sharedPolicyPassword = alias( + resourcePolicyPassword, + "sharedPolicyPassword" + ); + const defaultPolicyPassword = alias( + resourcePolicyPassword, + "defaultPolicyPassword" + ); + const sharedPolicyHeaderAuth = alias( + resourcePolicyHeaderAuth, + "sharedPolicyHeaderAuth" + ); + const defaultPolicyHeaderAuth = alias( + resourcePolicyHeaderAuth, + "defaultPolicyHeaderAuth" + ); + + const effectiveSso = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso} + ELSE ${defaultPolicy.sso} + END, + false + ) + `; + const effectiveWhitelist = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled} + ELSE ${defaultPolicy.emailWhitelistEnabled} + END, + false + ) + `; + const effectiveHeaderAuthId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId} + ELSE ${defaultPolicyHeaderAuth.headerAuthId} + END, + ${resourceHeaderAuth.headerAuthId} + ) + `; + const effectivePincodeId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId} + ELSE ${defaultPolicyPincode.pincodeId} + END, + ${resourcePincode.pincodeId} + ) + `; + const effectivePasswordId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId} + ELSE ${defaultPolicyPassword.passwordId} + END, + ${resourcePassword.passwordId} + ) + `; + const browserGatewayModes = ["http", "ssh", "rdp", "vnc"]; + switch (authState) { case "none": conditions.push( @@ -404,22 +603,28 @@ export async function listResources( break; case "protected": conditions.push( - or( - eq(resourcePolicies.sso, true), - eq(resourcePolicies.emailWhitelistEnabled, true), - not(isNull(resourcePolicyHeaderAuth.headerAuthId)), - not(isNull(resourcePolicyPincode.pincodeId)), - not(isNull(resourcePolicyPassword.passwordId)) + and( + inArray(resources.mode, browserGatewayModes), + or( + eq(effectiveSso, true), + eq(effectiveWhitelist, true), + not(isNull(effectiveHeaderAuthId)), + not(isNull(effectivePincodeId)), + not(isNull(effectivePasswordId)) + ) ) ); break; case "not_protected": conditions.push( - not(eq(resourcePolicies.sso, true)), - not(eq(resourcePolicies.emailWhitelistEnabled, true)), - isNull(resourcePolicyHeaderAuth.headerAuthId), - isNull(resourcePolicyPincode.pincodeId), - isNull(resourcePolicyPassword.passwordId) + and( + inArray(resources.mode, browserGatewayModes), + not(eq(effectiveSso, true)), + not(eq(effectiveWhitelist, true)), + isNull(effectiveHeaderAuthId), + isNull(effectivePincodeId), + isNull(effectivePasswordId) + ) ); break; } From 7cf3f8df92cb82dbead839edc8ed6e50d4dbfff2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 12:12:13 -0700 Subject: [PATCH 08/28] Rename proxy -> public --- messages/bg-BG.json | 4 ++-- messages/cs-CZ.json | 4 ++-- messages/de-DE.json | 4 ++-- messages/en-US.json | 4 ++-- messages/es-ES.json | 4 ++-- messages/fr-FR.json | 4 ++-- messages/it-IT.json | 4 ++-- messages/ko-KR.json | 4 ++-- messages/nb-NO.json | 4 ++-- messages/nl-NL.json | 4 ++-- messages/pl-PL.json | 4 ++-- messages/pt-PT.json | 4 ++-- messages/ru-RU.json | 4 ++-- messages/tr-TR.json | 4 ++-- messages/zh-CN.json | 4 ++-- messages/zh-TW.json | 4 ++-- src/app/[orgId]/settings/resources/public/page.tsx | 10 +++++----- ...xyResourcesBanner.tsx => PublicResourcesBanner.tsx} | 9 ++++----- ...roxyResourcesTable.tsx => PublicResourcesTable.tsx} | 2 +- 19 files changed, 42 insertions(+), 43 deletions(-) rename src/components/{ProxyResourcesBanner.tsx => PublicResourcesBanner.tsx} (64%) rename src/components/{ProxyResourcesTable.tsx => PublicResourcesTable.tsx} (99%) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 3417a0d3f..91291d2e3 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Моля, изберете ресурс", "proxyResourceTitle": "Управление на обществени ресурси", "proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.", - "proxyResourcesBannerTitle": "Публичен достъп чрез уеб.", - "proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", + "publicResourcesBannerTitle": "Публичен достъп чрез уеб.", + "publicResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", "clientResourceTitle": "Управление на частни ресурси", "clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.", "privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 1ab19dc13..891bc58a4 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Zvolte prosím zdroj", "proxyResourceTitle": "Spravovat veřejné zdroje", "proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče", - "proxyResourcesBannerTitle": "Veřejný přístup založený na webu", - "proxyResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.", + "publicResourcesBannerTitle": "Veřejný přístup založený na webu", + "publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.", "clientResourceTitle": "Spravovat soukromé zdroje", "clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta", "privateResourcesBannerTitle": "Zero-Trust soukromý přístup", diff --git a/messages/de-DE.json b/messages/de-DE.json index 50b9c0bda..e4afce362 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Bitte wählen Sie eine Ressource", "proxyResourceTitle": "Öffentliche Ressourcen verwalten", "proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind", - "proxyResourcesBannerTitle": "Web-basierter öffentlicher Zugang", - "proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.", + "publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang", + "publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.", "clientResourceTitle": "Private Ressourcen verwalten", "clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind", "privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen", diff --git a/messages/en-US.json b/messages/en-US.json index 42864ab31..f8f04f55a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -202,8 +202,8 @@ "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", - "proxyResourcesBannerTitle": "Web-based Public Access", - "proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "publicResourcesBannerTitle": "Web-based Public Access", + "publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", diff --git a/messages/es-ES.json b/messages/es-ES.json index 7b41a83be..a43ccbc4c 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Por favor, seleccione un recurso", "proxyResourceTitle": "Administrar recursos públicos", "proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web", - "proxyResourcesBannerTitle": "Acceso público basado en web", - "proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.", + "publicResourcesBannerTitle": "Acceso público basado en web", + "publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.", "clientResourceTitle": "Administrar recursos privados", "clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado", "privateResourcesBannerTitle": "Acceso privado de confianza cero", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 54a0581f2..98ebf6e7c 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Veuillez sélectionner une ressource", "proxyResourceTitle": "Gérer les ressources publiques", "proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web", - "proxyResourcesBannerTitle": "Accès public basé sur le Web", - "proxyResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.", + "publicResourcesBannerTitle": "Accès public basé sur le Web", + "publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.", "clientResourceTitle": "Gérer les ressources privées", "clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté", "privateResourcesBannerTitle": "Accès privé sans confiance", diff --git a/messages/it-IT.json b/messages/it-IT.json index 33694cb2c..78018b288 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Seleziona una risorsa", "proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web", - "proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web", - "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", + "publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web", + "publicResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "clientResourceTitle": "Gestisci Risorse Private", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 1ee09e4bb..43213d093 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "리소스를 선택하세요", "proxyResourceTitle": "공개 리소스 관리", "proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.", - "proxyResourcesBannerTitle": "웹 기반 공공 접근", - "proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", + "publicResourcesBannerTitle": "웹 기반 공공 접근", + "publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", "clientResourceTitle": "개인 리소스 관리", "clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.", "privateResourcesBannerTitle": "제로 트러스트 개인 접근", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index ccfd483b4..22ad13c05 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Vennligst velg en ressurs", "proxyResourceTitle": "Administrere offentlige ressurser", "proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser", - "proxyResourcesBannerTitle": "Nettbasert offentlig tilgang", - "proxyResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.", + "publicResourcesBannerTitle": "Nettbasert offentlig tilgang", + "publicResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.", "clientResourceTitle": "Administrer private ressurser", "clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient", "privateResourcesBannerTitle": "Zero-Trust privat tilgang", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 9f170c853..5c36b2504 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", - "proxyResourcesBannerTitle": "Webgebaseerde openbare toegang", - "proxyResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.", + "publicResourcesBannerTitle": "Webgebaseerde openbare toegang", + "publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.", "clientResourceTitle": "Privébronnen beheren", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", "privateResourcesBannerTitle": "Zero-Trust Private Access", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index b90cb8f8e..e793145b3 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Wybierz zasób", "proxyResourceTitle": "Zarządzaj zasobami publicznymi", "proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej", - "proxyResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web", - "proxyResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.", + "publicResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web", + "publicResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.", "clientResourceTitle": "Zarządzaj zasobami prywatnymi", "clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta", "privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 9cade51f2..97c88ff3f 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Por favor, selecione um recurso", "proxyResourceTitle": "Gerenciar Recursos Públicos", "proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web", - "proxyResourcesBannerTitle": "Acesso Público via Web", - "proxyResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.", + "publicResourcesBannerTitle": "Acesso Público via Web", + "publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.", "clientResourceTitle": "Gerenciar recursos privados", "clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado", "privateResourcesBannerTitle": "Acesso Privado com Confiança Zero", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 6b2e8c2a1..702525a2f 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Пожалуйста, выберите ресурс", "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", - "proxyResourcesBannerTitle": "Общедоступный доступ через веб", - "proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", + "publicResourcesBannerTitle": "Общедоступный доступ через веб", + "publicResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 1eb3adb03..236adf4d4 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Lütfen bir kaynak seçin", "proxyResourceTitle": "Herkese Açık Kaynakları Yönet", "proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin", - "proxyResourcesBannerTitle": "Web Tabanlı Genel Erişim", - "proxyResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.", + "publicResourcesBannerTitle": "Web Tabanlı Genel Erişim", + "publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.", "clientResourceTitle": "Özel Kaynakları Yönet", "clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin", "privateResourcesBannerTitle": "Sıfır Güven Özel Erişim", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 637653c2a..cabee95be 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "请选择一个资源", "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", - "proxyResourcesBannerTitle": "基于Web的公共访问", - "proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", + "publicResourcesBannerTitle": "基于Web的公共访问", + "publicResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", "privateResourcesBannerTitle": "零信任的私人访问", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 532962593..6eb103d3a 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -152,8 +152,8 @@ "shareErrorSelectResource": "請選擇一個資源", "proxyResourceTitle": "管理公開資源", "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", - "proxyResourcesBannerTitle": "基於網頁的公開存取", - "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", + "publicResourcesBannerTitle": "基於網頁的公開存取", + "publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", "clientResourceTitle": "管理私有資源", "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", "privateResourcesBannerTitle": "零信任私有存取", diff --git a/src/app/[orgId]/settings/resources/public/page.tsx b/src/app/[orgId]/settings/resources/public/page.tsx index 44430e226..e0b9f6210 100644 --- a/src/app/[orgId]/settings/resources/public/page.tsx +++ b/src/app/[orgId]/settings/resources/public/page.tsx @@ -1,7 +1,7 @@ -import type { ResourceRow } from "@app/components/ProxyResourcesTable"; -import ProxyResourcesTable from "@app/components/ProxyResourcesTable"; +import type { ResourceRow } from "@app/components/PublicResourcesTable"; +import PublicResourcesTable from "@app/components/PublicResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner"; +import PublicResourcesBanner from "@app/components/PublicResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import OrgProvider from "@app/providers/OrgProvider"; @@ -146,10 +146,10 @@ export default async function ProxyResourcesPage( description={t("proxyResourceDescription")} /> - + - { +export const PublicResourcesBanner = () => { const t = useTranslations(); return ( } - description={t("proxyResourcesBannerDescription")} + description={t("publicResourcesBannerDescription")} /> ); }; -export default ProxyResourcesBanner; +export default PublicResourcesBanner; diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/PublicResourcesTable.tsx similarity index 99% rename from src/components/ProxyResourcesTable.tsx rename to src/components/PublicResourcesTable.tsx index 760b68f2f..0f041e35d 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -126,7 +126,7 @@ const booleanSearchFilterSchema = z .optional() .catch(undefined); -export default function ProxyResourcesTable({ +export default function PublicResourcesTable({ resources, orgId, pagination, From b78db3daef8f9c72a7ed904efcb2b022ea2d45cd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Jun 2026 12:17:05 -0700 Subject: [PATCH 09/28] move policies routes --- .../{resource => resources/public}/[niceId]/page.tsx | 8 +++++--- .../{resource => resources/public}/create/page.tsx | 4 +++- .../policies/{resource => resources/public}/page.tsx | 0 .../resources/public/[niceId]/authentication/page.tsx | 2 +- src/app/navigation.tsx | 2 +- src/components/ResourcePoliciesTable.tsx | 6 +++--- src/components/resource-policy/CreatePolicyForm.tsx | 2 +- .../resource-policy/EditPolicyNameSectionForm.tsx | 2 +- 8 files changed, 15 insertions(+), 11 deletions(-) rename src/app/[orgId]/settings/(private)/policies/{resource => resources/public}/[niceId]/page.tsx (87%) rename src/app/[orgId]/settings/(private)/policies/{resource => resources/public}/create/page.tsx (88%) rename src/app/[orgId]/settings/(private)/policies/{resource => resources/public}/page.tsx (100%) diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx similarity index 87% rename from src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx index 5519506b9..1113016b8 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx @@ -28,11 +28,11 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { ); policyResponse = res.data.data; } catch { - redirect(`/${params.orgId}/settings/policies/resource`); + redirect(`/${params.orgId}/settings/policies/resources/public`); } if (!policyResponse) { - redirect(`/${params.orgId}/settings/policies/resource`); + redirect(`/${params.orgId}/settings/policies/resources/public`); } return ( @@ -46,7 +46,9 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { /> diff --git a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx similarity index 88% rename from src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx index edf67fbef..4afa1110d 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx @@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage( /> diff --git a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx similarity index 100% rename from src/app/[orgId]/settings/(private)/policies/resource/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx index ba55ce833..5c02e8c8f 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -330,7 +330,7 @@ export default function ResourceAuthenticationPage() { asChild > {t("editSharedPolicy")} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index bf954a5cb..87cd3259f 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -146,7 +146,7 @@ export const orgNavSections = ( items: [ { title: "sidebarResourcePolicies", - href: "/{orgId}/settings/policies/resource", + href: "/{orgId}/settings/policies/resources/public", icon: ( ) diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 48235e79f..4a0a382e5 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -200,7 +200,7 @@ export function ResourcePoliciesTable({ {t("viewSettings")} @@ -219,7 +219,7 @@ export function ResourcePoliciesTable({
+ + + { + updateTarget(proxyTarget.targetId, { + siteId: site.siteId, + siteType: site.type, + siteName: site.name + }); + setSelectedSite(site); + }} + /> + + + + ); +} + +export type ResourceTargetAddressItemProps = { + updateTarget: (targetId: number, data: Partial) => void; + proxyTarget: LocalTarget; + isHttp: boolean; +}; + +export function ResourceTargetAddressItem({ + updateTarget, + proxyTarget, + isHttp +}: ResourceTargetAddressItemProps) { return (
- {selectedSite && selectedSite.type === "newt" && ( - - refreshContainersForSite(selectedSite.siteId) - } - /> - )} - - - - - - - { - updateTarget(proxyTarget.targetId, { - siteId: site.siteId, - siteType: site.type, - siteName: site.name - }); - setSelectedSite(site); - }} - /> - - - {isHttp && ( Date: Fri, 5 Jun 2026 14:30:36 -0700 Subject: [PATCH 11/28] Rename to public-policies --- server/lib/blueprints/resourcePolicies.ts | 2 +- server/lib/blueprints/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index 7a794c55a..f8d8d1269 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -37,7 +37,7 @@ export async function updateResourcePolicies( const results: ResourcePoliciesResults = []; for (const [policyNiceId, policyData] of Object.entries( - config["resource-policies"] + config["public-policies"] )) { const isLicensed = await isLicensedOrSubscribed( orgId, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ad3676c4b..fc73d83a0 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -573,7 +573,7 @@ export const ConfigSchema = z .record(z.string(), PrivateResourceSchema) .optional() .prefault({}), - "resource-policies": z + "public-policies": z .record(z.string(), ResourcePolicySchema) .optional() .prefault({}), @@ -607,7 +607,7 @@ export const ConfigSchema = z string, z.infer >; - "resource-policies": Record< + "public-policies": Record< string, z.infer >; From 8e5d9e94a99bf20710e48f8e0547821feb4f66b3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 14:37:44 -0700 Subject: [PATCH 12/28] Fix delete site only working on newt site --- server/routers/site/deleteSite.ts | 3 +-- src/components/SitesTable.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 47efba910..bac56c6ba 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -93,10 +93,9 @@ export async function deleteSite( // Clean up all client associations and send peer/proxy removal // messages in a single efficient pass before deleting the row. await cleanupSiteAssociations(site, trx); - - await trx.delete(sites).where(eq(sites.siteId, siteId)); } + await trx.delete(sites).where(eq(sites.siteId, siteId)); await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 3e234bf79..8c3036c4a 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -514,6 +514,16 @@ export default function SitesTable({ )} + { + setSelectedSite(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + Date: Fri, 5 Jun 2026 14:55:27 -0700 Subject: [PATCH 13/28] form validation improvements --- messages/en-US.json | 9 +- .../resources/public/[niceId]/rdp/page.tsx | 288 ++++---- .../resources/public/[niceId]/ssh/page.tsx | 686 ++++++++++-------- .../resources/public/[niceId]/vnc/page.tsx | 288 ++++---- .../settings/resources/public/create/page.tsx | 593 ++++++++------- src/components/BrowserGatewayTargetForm.tsx | 247 ++++--- src/components/HealthCheckCredenza.tsx | 7 +- src/components/PrivateResourceForm.tsx | 12 +- src/components/ResourceInfoBox.tsx | 2 +- src/components/Settings.tsx | 6 +- src/components/labels-selector.tsx | 2 +- .../resource-target-address-item.tsx | 4 +- src/lib/browserGatewayTargetFormSchema.ts | 140 ++++ 13 files changed, 1312 insertions(+), 972 deletions(-) create mode 100644 src/lib/browserGatewayTargetFormSchema.ts diff --git a/messages/en-US.json b/messages/en-US.json index f8f04f55a..ea4d1fc89 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2032,13 +2032,13 @@ "healthCheckUnknown": "Unknown", "healthCheck": "Health Check", "configureHealthCheck": "Configure Health Check", - "configureHealthCheckDescription": "Set up health monitoring for {target}", + "configureHealthCheckDescription": "Set up monitoring for your resource to ensure it is always available", "enableHealthChecks": "Enable Health Checks", "healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.", "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", "healthScheme": "Method", "healthSelectScheme": "Select Method", - "healthCheckPortInvalid": "Health check port must be between 1 and 65535", + "healthCheckPortInvalid": "Port must be between 1 and 65535", "healthCheckPath": "Path", "healthHostname": "IP / Host", "healthPort": "Port", @@ -2080,6 +2080,11 @@ "sshServerDestination": "Server Destination", "sshServerDestinationDescription": "Configure the destination of the SSH server", "destination": "Destination", + "destinationRequired": "Destination is required.", + "domainRequired": "Domain is required.", + "proxyPortRequired": "Port is required.", + "invalidPathConfiguration": "Invalid path configuration.", + "invalidRewritePathConfiguration": "Invalid rewrite path configuration.", "bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.", "roleAllowSsh": "Allow SSH", "roleAllowSshAllow": "Allow", diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx index ee564156a..8ef4f6a8b 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -11,22 +11,23 @@ import { } from "@app/components/Settings"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { type Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema"; +import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useActionState, useEffect, useState } from "react"; +import { use, useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; @@ -35,177 +36,172 @@ type ExistingTarget = { siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); +type BgTarget = { + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + siteName?: string; + type: string; + destination: string; + destinationPort: number; +}; -export default function SshSettingsPage(props: { +type BgTargetsResponse = { + targets: BgTarget[]; +}; + +export default function RdpSettingsPage(props: { params: Promise<{ orgId: string }>; }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const { isPaidUser } = usePaidStatus(); + const api = createApiClient(useEnvContext()); const disabled = !isPaidUser( tierMatrix[TierFeature.AdvancedPublicResources] ); + const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], + queryFn: async () => { + const res = await api.get( + `/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` + ); + return res.data.data as BgTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( - ); } -function SshServerForm({ +function RdpServerForm({ orgId, resource, - updateResource, - disabled + disabled, + bgTargetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + bgTargetsResponse: BgTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const targets = bgTargetsResponse.targets; + const firstTarget = targets[0]; - // Standard mode: multi-site - const [selectedSites, setSelectedSites] = useState([]); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); - const [existingTargets, setExistingTargets] = useState( - [] + const formSchema = useMemo( + () => createBrowserGatewayTargetFormSchema(t), + [t] ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); - const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); - - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - siteName?: string; - type: string; - destination: string; - destinationPort: number; - }>; - }; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + selectedSites: targets.map((target) => ({ + siteId: target.siteId, + name: target.siteName ?? String(target.siteId), + type: "newt" as const + })), + destination: firstTarget?.destination ?? "", + destinationPort: firstTarget + ? String(firstTarget.destinationPort) + : "3389" } }); - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; - const first = targets[0]; - - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); - setExistingTargets( - targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId + const [existingTargets, setExistingTargets] = useState( + () => + targets.map((target) => ({ + browserGatewayTargetId: target.browserGatewayTargetId, + siteId: target.siteId })) - ); - setSelectedSites( - targets.map((t) => ({ - siteId: t.siteId, - name: t.siteName ?? String(t.siteId), - type: "newt" as const - })) - ); - }, [bgTargetsResponse]); + ); const [, formAction, isSubmitting] = useActionState(save, null); async function save() { + const isValid = await form.trigger(); + if (!isValid) return; + + const { selectedSites, destination, destinationPort } = + form.getValues(); + try { - if (bgDestination && bgDestinationPort) { - const selectedSiteIds = new Set( - selectedSites.map((s) => s.siteId) - ); - const existingSiteIds = new Set( - existingTargets.map((t) => t.siteId) - ); + const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId)); + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) + ); - const toDelete = existingTargets.filter( - (t) => !selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toDelete.map((t) => + api.delete( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` ) - ); + ) + ); - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, - { - type: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId - } - ) + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + { + type: "rdp", + destination, + destinationPort: Number(destinationPort), + siteId: t.siteId + } ) - ); + ) + ); - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - const created = await Promise.all( - toCreate.map((s) => - api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, - { - siteId: s.siteId, - type: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) - } - ) + const toCreate = selectedSites.filter( + (s) => !existingSiteIds.has(s.siteId) + ); + const created = await Promise.all( + toCreate.map((s) => + api.put( + `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + { + siteId: s.siteId, + type: "rdp", + destination, + destinationPort: Number(destinationPort) + } ) - ); + ) + ); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId - })); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); toast({ title: t("settingsUpdated"), @@ -237,31 +233,31 @@ function SshServerForm({ disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""} > - - - - - -
- -
+
+ + + + + + + +
+ ); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index c769d28e0..1e283afa8 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -16,8 +16,7 @@ import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { - SitesSelector, - type Selectedsite + SitesSelector } from "@app/components/site-selector"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; @@ -41,15 +40,16 @@ import { Badge } from "@app/components/ui/badge"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createSshSettingsFormSchema } from "@app/lib/browserGatewayTargetFormSchema"; +import type { SshSettingsFormValues } from "@app/lib/browserGatewayTargetFormSchema"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useActionState, useEffect, useState } from "react"; +import { use, useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; @@ -58,16 +58,19 @@ type ExistingTarget = { siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); +type BgTarget = { + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + siteName?: string; + type: string; + destination: string; + destinationPort: number; +}; + +type BgTargetsResponse = { + targets: BgTarget[]; +}; export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; @@ -75,10 +78,25 @@ export default function SshSettingsPage(props: { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const { isPaidUser } = usePaidStatus(); + const api = createApiClient(useEnvContext()); const disabled = !isPaidUser( tierMatrix[TierFeature.AdvancedPublicResources] ); + const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], + queryFn: async () => { + const res = await api.get( + `/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` + ); + return res.data.data as BgTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( ); @@ -98,142 +117,146 @@ function SshServerForm({ orgId, resource, updateResource, - disabled + disabled, + bgTargetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + bgTargetsResponse: BgTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); const isNativeInitially = resource.authDaemonMode === "native"; + const targets = bgTargetsResponse.targets; + const firstTarget = targets[0]; + const initialPamMode = + (resource.pamMode as "passthrough" | "push") || "passthrough"; + const initialStandardDaemonLocation = isNativeInitially + ? "site" + : ((resource.authDaemonMode as "site" | "remote") || "site"); + const useSingleSiteOnLoad = + !isNativeInitially && + initialPamMode === "push" && + initialStandardDaemonLocation === "site"; - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( + const [sshServerMode] = useState<"standard" | "native">( isNativeInitially ? "native" : "standard" ); const isNative = sshServerMode === "native"; - const [pamMode, setPamMode] = useState<"passthrough" | "push">( - (resource.pamMode as "passthrough" | "push") || "passthrough" + const formSchema = useMemo( + () => createSshSettingsFormSchema(t, { isNative }), + [t, isNative] ); - const [standardDaemonLocation, setStandardDaemonLocation] = useState< - "site" | "remote" - >( - isNativeInitially - ? "site" - : (resource.authDaemonMode as "site" | "remote") || "site" - ); - - const form = useForm({ - resolver: zodResolver(sshFormSchema), + const form = useForm({ + resolver: zodResolver(formSchema), defaultValues: { - authDaemonPort: (resource as any).authDaemonPort - ? String((resource as any).authDaemonPort) - : "22123" + pamMode: initialPamMode, + standardDaemonLocation: initialStandardDaemonLocation, + authDaemonPort: (resource as { authDaemonPort?: number }) + .authDaemonPort + ? String((resource as { authDaemonPort?: number }).authDaemonPort) + : "22123", + selectedSites: + isNativeInitially || useSingleSiteOnLoad + ? [] + : targets.map((target) => ({ + siteId: target.siteId, + name: target.siteName ?? String(target.siteId), + type: "newt" as const + })), + selectedSite: + useSingleSiteOnLoad && firstTarget + ? { + siteId: firstTarget.siteId, + name: + firstTarget.siteName ?? + String(firstTarget.siteId), + type: "newt" as const + } + : null, + selectedNativeSite: + isNativeInitially && firstTarget + ? { + siteId: firstTarget.siteId, + name: + firstTarget.siteName ?? + String(firstTarget.siteId), + type: "newt" as const + } + : null, + destination: isNativeInitially + ? "" + : (firstTarget?.destination ?? ""), + destinationPort: isNativeInitially + ? "22" + : firstTarget + ? String(firstTarget.destinationPort) + : "22" } }); - // Standard mode: multi-site - const [selectedSites, setSelectedSites] = useState([]); - const [selectedSite, setSelectedSite] = useState(null); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); const [existingTargets, setExistingTargets] = useState( - [] + () => + isNativeInitially + ? [] + : targets.map((target) => ({ + browserGatewayTargetId: target.browserGatewayTargetId, + siteId: target.siteId + })) ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); + useState(() => + isNativeInitially && firstTarget + ? { + browserGatewayTargetId: + firstTarget.browserGatewayTargetId, + siteId: firstTarget.siteId + } + : null + ); const [nativeSiteOpen, setNativeSiteOpen] = useState(false); - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - siteName?: string; - type: string; - destination: string; - destinationPort: number; - }>; - }; - } - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; - const first = targets[0]; - if (isNativeInitially) { - setSelectedNativeSite({ - siteId: first.siteId, - name: first.siteName ?? String(first.siteId), - type: "newt" as const - }); - setNativeExistingTarget({ - browserGatewayTargetId: first.browserGatewayTargetId, - siteId: first.siteId - }); - } else { - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); - setExistingTargets( - targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId - })) - ); - setSelectedSites( - targets.map((t) => ({ - siteId: t.siteId, - name: t.siteName ?? String(t.siteId), - type: "newt" as const - })) - ); - } - }, [bgTargetsResponse]); - const [, formAction, isSubmitting] = useActionState(save, null); + const pamMode = form.watch("pamMode"); + const standardDaemonLocation = form.watch("standardDaemonLocation"); + const selectedNativeSite = form.watch("selectedNativeSite"); + async function save() { const isValid = await form.trigger(); if (!isValid) return; - const effectiveMode = isNative ? "native" : standardDaemonLocation; - const portVal = form.getValues().authDaemonPort; + const values = form.getValues(); + const effectiveMode = isNative ? "native" : values.standardDaemonLocation; const effectivePort = - !isNative && standardDaemonLocation === "remote" && portVal - ? Number(portVal) + !isNative && + values.standardDaemonLocation === "remote" && + values.authDaemonPort + ? Number(values.authDaemonPort) : null; try { await api.post(`/resource/${resource.resourceId}`, { - pamMode, + pamMode: values.pamMode, authDaemonMode: effectiveMode, authDaemonPort: effectivePort }); updateResource({ ...resource, - pamMode, + pamMode: values.pamMode, authDaemonMode: effectiveMode }); if (isNative) { - if (selectedNativeSite) { + if (values.selectedNativeSite) { if (nativeExistingTarget) { await api.post( `/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`, @@ -241,14 +264,14 @@ function SshServerForm({ type: "ssh", destination: "localhost", destinationPort: 22, - siteId: selectedNativeSite.siteId + siteId: values.selectedNativeSite.siteId } ); } else { const res = await api.put( `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, { - siteId: selectedNativeSite.siteId, + siteId: values.selectedNativeSite.siteId, type: "ssh", destination: "localhost", destinationPort: 22 @@ -257,73 +280,82 @@ function SshServerForm({ setNativeExistingTarget({ browserGatewayTargetId: res.data.data.browserGatewayTargetId, - siteId: selectedNativeSite.siteId + siteId: values.selectedNativeSite.siteId }); } } } else { - if (bgDestination && bgDestinationPort) { - const selectedSiteIds = new Set( - selectedSites.map((s) => s.siteId) - ); - const existingSiteIds = new Set( - existingTargets.map((t) => t.siteId) - ); + const useMultiSite = + values.standardDaemonLocation !== "site" || + values.pamMode === "passthrough"; + const activeSites = useMultiSite + ? values.selectedSites + : values.selectedSite + ? [values.selectedSite] + : []; - const toDelete = existingTargets.filter( - (t) => !selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + const selectedSiteIds = new Set( + activeSites.map((s) => s.siteId) + ); + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) + ); + + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toDelete.map((t) => + api.delete( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` ) - ); + ) + ); - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, - { - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId - } - ) + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + { + type: "ssh", + destination: values.destination, + destinationPort: Number( + values.destinationPort + ), + siteId: t.siteId + } ) - ); + ) + ); - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - const created = await Promise.all( - toCreate.map((s) => - api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, - { - siteId: s.siteId, - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) - } - ) + const toCreate = activeSites.filter( + (s) => !existingSiteIds.has(s.siteId) + ); + const created = await Promise.all( + toCreate.map((s) => + api.put( + `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + { + siteId: s.siteId, + type: "ssh", + destination: values.destination, + destinationPort: Number( + values.destinationPort + ) + } ) - ); + ) + ); - const newTargets: ExistingTarget[] = created.map( - (res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId - }) - ); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: + res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); } toast({ @@ -373,6 +405,9 @@ function SshServerForm({ const showDaemonLocation = !isNative && pamMode === "push"; const showDaemonPort = !isNative && pamMode === "push" && standardDaemonLocation === "remote"; + const useMultiSiteTargetForm = + !isNative && + (standardDaemonLocation !== "site" || pamMode === "passthrough"); return ( @@ -386,160 +421,189 @@ function SshServerForm({ disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""} > - - -
- - {t("sshServerMode")} - - - {sshServerMode == "standard" - ? t("sshServerModeStandard") - : t("sshServerModePangolin")} - -
+
+ + +
+

{t("sshServerMode")}

+ + {sshServerMode == "standard" + ? t("sshServerModeStandard") + : t("sshServerModePangolin")} + +
-
- - {t("sshAuthenticationMethod")} - - - value={pamMode} - options={authMethodOptions} - onChange={setPamMode} - cols={2} - /> -
+
+

{t("sshAuthenticationMethod")}

+ + value={pamMode} + options={authMethodOptions} + onChange={(value) => + form.setValue("pamMode", value, { + shouldValidate: true + }) + } + cols={2} + /> +
- {showDaemonLocation && ( -
- - {t("sshAuthDaemonLocation")} - - - value={standardDaemonLocation} - options={daemonLocationOptions} - onChange={setStandardDaemonLocation} - cols={2} - /> -

- {t("sshDaemonDisclaimer")}{" "} - - {t("learnMore")} - - -

-
- )} - - {showDaemonPort && ( - - ( - - - {t("sshDaemonPort")} - - - - - - - )} - /> - - )} - -
- - - {t("sshServerDestination")} - - - {t("sshServerDestinationDescription")} - - - {isNative ? ( - - - - - - { - setSelectedNativeSite(site); - setNativeSiteOpen(false); - }} + {showDaemonLocation && ( +
+

{t("sshAuthDaemonLocation")}

+ + value={standardDaemonLocation} + options={daemonLocationOptions} + onChange={(value) => + form.setValue( + "standardDaemonLocation", + value, + { shouldValidate: true } + ) + } + cols={2} /> - - - ) : standardDaemonLocation !== "site" || - pamMode === "passthrough" ? ( - - ) : ( - - )} -
- - -
- -
+

+ {t("sshDaemonDisclaimer")}{" "} + + {t("learnMore")} + + +

+
+ )} + + {showDaemonPort && ( +
+ ( + + + {t("sshDaemonPort")} + + + + + + + )} + /> +
+ )} + +
+ + + {t("sshServerDestination")} + + + {t("sshServerDestinationDescription")} + + + {isNative ? ( + ( + + + + + + + + + { + form.setValue( + "selectedNativeSite", + site, + { + shouldValidate: + true + } + ); + setNativeSiteOpen( + false + ); + }} + /> + + + + + )} + /> + ) : useMultiSiteTargetForm ? ( + + ) : ( + + )} +
+
+
+
+ +
+
); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 51efd0311..55606af07 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -11,20 +11,23 @@ import { } from "@app/components/Settings"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { type Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema"; +import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useActionState, useEffect, useState } from "react"; -import { z } from "zod"; +import { use, useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; @@ -33,177 +36,172 @@ type ExistingTarget = { siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); +type BgTarget = { + browserGatewayTargetId: number; + resourceId: number; + siteId: number; + siteName?: string; + type: string; + destination: string; + destinationPort: number; +}; -export default function SshSettingsPage(props: { +type BgTargetsResponse = { + targets: BgTarget[]; +}; + +export default function VncSettingsPage(props: { params: Promise<{ orgId: string }>; }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); const { isPaidUser } = usePaidStatus(); + const api = createApiClient(useEnvContext()); const disabled = !isPaidUser( tierMatrix[TierFeature.AdvancedPublicResources] ); + const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({ + queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId], + queryFn: async () => { + const res = await api.get( + `/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets` + ); + return res.data.data as BgTargetsResponse; + } + }); + + if (isLoadingTargets) { + return null; + } + return ( - ); } -function SshServerForm({ +function VncServerForm({ orgId, resource, - updateResource, - disabled + disabled, + bgTargetsResponse }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; disabled: boolean; + bgTargetsResponse: BgTargetsResponse; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); + const targets = bgTargetsResponse.targets; + const firstTarget = targets[0]; - // Standard mode: multi-site - const [selectedSites, setSelectedSites] = useState([]); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); - const [existingTargets, setExistingTargets] = useState( - [] + const formSchema = useMemo( + () => createBrowserGatewayTargetFormSchema(t), + [t] ); - // Native mode: single site - const [selectedNativeSite, setSelectedNativeSite] = - useState(null); - const [nativeExistingTarget, setNativeExistingTarget] = - useState(null); - - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - siteName?: string; - type: string; - destination: string; - destinationPort: number; - }>; - }; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + selectedSites: targets.map((target) => ({ + siteId: target.siteId, + name: target.siteName ?? String(target.siteId), + type: "newt" as const + })), + destination: firstTarget?.destination ?? "", + destinationPort: firstTarget + ? String(firstTarget.destinationPort) + : "5900" } }); - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; - const first = targets[0]; - - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); - setExistingTargets( - targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId + const [existingTargets, setExistingTargets] = useState( + () => + targets.map((target) => ({ + browserGatewayTargetId: target.browserGatewayTargetId, + siteId: target.siteId })) - ); - setSelectedSites( - targets.map((t) => ({ - siteId: t.siteId, - name: t.siteName ?? String(t.siteId), - type: "newt" as const - })) - ); - }, [bgTargetsResponse]); + ); const [, formAction, isSubmitting] = useActionState(save, null); async function save() { + const isValid = await form.trigger(); + if (!isValid) return; + + const { selectedSites, destination, destinationPort } = + form.getValues(); + try { - if (bgDestination && bgDestinationPort) { - const selectedSiteIds = new Set( - selectedSites.map((s) => s.siteId) - ); - const existingSiteIds = new Set( - existingTargets.map((t) => t.siteId) - ); + const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId)); + const existingSiteIds = new Set( + existingTargets.map((t) => t.siteId) + ); - const toDelete = existingTargets.filter( - (t) => !selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + const toDelete = existingTargets.filter( + (t) => !selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toDelete.map((t) => + api.delete( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` ) - ); + ) + ); - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, - { - type: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId - } - ) + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post( + `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + { + type: "vnc", + destination, + destinationPort: Number(destinationPort), + siteId: t.siteId + } ) - ); + ) + ); - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - const created = await Promise.all( - toCreate.map((s) => - api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, - { - siteId: s.siteId, - type: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) - } - ) + const toCreate = selectedSites.filter( + (s) => !existingSiteIds.has(s.siteId) + ); + const created = await Promise.all( + toCreate.map((s) => + api.put( + `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + { + siteId: s.siteId, + type: "vnc", + destination, + destinationPort: Number(destinationPort) + } ) - ); + ) + ); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId - })); - setExistingTargets([...toUpdate, ...newTargets]); - } + const newTargets: ExistingTarget[] = created.map((res, i) => ({ + browserGatewayTargetId: res.data.data.browserGatewayTargetId, + siteId: toCreate[i].siteId + })); + setExistingTargets([...toUpdate, ...newTargets]); toast({ title: t("settingsUpdated"), @@ -235,31 +233,31 @@ function SshServerForm({ disabled={disabled} className={disabled ? "opacity-50 pointer-events-none" : ""} > - - - - - -
- -
+
+ + + + + + + +
+ ); diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 407196769..b67489c6b 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -50,6 +50,12 @@ import { toast } from "@app/hooks/useToast"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + createBrowserGatewayTargetFormSchema, + createSshSettingsFormSchema, + selectedSiteSchema, + type SshSettingsFormValues +} from "@app/lib/browserGatewayTargetFormSchema"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries } from "@app/lib/queries"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; @@ -79,100 +85,134 @@ import { useTransition, useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, type Resolver } from "react-hook-form"; import { z } from "zod"; -const baseResourceFormSchema = z.object({ - name: z.string().min(1).max(255), - http: z.boolean() -}); +type TranslateFn = (key: string) => string; -const httpResourceFormSchema = z.object({ - domainId: z.string().nonempty(), - subdomain: z.string().optional() -}); +function createBaseResourceFormSchema(t: TranslateFn) { + return z.object({ + name: z + .string() + .min(1, { message: t("nameRequired") }) + .max(255, { + message: t("createInternalResourceDialogNameMaxLength") + }), + http: z.boolean() + }); +} -const tcpUdpResourceFormSchema = z.object({ - protocol: z.string(), - proxyPort: z.int().min(1).max(65535) -}); +function createHttpResourceFormSchema(t: TranslateFn) { + return z.object({ + domainId: z.string().min(1, { message: t("domainRequired") }), + subdomain: z.string().optional() + }); +} -const sshDaemonPortSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); +function createTcpUdpResourceFormSchema(t: TranslateFn) { + return z.object({ + protocol: z.string(), + proxyPort: z + .number({ error: t("proxyPortRequired") }) + .int({ error: t("healthCheckPortInvalid") }) + .min(1, { message: t("healthCheckPortInvalid") }) + .max(65535, { message: t("healthCheckPortInvalid") }) + }); +} -const addTargetSchema = z - .object({ - ip: z.string().refine(isTargetValid), - method: z.string().nullable(), - port: z.coerce.number().int().positive(), - siteId: z.int().positive(), - path: z.string().optional().nullable(), - pathMatchType: z - .enum(["exact", "prefix", "regex"]) - .optional() - .nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional() - }) - .refine( - (data) => { - if (data.path && !data.pathMatchType) { - return false; - } - if (data.pathMatchType && !data.path) { - return false; - } - if (data.path && data.pathMatchType) { - switch (data.pathMatchType) { - case "exact": - case "prefix": - return data.path.startsWith("/"); - case "regex": - try { - new RegExp(data.path); - return true; - } catch { - return false; - } - } - } - return true; - }, - { - error: "Invalid path configuration" - } - ) - .refine( - (data) => { - if (data.rewritePath && !data.rewritePathType) { - return false; - } - if (data.rewritePathType && !data.rewritePath) { - if (data.rewritePathType !== "stripPrefix") { +function createSshDaemonPortSchema(t: TranslateFn) { + return z.object({ + authDaemonPort: z.string().refine( + (val) => { + if (!val) return true; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ) + }); +} + +function createAddTargetSchema(t: TranslateFn) { + return z + .object({ + ip: z.string().refine(isTargetValid, { + message: t("targetErrorInvalidIpDescription") + }), + method: z.string().nullable(), + port: z.coerce + .number({ error: t("targetErrorInvalidPortDescription") }) + .int({ error: t("targetErrorInvalidPortDescription") }) + .positive({ error: t("targetErrorInvalidPortDescription") }), + siteId: z + .int({ error: t("siteRequired") }) + .positive({ error: t("siteRequired") }), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable(), + priority: z + .int() + .min(1, { message: t("healthCheckPortInvalid") }) + .max(1000, { message: t("healthCheckPortInvalid") }) + .optional() + }) + .refine( + (data) => { + if (data.path && !data.pathMatchType) { return false; } + if (data.pathMatchType && !data.path) { + return false; + } + if (data.path && data.pathMatchType) { + switch (data.pathMatchType) { + case "exact": + case "prefix": + return data.path.startsWith("/"); + case "regex": + try { + new RegExp(data.path); + return true; + } catch { + return false; + } + } + } + return true; + }, + { + message: t("invalidPathConfiguration") } - return true; - }, - { - error: "Invalid rewrite path configuration" - } - ); + ) + .refine( + (data) => { + if (data.rewritePath && !data.rewritePathType) { + return false; + } + if (data.rewritePathType && !data.rewritePath) { + if (data.rewritePathType !== "stripPrefix") { + return false; + } + } + return true; + }, + { + message: t("invalidRewritePathConfiguration") + } + ); +} type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; +type CreateBgTargetFormValues = SshSettingsFormValues; + export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -223,29 +263,6 @@ export default function Page() { useState(null); const [nativeSiteOpen, setNativeSiteOpen] = useState(false); - // Browser-gateway targets state (SSH standard, RDP, VNC) - const [bgSelectedSites, setBgSelectedSites] = useState([]); - const [bgSelectedSite, setBgSelectedSite] = useState( - null - ); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState("22"); - - // Reset BG state when resource type changes - useEffect(() => { - if (resourceType === "rdp") { - setBgDestinationPort("3389"); - } else if (resourceType === "vnc") { - setBgDestinationPort("5900"); - } else if (resourceType === "ssh") { - setBgDestinationPort("22"); - } - setBgDestination(""); - setBgSelectedSites([]); - setBgSelectedSite(null); - setNativeSelectedSite(null); - }, [resourceType]); - useEffect(() => { if (build !== "saas") return; @@ -278,6 +295,39 @@ export default function Page() { pamMode === "push" && standardDaemonLocation === "remote"; + const bgTargetFormSchema = useMemo(() => { + if (resourceType === "ssh" && !isNative) { + return createSshSettingsFormSchema(t, { isNative: false }); + } + if (resourceType === "rdp" || resourceType === "vnc") { + return createBrowserGatewayTargetFormSchema(t); + } + return z.object({ + selectedSites: z.array(selectedSiteSchema), + selectedSite: selectedSiteSchema.nullable(), + destination: z.string(), + destinationPort: z.string(), + pamMode: z.enum(["passthrough", "push"]), + standardDaemonLocation: z.enum(["site", "remote"]) + }); + }, [resourceType, isNative, t]); + + const bgTargetForm = useForm({ + resolver: zodResolver( + bgTargetFormSchema + ) as unknown as Resolver, + defaultValues: { + selectedSites: [], + selectedSite: null, + selectedNativeSite: null, + destination: "", + destinationPort: "22", + pamMode: "passthrough", + standardDaemonLocation: "site", + authDaemonPort: "22123" + } + }); + // Whether raw (TCP/UDP) resources are available const rawResourcesAllowed = env.flags.allowRawResources && @@ -302,6 +352,24 @@ export default function Page() { } }, [availableTypes, resourceType]); + const baseResourceFormSchema = useMemo( + () => createBaseResourceFormSchema(t), + [t] + ); + const httpResourceFormSchema = useMemo( + () => createHttpResourceFormSchema(t), + [t] + ); + const tcpUdpResourceFormSchema = useMemo( + () => createTcpUdpResourceFormSchema(t), + [t] + ); + const sshDaemonPortSchema = useMemo( + () => createSshDaemonPortSchema(t), + [t] + ); + const addTargetSchema = useMemo(() => createAddTargetSchema(t), [t]); + const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), defaultValues: { @@ -330,6 +398,31 @@ export default function Page() { } }); + useEffect(() => { + const defaultPort = + resourceType === "rdp" + ? "3389" + : resourceType === "vnc" + ? "5900" + : "22"; + bgTargetForm.reset({ + selectedSites: [], + selectedSite: null, + selectedNativeSite: null, + destination: "", + destinationPort: defaultPort, + pamMode, + standardDaemonLocation, + authDaemonPort: sshDaemonPortForm.getValues().authDaemonPort + }); + setNativeSelectedSite(null); + }, [resourceType]); + + useEffect(() => { + bgTargetForm.setValue("pamMode", pamMode); + bgTargetForm.setValue("standardDaemonLocation", standardDaemonLocation); + }, [pamMode, standardDaemonLocation]); + // Sync form http field with resourceType useEffect(() => { baseForm.setValue("http", isHttpResource); @@ -508,20 +601,25 @@ export default function Page() { ); } } else { - const sitesToCreate = - standardDaemonLocation !== "site" - ? bgSelectedSites - : bgSelectedSite - ? [bgSelectedSite] - : []; + const bgValues = bgTargetForm.getValues(); + const useMultiSite = + standardDaemonLocation !== "site" || + pamMode === "passthrough"; + const sitesToCreate = useMultiSite + ? bgValues.selectedSites + : bgValues.selectedSite + ? [bgValues.selectedSite] + : []; for (const site of sitesToCreate) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: site.siteId, type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + destination: bgValues.destination, + destinationPort: Number( + bgValues.destinationPort + ) } ); } @@ -531,16 +629,19 @@ export default function Page() { `/${orgId}/settings/resources/public/${newNiceId}` ); } else if (resourceType === "rdp" || resourceType === "vnc") { - for (const site of bgSelectedSites) { + const bgValues = bgTargetForm.getValues(); + for (const site of bgValues.selectedSites) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: site.siteId, type: resourceType, - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + destination: bgValues.destination, + destinationPort: Number( + bgValues.destinationPort + ) } - ); + ); } router.push( @@ -760,32 +861,56 @@ export default function Page() { {/* Domain/Subdomain (HTTP-based types) */} {isHttpResource && ( -
- = - 1 - } - onDomainChange={(res) => { - if (!res) return; - httpForm.setValue( - "subdomain", - res.subdomain - ); - httpForm.setValue( - "domainId", - res.domainId - ); - }} - /> -

- {t( - "resourceDomainDescription" +

+ ( + + = + 1 + } + onDomainChange={( + res + ) => { + if (!res) + return; + httpForm.setValue( + "subdomain", + res.subdomain, + { + shouldValidate: + true + } + ); + httpForm.setValue( + "domainId", + res.domainId, + { + shouldValidate: + true + } + ); + }} + /> + + + {t( + "resourceDomainDescription" + )} + + )} -

-
+ /> + )} {/* Proxy Port (TCP/UDP types) */} @@ -883,9 +1008,7 @@ export default function Page() { {/* Mode */}
- - {t("sshServerMode")} - +

{t("sshServerMode")}

@@ -897,11 +1020,7 @@ export default function Page() {
- - {t( - "sshAuthenticationMethod" - )} - +

{t("sshAuthenticationMethod")}

@@ -917,11 +1036,7 @@ export default function Page() { {/* Daemon Location (standard + push) */} {showDaemonLocation && (
- - {t( - "sshAuthDaemonLocation" - )} - +

{t("sshAuthDaemonLocation")}

@@ -1052,55 +1167,39 @@ export default function Page() { "site" || pamMode === "passthrough" ? ( - +
+ + ) : ( - +
+ + )}
@@ -1138,26 +1237,18 @@ export default function Page() { > - +
+ +
@@ -1193,26 +1284,18 @@ export default function Page() { > - +
+ +
@@ -1253,15 +1336,31 @@ export default function Page() { const tcpValid = !isHttpResource ? await tcpUdpForm.trigger() : true; - const sshPortValid = showDaemonPort - ? await sshDaemonPortForm.trigger() - : true; + + if ( + resourceType === "ssh" && + !isNative + ) { + bgTargetForm.setValue( + "authDaemonPort", + sshDaemonPortForm.getValues() + .authDaemonPort + ); + } + + const bgValid = + resourceType === "rdp" || + resourceType === "vnc" || + (resourceType === "ssh" && + !isNative) + ? await bgTargetForm.trigger() + : true; if ( baseValid && domainValid && tcpValid && - sshPortValid + bgValid ) { onSubmit(); } diff --git a/src/components/BrowserGatewayTargetForm.tsx b/src/components/BrowserGatewayTargetForm.tsx index df74e17b9..768ac2738 100644 --- a/src/components/BrowserGatewayTargetForm.tsx +++ b/src/components/BrowserGatewayTargetForm.tsx @@ -1,128 +1,173 @@ "use client"; +import { cn } from "@app/lib/cn"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import type { Control, FieldValues, Path } from "react-hook-form"; +import { useWatch } from "react-hook-form"; import { MultiSitesSelector, formatMultiSitesSelectorLabel } from "./multi-site-selector"; import { SitesSelector, type Selectedsite } from "./site-selector"; import { Button } from "./ui/button"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "./ui/form"; import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -type SingleSiteProps = { - multiSite?: false; - selectedSite: Selectedsite | null; - onSiteChange: (site: Selectedsite | null) => void; -}; - -type MultiSiteProps = { - multiSite: true; - selectedSites: Selectedsite[]; - onSitesChange: (sites: Selectedsite[]) => void; -}; - -export type BrowserGatewayTargetFormProps = { +type BaseProps = { + control: Control; orgId: string; - destination: string; - defaultPort: number; - destinationPort: string; - onDestinationChange: (v: string) => void; - onDestinationPortChange: (v: string) => void; + destinationField: Path; + destinationPortField: Path; learnMoreHref?: string; -} & (SingleSiteProps | MultiSiteProps); + defaultPort: number; +}; -export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) { +type MultiSiteFormProps = BaseProps & { + multiSite: true; + sitesField: Path; +}; + +type SingleSiteFormProps = BaseProps & { + multiSite?: false; + siteField: Path; +}; + +export type BrowserGatewayTargetFormProps = + | MultiSiteFormProps + | SingleSiteFormProps; + +export function BrowserGatewayTargetForm( + props: BrowserGatewayTargetFormProps +) { const t = useTranslations(); const [siteOpen, setSiteOpen] = useState(false); - const siteSelector = - props.multiSite === true ? ( - - - - - - - - - ) : ( - - - - - - { - props.onSiteChange(site); - setSiteOpen(false); - }} - /> - - - ); + const sitesFieldName = + props.multiSite === true ? props.sitesField : props.siteField; + + const watchedSites = useWatch({ + control: props.control, + name: sitesFieldName + }); + + const showMultiSiteDisclaimer = + props.multiSite === true && + ((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1; return (
-
-
- - {siteSelector} -
-
- - - props.onDestinationChange(e.target.value) - } - /> -
-
- - - props.onDestinationPortChange(e.target.value) - } - /> -
+
+ ( + + {t("sites")} + + + + + + + + {props.multiSite === true ? ( + + ) : ( + { + field.onChange(site); + setSiteOpen(false); + }} + /> + )} + + + + + )} + /> + ( + + {t("destination")} + + + + + + )} + /> + ( + + {t("port")} + + + + + + )} + />
- {props.multiSite === true && props.selectedSites.length > 1 && ( + {showMultiSiteDisclaimer && (

{t("bgTargetMultiSiteDisclaimer")}{" "} - +

{t("sshServerMode")} - +

value={sshServerMode} options={[ @@ -1870,9 +1870,9 @@ export function PrivateResourceForm({
- +

{t("sshAuthenticationMethod")} - +

- +

{t("sshAuthDaemonLocation")} - +

- {resource.mode!.toUpperCase()} + {resource.ssl ? "HTTPS" : "HTTP"} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a1df9d3c8..09fc8b0af 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -70,7 +70,7 @@ export function SettingsSubsectionHeader({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return
{children}
; } export function SettingsSubsectionTitle({ @@ -80,9 +80,7 @@ export function SettingsSubsectionTitle({ children: React.ReactNode; className?: string; }) { - return ( -

{children}

- ); + return

{children}

; } export function SettingsSubsectionDescription({ diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 8a2bae44c..788d9fe7d 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -157,7 +157,7 @@ export function LabelsSelector({ /> { const input = e.target.value.trim(); const hasProtocol = /^(https?|h2c):\/\//.test(input); diff --git a/src/lib/browserGatewayTargetFormSchema.ts b/src/lib/browserGatewayTargetFormSchema.ts new file mode 100644 index 000000000..c6d7b3afd --- /dev/null +++ b/src/lib/browserGatewayTargetFormSchema.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; + +type TranslateFn = (key: string) => string; + +export const selectedSiteSchema = z.object({ + siteId: z.number().int().positive(), + name: z.string(), + type: z.string() +}); + +export type SelectedSiteFormValue = z.infer; + +export function createPortStringSchema(t: TranslateFn) { + return z.string().refine( + (val) => { + if (!val) return false; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ); +} + +function createOptionalAuthDaemonPortSchema(t: TranslateFn) { + return z.string().refine( + (val) => { + if (!val) return true; + const n = Number(val); + return Number.isInteger(n) && n >= 1 && n <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ); +} + +export function createBrowserGatewayTargetFormSchema(t: TranslateFn) { + return z.object({ + selectedSites: z.array(selectedSiteSchema).min(1, { + message: t("siteRequired") + }), + destination: z.string().min(1, { + message: t("destinationRequired") + }), + destinationPort: createPortStringSchema(t) + }); +} + +export type BrowserGatewayTargetFormValues = z.infer< + ReturnType +>; + +export function createSshSettingsFormSchema( + t: TranslateFn, + options: { isNative: boolean } +) { + const { isNative } = options; + const portSchema = createPortStringSchema(t); + const optionalAuthDaemonPortSchema = createOptionalAuthDaemonPortSchema(t); + + return z + .object({ + pamMode: z.enum(["passthrough", "push"]), + standardDaemonLocation: z.enum(["site", "remote"]), + authDaemonPort: z.string(), + selectedSites: z.array(selectedSiteSchema), + selectedSite: selectedSiteSchema.nullable(), + selectedNativeSite: selectedSiteSchema.nullable(), + destination: z.string(), + destinationPort: z.string() + }) + .superRefine((data, ctx) => { + if (isNative) { + if (!data.selectedNativeSite) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["selectedNativeSite"], + message: t("siteRequired") + }); + } + return; + } + + const useMultiSite = + data.standardDaemonLocation !== "site" || + data.pamMode === "passthrough"; + + if (useMultiSite) { + if (data.selectedSites.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["selectedSites"], + message: t("siteRequired") + }); + } + } else if (!data.selectedSite) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["selectedSite"], + message: t("siteRequired") + }); + } + + if (!data.destination.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["destination"], + message: t("destinationRequired") + }); + } + + const portResult = portSchema.safeParse(data.destinationPort); + if (!portResult.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["destinationPort"], + message: t("healthCheckPortInvalid") + }); + } + + const showDaemonPort = + data.pamMode === "push" && + data.standardDaemonLocation === "remote"; + + if (showDaemonPort) { + const authPortResult = optionalAuthDaemonPortSchema.safeParse( + data.authDaemonPort + ); + if (!data.authDaemonPort.trim() || !authPortResult.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["authDaemonPort"], + message: t("healthCheckPortInvalid") + }); + } + } + }); +} + +export type SshSettingsFormValues = z.infer< + ReturnType +>; From 772ac8af73e4781ce89ea60b6187145304003e49 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 15:30:42 -0700 Subject: [PATCH 14/28] Remove browser gateway targets for regular targets --- server/db/pg/schema/privateSchema.ts | 21 -- server/db/pg/schema/schema.ts | 7 +- server/db/sqlite/schema/privateSchema.ts | 23 --- server/db/sqlite/schema/schema.ts | 7 +- .../private/lib/traefik/getTraefikConfig.ts | 78 ++------ .../createBrowserGatewayTarget.ts | 187 ------------------ .../deleteBrowserGatewayTarget.ts | 130 ------------ .../getBrowserGatewayTarget.ts | 109 ---------- .../browserGatewayTarget/getBrowserTarget.ts | 53 ++--- .../routers/browserGatewayTarget/index.ts | 5 - .../listBrowserGatewayTargets.ts | 159 --------------- .../updateBrowserGatewayTarget.ts | 180 ----------------- server/private/routers/external.ts | 46 ----- server/private/routers/integration.ts | 41 ---- server/private/routers/internal.ts | 2 +- server/private/routers/ssh/signSshKey.ts | 16 +- server/routers/newt/buildConfiguration.ts | 46 +++-- server/routers/newt/targets.ts | 20 +- server/routers/resource/listResources.ts | 49 +---- server/routers/site/listSites.ts | 5 - server/routers/target/createTarget.ts | 17 ++ server/routers/target/listTargets.ts | 1 + server/routers/target/updateTarget.ts | 7 + .../public/ProxyResourceTargetsForm.tsx | 43 +--- .../resources/public/[niceId]/rdp/page.tsx | 69 +++---- .../resources/public/[niceId]/ssh/page.tsx | 95 +++++---- .../resources/public/[niceId]/vnc/page.tsx | 69 +++---- .../settings/resources/public/create/page.tsx | 28 +-- 28 files changed, 259 insertions(+), 1254 deletions(-) delete mode 100644 server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts delete mode 100644 server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts delete mode 100644 server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts delete mode 100644 server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts delete mode 100644 server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 5040808a9..229fc9ff0 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", { sentAt: bigint("sentAt", { mode: "number" }).notNull() }); -export const browserGatewayTarget = pgTable("browserGatewayTarget", { - browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - authToken: varchar("authToken").notNull(), - type: varchar("type").notNull(), // "ssh", "rdp", "vnc" - destination: varchar("destination").notNull(), - destinationPort: integer("destinationPort").notNull() -}); - export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel< >; export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; -export type BrowserGatewayTarget = InferSelectModel< - typeof browserGatewayTarget ->; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6b4ce32b8..b7b34a5d7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -290,7 +290,12 @@ export const targets = pgTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100) + priority: integer("priority").notNull().default(100), + mode: varchar("mode") + .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() + .notNull() + .default("http"), + authToken: varchar("authToken") }); export const targetHealthCheck = pgTable("targetHealthCheck", { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b235d26d5..ae7360780 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", { sentAt: integer("sentAt").notNull() }); -export const browserGatewayTarget = sqliteTable("browserGatewayTarget", { - browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({ - autoIncrement: true - }), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - authToken: text("authToken").notNull(), - type: text("type").notNull(), // "ssh", "rdp", "vnc" - destination: text("destination").notNull(), - destinationPort: integer("destinationPort").notNull() -}); - export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel; export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; -export type BrowserGatewayTarget = InferSelectModel< - typeof browserGatewayTarget ->; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 492576cc6..639e3cf4f 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100) + priority: integer("priority").notNull().default(100), + mode: text("mode") + .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() + .notNull() + .default("http"), + authToken: text("authToken") }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 7ff452880..901c88f49 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -12,7 +12,6 @@ */ import { - browserGatewayTarget, certificates, db, domainNamespaces, @@ -182,6 +181,9 @@ export async function getTraefikConfig( const resourcesMap = new Map(); resourcesWithTargetsAndSites.forEach((row) => { + if (!["http", "tcp", "udp"].includes(row.mode)) { + return; + } const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") @@ -295,13 +297,12 @@ export async function getTraefikConfig( maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; targets: { - browserGatewayTargetId: number; + targetId: number; bgType: string; siteId: number; siteType: string; siteOnline: boolean | null; subnet: string | null; - siteExitNodeId: number | null; }[]; }; const browserGatewayResourcesMap = new Map< @@ -310,66 +311,10 @@ export async function getTraefikConfig( >(); if (allowBrowserGatewayResources) { - // Query browser gateway targets for this exit node - const browserGatewayRows = await db - .select({ - // Resource fields - resourceId: resources.resourceId, - resourceName: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - subdomain: resources.subdomain, - domainId: resources.domainId, - enabled: resources.enabled, - wildcard: resources.wildcard, - domainCertResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert, - domainNamespaceId: domainNamespaces.domainNamespaceId, - // Maintenance fields - maintenanceModeEnabled: resources.maintenanceModeEnabled, - maintenanceModeType: resources.maintenanceModeType, - maintenanceTitle: resources.maintenanceTitle, - maintenanceMessage: resources.maintenanceMessage, - maintenanceEstimatedTime: resources.maintenanceEstimatedTime, - // Browser gateway target fields - browserGatewayTargetId: - browserGatewayTarget.browserGatewayTargetId, - bgType: browserGatewayTarget.type, - // Site fields - siteId: sites.siteId, - siteType: sites.type, - siteOnline: sites.online, - subnet: sites.subnet, - siteExitNodeId: sites.exitNodeId - }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .innerJoin( - resources, - eq(resources.resourceId, browserGatewayTarget.resourceId) - ) - .leftJoin(domains, eq(domains.domainId, resources.domainId)) - .leftJoin( - domainNamespaces, - eq(domainNamespaces.domainId, resources.domainId) - ) - .where( - and( - eq(resources.enabled, true), - or( - eq(sites.exitNodeId, exitNodeId), - and( - isNull(sites.exitNodeId), - sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, - eq(sites.type, "local"), - sql`(${build != "saas" ? 1 : 0} = 1)` - ) - ), - inArray(sites.type, siteTypes) - ) - ); - - for (const row of browserGatewayRows) { + for (const row of resourcesWithTargetsAndSites) { + if (!["ssh", "vnc", "rdp"].includes(row.mode)) { + return; + } if (filterOutNamespaceDomains && row.domainNamespaceId) { continue; } @@ -394,13 +339,12 @@ export async function getTraefikConfig( }); } browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ - browserGatewayTargetId: row.browserGatewayTargetId, - bgType: row.bgType, + targetId: row.targetId, + bgType: row.mode, siteId: row.siteId, siteType: row.siteType, siteOnline: row.siteOnline, - subnet: row.subnet, - siteExitNodeId: row.siteExitNodeId + subnet: row.subnet }); } } diff --git a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts deleted file mode 100644 index b26a1a8b6..000000000 --- a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - newts, - resources, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { encrypt } from "@server/lib/crypto"; -import config from "@server/lib/config"; -import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; -import { generateId } from "@server/auth/sessions/app"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - resourceId: z.string().transform(Number).pipe(z.number().int().positive()) -}); - -const bodySchema = z.strictObject({ - siteId: z.number().int().positive(), - type: z.enum(["ssh", "rdp", "vnc"]), - destination: z.string().nonempty(), - destinationPort: z.number().int().min(1).max(65535) -}); - -export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget; - -registry.registerPath({ - method: "put", - path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target", - description: "Create a browser gateway target for a resource.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema, - body: { - content: { - "application/json": { - schema: bodySchema - } - } - } - }, - responses: {} -}); - -export async function createBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, resourceId } = parsedParams.data; - - const parsedBody = bodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { siteId, type, destination, destinationPort } = parsedBody.data; - - const [resource] = await db - .select() - .from(resources) - .where( - and( - eq(resources.resourceId, resourceId), - eq(resources.orgId, orgId) - ) - ) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found in organization ${orgId}` - ) - ); - } - - const [site] = await db - .select() - .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${siteId} not found in organization ${orgId}` - ) - ); - } - - const plainToken = generateId(48); - const encryptedToken = encrypt( - plainToken, - config.getRawConfig().server.secret! - ); - - const [record] = await db - .insert(browserGatewayTarget) - .values({ - resourceId, - siteId, - type, - destination, - destinationPort, - authToken: encryptedToken - }) - .returning(); - - if (site.type === "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, siteId)) - .limit(1); - - if (newt) { - await sendBrowserGatewayTargets( - newt.newtId, - [record], - newt.version - ); - } - } - - logger.info( - `Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}` - ); - - return response(res, { - data: record, - success: true, - error: false, - message: "Browser gateway target created successfully", - status: HttpCode.CREATED - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts deleted file mode 100644 index 850944b29..000000000 --- a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { browserGatewayTarget, db, newts, sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { removeBrowserGatewayTarget } from "@server/routers/newt/targets"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - browserGatewayTargetId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) -}); - -registry.registerPath({ - method: "delete", - path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}", - description: "Delete a browser gateway target.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema - }, - responses: {} -}); - -export async function deleteBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, browserGatewayTargetId } = parsedParams.data; - - const [existing] = await db - .select({ bgt: browserGatewayTarget, site: sites }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where( - and( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ), - eq(sites.orgId, orgId) - ) - ) - .limit(1); - - if (!existing) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Browser gateway target with ID ${browserGatewayTargetId} not found` - ) - ); - } - - await db - .delete(browserGatewayTarget) - .where( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ) - ); - - if (existing.site.type === "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, existing.bgt.siteId)) - .limit(1); - - if (newt) { - await removeBrowserGatewayTarget( - newt.newtId, - browserGatewayTargetId, - newt.version - ); - } - } - - logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`); - - return response(res, { - data: null, - success: true, - error: false, - message: "Browser gateway target deleted successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to delete browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts deleted file mode 100644 index 0ac7a8ce9..000000000 --- a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - browserGatewayTargetId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) -}); - -export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget; - -registry.registerPath({ - method: "get", - path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}", - description: "Get a browser gateway target.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema - }, - responses: {} -}); - -export async function getBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, browserGatewayTargetId } = parsedParams.data; - - const [result] = await db - .select({ bgt: browserGatewayTarget }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where( - and( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ), - eq(sites.orgId, orgId) - ) - ) - .limit(1); - - if (!result) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Browser gateway target with ID ${browserGatewayTargetId} not found` - ) - ); - } - - return response(res, { - data: result.bgt, - success: true, - error: false, - message: "Browser gateway target retrieved successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to retrieve browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts index 51e16de75..b8e32d836 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts @@ -13,9 +13,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { browserGatewayTarget, db } from "@server/db"; -import { resources, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, resources, targets } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -51,11 +50,11 @@ export async function getBrowserTarget( logger.info(`Retrieving browser target for domain: ${fullDomain}`); - const [browserTarget] = await db + const [row] = await db .select({ - destination: browserGatewayTarget.destination, - destinationPort: browserGatewayTarget.destinationPort, - authToken: browserGatewayTarget.authToken, + ip: targets.ip, + port: targets.port, + authToken: targets.authToken, resourceId: resources.resourceId, niceId: resources.niceId, name: resources.name, @@ -63,20 +62,18 @@ export async function getBrowserTarget( pamMode: resources.pamMode, authDaemonMode: resources.authDaemonMode }) - .from(browserGatewayTarget) - .innerJoin( - resources, - eq(browserGatewayTarget.resourceId, resources.resourceId) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + eq(resources.fullDomain, fullDomain), + eq(targets.enabled, true), + inArray(targets.mode, ["ssh", "rdp", "vnc"]) + ) ) - .where(eq(resources.fullDomain, fullDomain)) .limit(1); - const decryptedAuthToken = decrypt( - browserTarget.authToken, - config.getRawConfig().server.secret! - ); - - if (!browserTarget) { + if (!row) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -85,17 +82,21 @@ export async function getBrowserTarget( ); } + const decryptedAuthToken = row.authToken + ? decrypt(row.authToken, config.getRawConfig().server.secret!) + : ""; + return response(res, { data: { - ip: browserTarget.destination, - port: browserTarget.destinationPort, + ip: row.ip, + port: row.port, authToken: decryptedAuthToken, - pamMode: browserTarget.pamMode, - authDaemonMode: browserTarget.authDaemonMode, - orgId: browserTarget.orgId, - resourceId: browserTarget.resourceId, - niceId: browserTarget.niceId, - name: browserTarget.name + pamMode: row.pamMode, + authDaemonMode: row.authDaemonMode, + orgId: row.orgId, + resourceId: row.resourceId, + niceId: row.niceId, + name: row.name ?? "" }, success: true, error: false, diff --git a/server/private/routers/browserGatewayTarget/index.ts b/server/private/routers/browserGatewayTarget/index.ts index c9cd15dff..3c1b3d6f9 100644 --- a/server/private/routers/browserGatewayTarget/index.ts +++ b/server/private/routers/browserGatewayTarget/index.ts @@ -11,9 +11,4 @@ * This file is not licensed under the AGPLv3. */ -export * from "./createBrowserGatewayTarget"; -export * from "./updateBrowserGatewayTarget"; -export * from "./deleteBrowserGatewayTarget"; -export * from "./getBrowserGatewayTarget"; -export * from "./listBrowserGatewayTargets"; export * from "./getBrowserTarget"; diff --git a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts deleted file mode 100644 index 5b3d1e5d0..000000000 --- a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - resources, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - resourceId: z.string().transform(Number).pipe(z.number().int().positive()) -}); - -const querySchema = z.object({ - limit: z - .string() - .optional() - .default("1000") - .transform(Number) - .pipe(z.number().int().positive()), - offset: z - .string() - .optional() - .default("0") - .transform(Number) - .pipe(z.number().int().nonnegative()) -}); - -export type ListBrowserGatewayTargetsResponse = { - targets: BrowserGatewayTarget[]; - total: number; - limit: number; - offset: number; -}; - -registry.registerPath({ - method: "get", - path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets", - description: "List browser gateway targets for a resource.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema, - query: querySchema - }, - responses: {} -}); - -export async function listBrowserGatewayTargets( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, resourceId } = parsedParams.data; - - const parsedQuery = querySchema.safeParse(req.query); - if (!parsedQuery.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedQuery.error).toString() - ) - ); - } - - const { limit, offset } = parsedQuery.data; - - const [resource] = await db - .select() - .from(resources) - .where( - and( - eq(resources.resourceId, resourceId), - eq(resources.orgId, orgId) - ) - ) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found in organization ${orgId}` - ) - ); - } - - const rows = await db - .select({ - browserGatewayTargetId: - browserGatewayTarget.browserGatewayTargetId, - resourceId: browserGatewayTarget.resourceId, - siteId: browserGatewayTarget.siteId, - authToken: browserGatewayTarget.authToken, - type: browserGatewayTarget.type, - destination: browserGatewayTarget.destination, - destinationPort: browserGatewayTarget.destinationPort, - siteName: sites.name - }) - .from(browserGatewayTarget) - .leftJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where(eq(browserGatewayTarget.resourceId, resourceId)) - .limit(limit) - .offset(offset); - - return response(res, { - data: { - targets: rows as any, - total: rows.length, - limit, - offset - }, - success: true, - error: false, - message: "Browser gateway targets retrieved successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to list browser gateway targets" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts deleted file mode 100644 index 825407dc3..000000000 --- a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { - browserGatewayTarget, - BrowserGatewayTarget, - db, - newts, - sites -} from "@server/db"; -import { eq, and } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; - -const paramsSchema = z.strictObject({ - orgId: z.string().nonempty(), - browserGatewayTargetId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) -}); - -const bodySchema = z.strictObject({ - siteId: z.number().int().positive().optional(), - type: z.enum(["ssh", "rdp", "vnc"]).optional(), - destination: z.string().nonempty().optional(), - destinationPort: z.number().int().min(1).max(65535).optional() -}); - -export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget; - -registry.registerPath({ - method: "post", - path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}", - description: "Update a browser gateway target.", - tags: [OpenAPITags.Org], - request: { - params: paramsSchema, - body: { - content: { - "application/json": { - schema: bodySchema - } - } - } - }, - responses: {} -}); - -export async function updateBrowserGatewayTarget( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const { orgId, browserGatewayTargetId } = parsedParams.data; - - const parsedBody = bodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { siteId, type, destination, destinationPort } = parsedBody.data; - - const [existing] = await db - .select({ bgt: browserGatewayTarget, site: sites }) - .from(browserGatewayTarget) - .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) - .where( - and( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ), - eq(sites.orgId, orgId) - ) - ) - .limit(1); - - if (!existing) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Browser gateway target with ID ${browserGatewayTargetId} not found` - ) - ); - } - - const updateValues: Partial = {}; - if (siteId !== undefined) updateValues.siteId = siteId; - if (type !== undefined) updateValues.type = type; - if (destination !== undefined) updateValues.destination = destination; - if (destinationPort !== undefined) - updateValues.destinationPort = destinationPort; - - const [updated] = await db - .update(browserGatewayTarget) - .set(updateValues) - .where( - eq( - browserGatewayTarget.browserGatewayTargetId, - browserGatewayTargetId - ) - ) - .returning(); - - const targetSiteId = siteId ?? existing.bgt.siteId; - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, targetSiteId)) - .limit(1); - - if (site && site.type === "newt") { - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, targetSiteId)) - .limit(1); - - if (newt) { - await sendBrowserGatewayTargets( - newt.newtId, - [updated], - newt.version - ); - } - } - - logger.info(`Updated browser gateway target ${browserGatewayTargetId}`); - - return response(res, { - data: updated, - success: true, - error: false, - message: "Browser gateway target updated successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to update browser gateway target" - ) - ); - } -} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0598a1514..881ba2277 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; -import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client"; import * as resource from "#private/routers/resource"; @@ -879,48 +878,3 @@ authenticated.post( verifyClientAccess, client.rebuildClientAssociationsCacheRoute ); - -authenticated.put( - "/org/:orgId/resource/:resourceId/browser-gateway-target", - verifyValidLicense, - verifyOrgAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget), - logActionAudit(ActionsEnum.createBrowserGatewayTarget), - browserGatewayTarget.createBrowserGatewayTarget -); - -authenticated.get( - "/org/:orgId/resource/:resourceId/browser-gateway-targets", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets), - browserGatewayTarget.listBrowserGatewayTargets -); - -authenticated.get( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget), - browserGatewayTarget.getBrowserGatewayTarget -); - -authenticated.post( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyValidLicense, - verifyOrgAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget), - logActionAudit(ActionsEnum.updateBrowserGatewayTarget), - browserGatewayTarget.updateBrowserGatewayTarget -); - -authenticated.delete( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget), - logActionAudit(ActionsEnum.deleteBrowserGatewayTarget), - browserGatewayTarget.deleteBrowserGatewayTarget -); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 542c806f4..820a843f0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,7 +16,6 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; -import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import { verifyApiKeyHasAction, @@ -216,43 +215,3 @@ authenticated.delete( logActionAudit(ActionsEnum.removeUserRole), user.removeUserRole ); - -authenticated.put( - "/org/:orgId/resource/:resourceId/browser-gateway-target", - verifyApiKeyOrgAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget), - logActionAudit(ActionsEnum.createBrowserGatewayTarget), - browserGatewayTarget.createBrowserGatewayTarget -); - -authenticated.get( - "/org/:orgId/resource/:resourceId/browser-gateway-targets", - verifyApiKeyOrgAccess, - verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets), - browserGatewayTarget.listBrowserGatewayTargets -); - -authenticated.get( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyApiKeyOrgAccess, - verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget), - browserGatewayTarget.getBrowserGatewayTarget -); - -authenticated.post( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyApiKeyOrgAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget), - logActionAudit(ActionsEnum.updateBrowserGatewayTarget), - browserGatewayTarget.updateBrowserGatewayTarget -); - -authenticated.delete( - "/org/:orgId/browser-gateway-target/:browserGatewayTargetId", - verifyApiKeyOrgAccess, - verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget), - logActionAudit(ActionsEnum.deleteBrowserGatewayTarget), - browserGatewayTarget.deleteBrowserGatewayTarget -); diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index f78acb48e..c45fe36b9 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; import * as resource from "#private/routers/resource"; -import * as browserTarget from "#private/routers/browserGatewayTarget"; import * as ssh from "#private/routers/ssh"; import * as ws from "@server/routers/ws"; +import * as browserTarget from "#private/routers/browserGatewayTarget"; import { verifySessionUserMiddleware, diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index dac4ae62a..bcf8beab7 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -30,8 +30,7 @@ import { userOrgs, sites, Resource, - SiteResource, - browserGatewayTarget + SiteResource } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -291,16 +290,15 @@ export async function signSshKey( const publicResource = resource as Resource; const targetRows = await db .select({ - siteId: browserGatewayTarget.siteId, - ip: browserGatewayTarget.destination + siteId: targets.siteId, + ip: targets.ip }) - .from(browserGatewayTarget) + .from(targets) .where( and( - eq( - browserGatewayTarget.resourceId, - publicResource.resourceId - ) + eq(targets.resourceId, publicResource.resourceId), + eq(targets.enabled, true), + eq(targets.mode, "ssh") ) ); diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 135920d6f..73bf2c630 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -1,6 +1,4 @@ import { - browserGatewayTarget, - BrowserGatewayTarget, clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, @@ -16,7 +14,7 @@ import { } from "@server/db"; import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { @@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient( }) .from(targets) .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) - .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + .where( + and( + eq(targets.siteId, siteId), + eq(targets.enabled, true), + inArray(targets.mode, ["http", "udp", "tcp"]) + ) + ); const allHealthChecks = await db .select({ @@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient( .from(targetHealthCheck) .where(eq(targetHealthCheck.siteId, siteId)); + // Get all enabled targets with their resource mode information const allBrowserGatewayTargets = await db - .select() - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + enabled: targets.enabled, + mode: resources.mode, + authToken: targets.authToken + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + eq(targets.siteId, siteId), + eq(targets.enabled, true), + inArray(targets.mode, ["ssh", "rdp", "vnc"]) + ) + ); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { @@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient( const serverSecret = config.getRawConfig().server.secret!; const browserGatewayTargets = allBrowserGatewayTargets.map((t) => { + if (!t.ip || !t.port || !t.authToken) { + return null; + } const decryptAuthToken = decrypt(t.authToken, serverSecret); return { - id: t.browserGatewayTargetId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort, + id: t.targetId, + type: t.mode, + destination: t.ip, + destinationPort: t.port, authToken: decryptAuthToken }; }); diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 6d8212b12..44aa34637 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,4 +1,4 @@ -import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; +import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; @@ -244,23 +244,27 @@ export async function removeTargets( export async function sendBrowserGatewayTargets( newtId: string, - targets: BrowserGatewayTarget[], + targets: Target[], version?: string | null ) { if (targets.length === 0) return; - const payload = targets.map((t) => { + // filter out the ones without auth tokens + const filteredTargets = targets.filter((t) => t.authToken); + if (filteredTargets.length === 0) return; + + const payload = filteredTargets.map((t) => { const decryptAuthToken = decrypt( - t.authToken, + t.authToken!, config.getRawConfig().server.secret! ); return { - id: t.browserGatewayTargetId, + id: t.targetId, resourceId: t.resourceId, siteId: t.siteId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort, + type: t.mode, + destination: t.ip, + destinationPort: t.port, authToken: decryptAuthToken }; }); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c982cab9f..8e0a03384 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,6 +1,5 @@ import { alias, - browserGatewayTarget, db, labels, resourceHeaderAuth, @@ -639,15 +638,8 @@ export async function listResources( .from(targets) .innerJoin(sites, eq(targets.siteId, sites.siteId)) .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); - const resourcesWithBrowserGateway = db - .select({ resourceId: browserGatewayTarget.resourceId }) - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); conditions.push( - or( - inArray(resources.resourceId, resourcesWithSite), - inArray(resources.resourceId, resourcesWithBrowserGateway) - ) + or(inArray(resources.resourceId, resourcesWithSite)) ); } @@ -770,30 +762,6 @@ export async function listResources( ) .leftJoin(sites, eq(targets.siteId, sites.siteId)); - const allBgTargetSites = - resourceIdList.length === 0 - ? [] - : await db - .select({ - resourceId: browserGatewayTarget.resourceId, - siteId: browserGatewayTarget.siteId, - siteName: sites.name, - siteNiceId: sites.niceId, - siteOnline: sites.online, - siteType: sites.type - }) - .from(browserGatewayTarget) - .where( - inArray( - browserGatewayTarget.resourceId, - resourceIdList - ) - ) - .leftJoin( - sites, - eq(sites.siteId, browserGatewayTarget.siteId) - ); - // avoids TS issues with reduce/never[] const map = new Map(); @@ -856,21 +824,6 @@ export async function listResources( online: isLocal ? undefined : Boolean(t.siteOnline) }); } - const bgRaw = allBgTargetSites.filter( - (t) => t.resourceId === entry.resourceId - ); - for (const t of bgRaw) { - if (typeof t.siteId !== "number" || siteById.has(t.siteId)) { - continue; - } - const isLocal = t.siteType === "local"; - siteById.set(t.siteId, { - siteId: t.siteId, - siteName: t.siteName ?? "", - siteNiceId: t.siteNiceId ?? "", - online: isLocal ? undefined : Boolean(t.siteOnline) - }); - } entry.sites = Array.from(siteById.values()); } diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c217da489..c6abace5f 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -12,7 +12,6 @@ import { userSites, labels, siteLabels, - browserGatewayTarget, type Label } from "@server/db"; import cache from "#dynamic/lib/cache"; @@ -241,10 +240,6 @@ function querySitesBase() { ON ${siteResources.networkId} = ${siteNetworks.networkId} WHERE ${siteNetworks.siteId} = ${sites.siteId} AND ${siteResources.orgId} = ${sites.orgId} - ) + ( - SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId}) - FROM ${browserGatewayTarget} - WHERE ${browserGatewayTarget.siteId} = ${sites.siteId} )`, status: sites.status }) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 53488e2b7..bb880b045 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -24,6 +24,9 @@ import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "@server/lib/alerts"; +import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -32,6 +35,7 @@ const createTargetParamsSchema = z.strictObject({ const createTargetSchema = z.strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), method: z.string().optional().nullable(), port: z.int().min(1).max(65535), enabled: z.boolean().default(true), @@ -161,6 +165,12 @@ export async function createTarget( ); } + const plainToken = generateId(48); + const encryptedToken = encrypt( + plainToken, + config.getRawConfig().server.secret! + ); + let newTarget: Target[] = []; let targetIps: string[] = []; let healthCheck: TargetHealthCheck[] = []; @@ -191,6 +201,9 @@ export async function createTarget( .values({ resourceId, ...targetData, + mode: (targetData.mode ?? + resource.mode ?? + "http") as Target["mode"], priority: targetData.priority || 100 }) .returning(); @@ -226,6 +239,10 @@ export async function createTarget( resourceId, siteId: site.siteId, ip: targetData.ip, + mode: (targetData.mode ?? + resource.mode ?? + "http") as Target["mode"], + authToken: encryptedToken, method: targetData.method, port: targetData.port, internalPort, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 47e9cdea5..1b2eb0ed5 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -34,6 +34,7 @@ function queryTargets(resourceId: number) { .select({ targetId: targets.targetId, ip: targets.ip, + mode: targets.mode, method: targets.method, port: targets.port, enabled: targets.enabled, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4b667d086..a5bb5fef3 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -27,6 +27,10 @@ const updateTargetBodySchema = z .strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), + mode: z + .enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]) + .optional() + .nullable(), method: z.string().min(1).max(10).optional().nullable(), port: z.int().min(1).max(65535).optional(), enabled: z.boolean().optional(), @@ -184,6 +188,8 @@ export async function updateTarget( } const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; + const nextMode = + parsedBody.data.mode === null ? undefined : parsedBody.data.mode; let updatedTarget: any; let updatedHc: any; @@ -193,6 +199,7 @@ export async function updateTarget( .set({ siteId: parsedBody.data.siteId, ip: parsedBody.data.ip, + mode: nextMode, method: parsedBody.data.method, port: parsedBody.data.port, internalPort, diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 7289c2767..881d46b7b 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -138,11 +138,6 @@ export function ProxyResourceTargetsForm({ const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState(""); - const [bgSiteId, setBgSiteId] = useState(null); - const [bgTargetId, setBgTargetId] = useState(null); - const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { return; @@ -207,42 +202,6 @@ export function ProxyResourceTargetsForm({ }) ); - // Browser-gateway targets (edit mode only) - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource?.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - type: string; - destination: string; - destinationPort: number; - }>; - }; - }, - enabled: !!resource - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const bgt = bgTargetsResponse.targets[0]; - setBgDestination(bgt.destination); - setBgDestinationPort(String(bgt.destinationPort)); - setBgSiteId(bgt.siteId); - setBgTargetId(bgt.browserGatewayTargetId); - }, [bgTargetsResponse]); - - useEffect(() => { - if (sites.length > 0 && bgSiteId === null) { - setBgSiteId(sites[0].siteId); - } - }, [sites, bgSiteId]); - const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { @@ -603,6 +562,8 @@ export function ProxyResourceTargetsForm({ const newTarget: LocalTarget = { targetId: -Date.now(), ip: "", + mode: ((resource?.mode as LocalTarget["mode"]) ?? + (isHttp ? "http" : "tcp")) as LocalTarget["mode"], method: isHttp ? "http" : null, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx index ee564156a..d2ed89601 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -31,21 +31,10 @@ import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { - browserGatewayTargetId: number; + targetId: number; siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); - export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; }) { @@ -61,7 +50,7 @@ export default function SshSettingsPage(props: { - (null); const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], + queryKey: ["resourceTargets", resource.resourceId, orgId, "rdp"], queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); + const res = await api.get(`/resource/${resource.resourceId}/targets`); return res.data.data as { targets: Array<{ - browserGatewayTargetId: number; + targetId: number; resourceId: number; siteId: number; siteName?: string; - type: string; - destination: string; - destinationPort: number; + mode: string | null; + ip: string; + port: number; }>; }; } @@ -122,14 +109,17 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; + const targets = bgTargetsResponse.targets.filter( + (t) => t.mode === "rdp" + ); + if (!targets.length) return; const first = targets[0]; - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); + setBgDestination(first.ip); + setBgDestinationPort(String(first.port)); setExistingTargets( targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, + targetId: t.targetId, siteId: t.siteId })) ); @@ -159,9 +149,7 @@ function SshServerForm({ ); await Promise.all( toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + api.delete(`/target/${t.targetId}`) ) ); @@ -171,12 +159,13 @@ function SshServerForm({ await Promise.all( toUpdate.map((t) => api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + `/target/${t.targetId}`, { - type: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId + mode: "rdp", + ip: bgDestination, + port: Number(bgDestinationPort), + siteId: t.siteId, + hcEnabled: false } ) ) @@ -188,20 +177,20 @@ function SshServerForm({ const created = await Promise.all( toCreate.map((s) => api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: s.siteId, - type: "rdp", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "rdp", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ) ) ); const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, + targetId: res.data.data.targetId, siteId: toCreate[i].siteId })); setExistingTargets([...toUpdate, ...newTargets]); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index c769d28e0..7a85be60c 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -54,8 +54,9 @@ import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { - browserGatewayTargetId: number; + targetId: number; siteId: number; + authToken?: string | null; }; const sshFormSchema = z.object({ @@ -154,20 +155,19 @@ function SshServerForm({ const [nativeSiteOpen, setNativeSiteOpen] = useState(false); const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], + queryKey: ["resourceTargets", resource.resourceId, orgId, "ssh"], queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); + const res = await api.get(`/resource/${resource.resourceId}/targets`); return res.data.data as { targets: Array<{ - browserGatewayTargetId: number; + targetId: number; resourceId: number; siteId: number; siteName?: string; - type: string; - destination: string; - destinationPort: number; + mode: string | null; + ip: string; + port: number; + authToken?: string | null; }>; }; } @@ -175,7 +175,10 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; + const targets = bgTargetsResponse.targets.filter( + (t) => t.mode === "ssh" + ); + if (!targets.length) return; const first = targets[0]; if (isNativeInitially) { setSelectedNativeSite({ @@ -184,16 +187,18 @@ function SshServerForm({ type: "newt" as const }); setNativeExistingTarget({ - browserGatewayTargetId: first.browserGatewayTargetId, - siteId: first.siteId + targetId: first.targetId, + siteId: first.siteId, + authToken: first.authToken }); } else { - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); + setBgDestination(first.ip); + setBgDestinationPort(String(first.port)); setExistingTargets( targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, - siteId: t.siteId + targetId: t.targetId, + siteId: t.siteId, + authToken: t.authToken })) ); setSelectedSites( @@ -236,28 +241,31 @@ function SshServerForm({ if (selectedNativeSite) { if (nativeExistingTarget) { await api.post( - `/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`, + `/target/${nativeExistingTarget.targetId}`, { - type: "ssh", - destination: "localhost", - destinationPort: 22, - siteId: selectedNativeSite.siteId + mode: "ssh", + ip: "localhost", + port: 22, + siteId: selectedNativeSite.siteId, + authToken: nativeExistingTarget.authToken, + hcEnabled: false } ); } else { const res = await api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: selectedNativeSite.siteId, - type: "ssh", - destination: "localhost", - destinationPort: 22 + mode: "ssh", + ip: "localhost", + port: 22, + hcEnabled: false } ); setNativeExistingTarget({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: selectedNativeSite.siteId + targetId: res.data.data.targetId, + siteId: selectedNativeSite.siteId, + authToken: res.data.data.authToken }); } } @@ -275,9 +283,7 @@ function SshServerForm({ ); await Promise.all( toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + api.delete(`/target/${t.targetId}`) ) ); @@ -287,12 +293,14 @@ function SshServerForm({ await Promise.all( toUpdate.map((t) => api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + `/target/${t.targetId}`, { - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId + mode: "ssh", + ip: bgDestination, + port: Number(bgDestinationPort), + siteId: t.siteId, + authToken: t.authToken, + hcEnabled: false } ) ) @@ -304,12 +312,13 @@ function SshServerForm({ const created = await Promise.all( toCreate.map((s) => api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: s.siteId, - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "ssh", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ) ) @@ -317,9 +326,9 @@ function SshServerForm({ const newTargets: ExistingTarget[] = created.map( (res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, - siteId: toCreate[i].siteId + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId, + authToken: res.data.data.authToken }) ); setExistingTargets([...toUpdate, ...newTargets]); diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 51efd0311..ecac78b6f 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -29,21 +29,10 @@ import { GetResourceResponse } from "@server/routers/resource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { - browserGatewayTargetId: number; + targetId: number; siteId: number; }; -const sshFormSchema = z.object({ - authDaemonPort: z.string().refine( - (val) => { - if (!val) return true; - const n = Number(val); - return Number.isInteger(n) && n >= 1 && n <= 65535; - }, - { message: "Port must be between 1 and 65535" } - ) -}); - export default function SshSettingsPage(props: { params: Promise<{ orgId: string }>; }) { @@ -59,7 +48,7 @@ export default function SshSettingsPage(props: { - (null); const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource.resourceId, orgId], + queryKey: ["resourceTargets", resource.resourceId, orgId, "vnc"], queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets` - ); + const res = await api.get(`/resource/${resource.resourceId}/targets`); return res.data.data as { targets: Array<{ - browserGatewayTargetId: number; + targetId: number; resourceId: number; siteId: number; siteName?: string; - type: string; - destination: string; - destinationPort: number; + mode: string | null; + ip: string; + port: number; }>; }; } @@ -120,14 +107,17 @@ function SshServerForm({ useEffect(() => { if (!bgTargetsResponse?.targets?.length) return; - const targets = bgTargetsResponse.targets; + const targets = bgTargetsResponse.targets.filter( + (t) => t.mode === "vnc" + ); + if (!targets.length) return; const first = targets[0]; - setBgDestination(first.destination); - setBgDestinationPort(String(first.destinationPort)); + setBgDestination(first.ip); + setBgDestinationPort(String(first.port)); setExistingTargets( targets.map((t) => ({ - browserGatewayTargetId: t.browserGatewayTargetId, + targetId: t.targetId, siteId: t.siteId })) ); @@ -157,9 +147,7 @@ function SshServerForm({ ); await Promise.all( toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` - ) + api.delete(`/target/${t.targetId}`) ) ); @@ -169,12 +157,13 @@ function SshServerForm({ await Promise.all( toUpdate.map((t) => api.post( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`, + `/target/${t.targetId}`, { - type: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort), - siteId: t.siteId + mode: "vnc", + ip: bgDestination, + port: Number(bgDestinationPort), + siteId: t.siteId, + hcEnabled: false } ) ) @@ -186,20 +175,20 @@ function SshServerForm({ const created = await Promise.all( toCreate.map((s) => api.put( - `/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`, + `/resource/${resource.resourceId}/target`, { siteId: s.siteId, - type: "vnc", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "vnc", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ) ) ); const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - res.data.data.browserGatewayTargetId, + targetId: res.data.data.targetId, siteId: toCreate[i].siteId })); setExistingTargets([...toUpdate, ...newTargets]); diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 407196769..f3715cba0 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -498,12 +498,13 @@ export default function Page() { if (isNative) { if (nativeSelectedSite) { await api.put( - `/org/${orgId}/resource/${id}/browser-gateway-target`, + `/resource/${id}/target`, { siteId: nativeSelectedSite.siteId, - type: "ssh", - destination: "localhost", - destinationPort: 22 + mode: "ssh", + ip: "localhost", + port: 22, + hcEnabled: false } ); } @@ -516,12 +517,13 @@ export default function Page() { : []; for (const site of sitesToCreate) { await api.put( - `/org/${orgId}/resource/${id}/browser-gateway-target`, + `/resource/${id}/target`, { siteId: site.siteId, - type: "ssh", - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: "ssh", + ip: bgDestination, + port: Number(bgDestinationPort), + hcEnabled: false } ); } @@ -533,12 +535,14 @@ export default function Page() { } else if (resourceType === "rdp" || resourceType === "vnc") { for (const site of bgSelectedSites) { await api.put( - `/org/${orgId}/resource/${id}/browser-gateway-target`, + `/resource/${id}/target`, { siteId: site.siteId, - type: resourceType, - destination: bgDestination, - destinationPort: Number(bgDestinationPort) + mode: resourceType, + ip: bgDestination, + port: Number(bgDestinationPort), + authToken: null, + hcEnabled: false } ); } From 7b7ff51289043aba69a3be33060baeb260697cf5 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 15:37:21 -0700 Subject: [PATCH 15/28] Add target mode and auth token --- server/lib/blueprints/proxyResources.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 6c37d17b8..0fb3861e6 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -48,6 +48,9 @@ import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { build } from "@server/build"; +import { encrypt } from "@server/lib/crypto"; +import { generateId } from "@server/auth/sessions/app"; +import serverConfig from "@server/lib/config"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -80,7 +83,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, type: sites.type }) .from(sites) .where( and( @@ -92,7 +95,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, type: sites.type }) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) @@ -119,6 +122,15 @@ export async function updateProxyResources( internalPortToCreate = targetData["internal-port"]; } + let authToken: string | undefined; + if (site.type !== "local") { + const plainToken = generateId(48); + authToken = encrypt( + plainToken, + serverConfig.getRawConfig().server.secret! + ); + } + // Create target const [newTarget] = await trx .insert(targets) @@ -126,10 +138,12 @@ export async function updateProxyResources( resourceId: resourceId, siteId: site.siteId, ip: targetData.hostname, + mode: resourceData.mode as Target["mode"], method: targetData.method, port: targetData.port, enabled: targetData.enabled, internalPort: internalPortToCreate, + authToken: authToken, path: targetData.path, pathMatchType: targetData["path-match"], rewritePath: @@ -707,7 +721,8 @@ export async function updateProxyResources( ? "/" : undefined), rewritePathType: targetData["rewrite-match"], - priority: targetData.priority + priority: targetData.priority, + mode: resourceData.mode }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); From 69bd61c308663473cbb526159f4725db3c2bf3b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 16:02:28 -0700 Subject: [PATCH 16/28] Update migrations --- server/setup/scriptsPg/1.19.0.ts | 22 ++++------------------ server/setup/scriptsSqlite/1.19.0.ts | 26 ++++++++++---------------- 2 files changed, 14 insertions(+), 34 deletions(-) diff --git a/server/setup/scriptsPg/1.19.0.ts b/server/setup/scriptsPg/1.19.0.ts index f8685e80b..d5b52edb1 100644 --- a/server/setup/scriptsPg/1.19.0.ts +++ b/server/setup/scriptsPg/1.19.0.ts @@ -39,18 +39,6 @@ export default async function migration() { try { await db.execute(sql`BEGIN`); - await db.execute(sql` - CREATE TABLE "browserGatewayTarget" ( - "browserGatewayTargetId" serial PRIMARY KEY NOT NULL, - "resourceId" integer NOT NULL, - "siteId" integer NOT NULL, - "authToken" varchar NOT NULL, - "type" varchar NOT NULL, - "destination" varchar NOT NULL, - "destinationPort" integer NOT NULL - ); - `); - await db.execute(sql` CREATE TABLE "clientLabels" ( "clientLabelId" serial PRIMARY KEY NOT NULL, @@ -215,12 +203,6 @@ export default async function migration() { await db.execute( sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;` ); - await db.execute( - sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;` - ); - await db.execute( - sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;` - ); await db.execute( sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); @@ -289,6 +271,10 @@ export default async function migration() { ); await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`); await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`); + await db.execute( + sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;` + ); + await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`); await db.execute(sql`COMMIT`); console.log("Migrated database"); diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index 9ea84261b..4540ed4b8 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -40,22 +40,6 @@ export default async function migration() { try { db.transaction(() => { - db.prepare( - ` - CREATE TABLE 'browserGatewayTarget' ( - 'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, - 'resourceId' integer NOT NULL, - 'siteId' integer NOT NULL, - 'authToken' text NOT NULL, - 'type' text NOT NULL, - 'destination' text NOT NULL, - 'destinationPort' integer NOT NULL, - FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade, - FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade - ); - ` - ).run(); - db.prepare( ` CREATE TABLE 'clientLabels' ( @@ -350,6 +334,16 @@ export default async function migration() { ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id); ` ).run(); + db.prepare( + ` + ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'targets' ADD 'authToken' text; + ` + ).run(); })(); const existingResources = db From d1af7a153fa6dd72e8fb4fd93a994c614b7b246f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 16:57:53 -0700 Subject: [PATCH 17/28] Enforece some more things on the types --- server/lib/blueprints/proxyResources.ts | 7 + server/lib/blueprints/types.ts | 74 ++++++++++- .../resources/public/[niceId]/ssh/page.tsx | 121 +++++++++--------- 3 files changed, 137 insertions(+), 65 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 0fb3861e6..b17878974 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -579,6 +579,13 @@ export async function updateProxyResources( ? (resourceData["proxy-protocol-version"] ?? 1) : 1, + pamMode: + resourceData["auth-daemon"]?.pam || + "passthrough", + authDaemonMode: + resourceData["auth-daemon"]?.mode || "native", + authDaemonPort: + resourceData["auth-daemon"]?.port || 22123, resourcePolicyId: null, defaultResourcePolicyId: inlinePolicyId }) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index fc73d83a0..a98843a99 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -268,8 +268,37 @@ export const PublicResourceSchema = z return true; } - // If protocol/mode is http, it must have a full-domain - if ((resource.mode ?? resource.protocol) === "http") { + const effectiveProtocol = resource.mode ?? resource.protocol; + if (effectiveProtocol !== "ssh") { + return true; + } + + const authDaemonMode = resource["auth-daemon"]?.mode; + if (authDaemonMode !== "native" && authDaemonMode !== "site") { + return true; + } + + return ( + resource.targets.filter((target) => target != null).length <= 1 + ); + }, + { + path: ["targets"], + error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource)) { + return true; + } + + // If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain + const effectiveProtocol = resource.mode ?? resource.protocol; + if ( + effectiveProtocol !== undefined && + ["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol) + ) { return ( resource["full-domain"] !== undefined && resource["full-domain"].length > 0 @@ -279,7 +308,7 @@ export const PublicResourceSchema = z }, { path: ["full-domain"], - error: "When protocol is 'http', a 'full-domain' must be provided" + error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided" } ) .refine( @@ -506,7 +535,44 @@ export const PrivateResourceSchema = z { message: "Destination must be a valid CIDR notation for cidr mode" } - ); + ) + .refine( + (data) => { + if (data.mode !== "ssh") { + return true; + } + + const authDaemonMode = data["auth-daemon"]?.mode; + if (authDaemonMode !== "native" && authDaemonMode !== "site") { + return true; + } + + const uniqueSites = new Set(); + if (data.site) { + uniqueSites.add(data.site); + } + for (const site of data.sites) { + uniqueSites.add(site); + } + + return uniqueSites.size <= 1; + }, + { + path: ["sites"], + message: + "When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed" + } + ) + .transform((data) => { + if ( + data.mode === "ssh" && + data.destination !== undefined && + data["destination-port"] === undefined + ) { + data["destination-port"] = 22; + } + return data; + }); export const ResourcePolicyRuleSchema = RuleSchema; diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index 0187a6c62..c6487ad68 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -205,7 +205,8 @@ function SshServerForm({ ? [] : targets.map((target) => ({ targetId: target.targetId, - siteId: target.siteId + siteId: target.siteId, + authToken: target.authToken })) ); @@ -253,7 +254,8 @@ function SshServerForm({ }); if (isNative) { - if (values.selectedNativeSite) { + const nativeSite = values.selectedNativeSite; + if (nativeSite) { if (nativeExistingTarget) { await api.post( `/target/${nativeExistingTarget.targetId}`, @@ -261,16 +263,20 @@ function SshServerForm({ mode: "ssh", ip: "localhost", port: 22, - siteId: selectedNativeSite.siteId, + siteId: nativeSite.siteId, authToken: nativeExistingTarget.authToken, hcEnabled: false } ); + setNativeExistingTarget({ + ...nativeExistingTarget, + siteId: nativeSite.siteId + }); } else { const res = await api.put( `/resource/${resource.resourceId}/target`, { - siteId: selectedNativeSite.siteId, + siteId: nativeSite.siteId, mode: "ssh", ip: "localhost", port: 22, @@ -279,7 +285,7 @@ function SshServerForm({ ); setNativeExistingTarget({ targetId: res.data.data.targetId, - siteId: selectedNativeSite.siteId, + siteId: nativeSite.siteId, authToken: res.data.data.authToken }); } @@ -304,71 +310,64 @@ function SshServerForm({ (t) => !selectedSiteIds.has(t.siteId) ); await Promise.all( - toDelete.map((t) => - api.delete( - `/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}` ->>>>>>> 8ee520dbb58f6bd4009581c79322f77b17ff6757 - ) + toDelete.map((t) => api.delete(`/target/${t.targetId}`)) + ); + + const toUpdate = existingTargets.filter((t) => + selectedSiteIds.has(t.siteId) + ); + await Promise.all( + toUpdate.map((t) => + api.post(`/target/${t.targetId}`, { + mode: "ssh", + ip: values.destination, + port: Number(values.destinationPort), + siteId: t.siteId, + authToken: t.authToken, + hcEnabled: false + }) ) ); -<<<<<<< HEAD - const toUpdate = existingTargets.filter((t) => - selectedSiteIds.has(t.siteId) - ); - await Promise.all( - toUpdate.map((t) => - api.post( - `/target/${t.targetId}`, - { - mode: "ssh", - ip: bgDestination, - api.delete(`/target/${t.targetId}`) - } - ) + const toCreate = activeSites.filter( + (s) => !existingSiteIds.has(s.siteId) ); - -<<<<<<< HEAD - const toCreate = selectedSites.filter( - (s) => !existingSiteIds.has(s.siteId) - ); - `/target/${t.targetId}`, - toCreate.map((s) => - mode: "ssh", - ip: values.destination, - port: Number(values.destinationPort), - siteId: t.siteId, - authToken: t.authToken, - hcEnabled: false - port: Number(bgDestinationPort), - hcEnabled: false - ) - ); - + const created = await Promise.all( + toCreate.map((s) => + api.put(`/resource/${resource.resourceId}/target`, { + siteId: s.siteId, + mode: "ssh", + ip: values.destination, + port: Number(values.destinationPort), + hcEnabled: false + }) ) ); -<<<<<<< HEAD - const newTargets: ExistingTarget[] = created.map( - (res, i) => ({ - `/resource/${resource.resourceId}/target`, - siteId: toCreate[i].siteId, - authToken: res.data.data.authToken - mode: "ssh", - ip: values.destination, - port: Number(values.destinationPort), - hcEnabled: false const newTargets: ExistingTarget[] = created.map((res, i) => ({ - browserGatewayTargetId: - ) - ); + targetId: res.data.data.targetId, + siteId: toCreate[i].siteId, + authToken: res.data.data.authToken + })); + setExistingTargets([...toUpdate, ...newTargets]); + } + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") }); - const newTargets: ExistingTarget[] = created.map((res, i) => ({ - targetId: res.data.data.targetId, - siteId: toCreate[i].siteId, - authToken: res.data.data.authToken - })); - setExistingTargets([...toUpdate, ...newTargets]); + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } } const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [ From dd8bcbb3e3c8df81b662b706a4a0f89d79f94751 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 5 Jun 2026 21:04:03 -0700 Subject: [PATCH 18/28] first pass restyle of auth methods and rules --- messages/en-US.json | 68 +- .../public/[niceId]/authentication/page.tsx | 32 +- .../CreatePolicyAuthMethodsSectionForm.tsx | 530 ----- .../resource-policy/CreatePolicyForm.tsx | 32 +- .../CreatePolicyOtpEmailSectionForm.tsx | 213 -- .../CreatePolicyRulesSectionForm.tsx | 826 ++----- .../CreatePolicyUserRolesSectionForm.tsx | 257 --- .../EditPolicyAuthMethodsSectionForm.tsx | 671 ------ .../resource-policy/EditPolicyForm.tsx | 84 +- .../EditPolicyNameSectionForm.tsx | 2 +- .../EditPolicyOtpEmailSectionForm.tsx | 294 --- .../EditPolicyRulesSectionForm.tsx | 1649 -------------- .../EditPolicyUserRolesSectionForm.tsx | 530 ----- .../PolicyAccessRulesIntro.tsx | 29 + .../PolicyAccessRulesSection.tsx | 1095 ++++++++++ .../PolicyAuthMethodCredenzas.tsx | 467 ++++ .../resource-policy/PolicyAuthMethodRow.tsx | 118 + .../resource-policy/PolicyAuthSsoSection.tsx | 140 ++ .../PolicyAuthStackSection.tsx | 38 + .../PolicyAuthStackSectionCreate.tsx | 310 +++ .../PolicyAuthStackSectionEdit.tsx | 694 ++++++ .../ResourcePolicySubForms.tsx | 1918 ----------------- src/components/resource-policy/index.ts | 21 +- .../policy-access-rule-utils.ts | 29 + .../policy-access-rule-validation.ts | 237 ++ .../resource-policy/policy-auth-method-id.ts | 21 + .../resource-policy/policy-auth-summaries.ts | 45 + src/components/ui/data-table-empty-state.tsx | 6 +- 28 files changed, 3583 insertions(+), 6773 deletions(-) delete mode 100644 src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx delete mode 100644 src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx delete mode 100644 src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx delete mode 100644 src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx delete mode 100644 src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx delete mode 100644 src/components/resource-policy/EditPolicyRulesSectionForm.tsx delete mode 100644 src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx create mode 100644 src/components/resource-policy/PolicyAccessRulesIntro.tsx create mode 100644 src/components/resource-policy/PolicyAccessRulesSection.tsx create mode 100644 src/components/resource-policy/PolicyAuthMethodCredenzas.tsx create mode 100644 src/components/resource-policy/PolicyAuthMethodRow.tsx create mode 100644 src/components/resource-policy/PolicyAuthSsoSection.tsx create mode 100644 src/components/resource-policy/PolicyAuthStackSection.tsx create mode 100644 src/components/resource-policy/PolicyAuthStackSectionCreate.tsx create mode 100644 src/components/resource-policy/PolicyAuthStackSectionEdit.tsx delete mode 100644 src/components/resource-policy/ResourcePolicySubForms.tsx create mode 100644 src/components/resource-policy/policy-access-rule-utils.ts create mode 100644 src/components/resource-policy/policy-access-rule-validation.ts create mode 100644 src/components/resource-policy/policy-auth-method-id.ts create mode 100644 src/components/resource-policy/policy-auth-summaries.ts diff --git a/messages/en-US.json b/messages/en-US.json index ea4d1fc89..428d0a2d4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -756,11 +756,11 @@ "rulesErrorDuplicate": "Duplicate rule", "rulesErrorDuplicateDescription": "A rule with these settings already exists", "rulesErrorInvalidIpAddressRange": "Invalid CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value", - "rulesErrorInvalidUrl": "Invalid URL path", - "rulesErrorInvalidUrlDescription": "Please enter a valid URL path value", - "rulesErrorInvalidIpAddress": "Invalid IP", - "rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address", + "rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Invalid path", + "rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).", + "rulesErrorInvalidIpAddress": "Invalid IP address", + "rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.", "rulesErrorUpdate": "Failed to update rules", "rulesErrorUpdateDescription": "An error occurred while updating rules", "rulesUpdated": "Enable Rules", @@ -768,10 +768,17 @@ "rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)", "rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)", "rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)", - "rulesErrorInvalidPriority": "Invalid Priority", - "rulesErrorInvalidPriorityDescription": "Please enter a valid priority", - "rulesErrorDuplicatePriority": "Duplicate Priorities", - "rulesErrorDuplicatePriorityDescription": "Please enter unique priorities", + "rulesErrorInvalidPriority": "Invalid priority", + "rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.", + "rulesErrorDuplicatePriority": "Duplicate priorities", + "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", + "rulesErrorValidation": "Invalid rules", + "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", + "rulesErrorValueRequired": "Enter a value for this rule.", + "rulesErrorInvalidCountry": "Invalid country", + "rulesErrorInvalidCountryDescription": "Select a valid country.", + "rulesErrorInvalidAsn": "Invalid ASN", + "rulesErrorInvalidAsnDescription": "Enter a valid ASN (e.g., AS15169).", "ruleUpdated": "Rules updated", "ruleUpdatedDescription": "Rules updated successfully", "ruleErrorUpdate": "Operation failed", @@ -795,7 +802,7 @@ "rulesResource": "Resource Rules Configuration", "rulesResourceDescription": "Configure rules to control access to the resource", "ruleSubmit": "Add Rule", - "rulesNoOne": "No rules. Add a rule using the form.", + "rulesNoOne": "No rules yet.", "rulesOrder": "Rules are evaluated by priority in ascending order.", "rulesSubmit": "Save Rules", "policyErrorCreate": "Error creating policy", @@ -806,7 +813,44 @@ "policyErrorUpdateMessageDescription": "An unexpected error occurred", "policyCreatedSuccess": "Resource policy succesfully created", "policyUpdatedSuccess": "Resource policy succesfully updated", - "authMethodsSave": "Save auth methods", + "authMethodsSave": "Save Settings", + "policyAuthStackTitle": "Authentication", + "policyAuthStackDescription": "Control which authentication methods are required to access this resource", + "policyAuthOrLogicTitle": "Multiple authentication methods active", + "policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.", + "policyAuthMethodActive": "Active", + "policyAuthMethodOff": "Off", + "policyAuthSsoTitle": "Platform SSO", + "policyAuthSsoDescription": "Require sign-in through your organization's identity provider", + "policyAuthSsoSummary": "{idp} · {users} users, {roles} roles", + "policyAuthSsoDefaultIdp": "Default provider", + "policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider", + "policyAuthOtherMethodsTitle": "Other Methods", + "policyAuthOtherMethodsDescription": "Optional methods visitors can use instead of or alongside platform SSO", + "policyAuthPasscodeTitle": "Passcode", + "policyAuthPasscodeDescription": "Require a shared alphanumeric passcode to access the resource", + "policyAuthPasscodeSummary": "Passcode set", + "policyAuthPincodeTitle": "PIN Code", + "policyAuthPincodeDescription": "A short numeric code required to access the resource", + "policyAuthPincodeSummary": "6-digit PIN set", + "policyAuthEmailTitle": "Email Whitelist", + "policyAuthEmailDescription": "Allow listed email addresses with one-time passwords", + "policyAuthEmailSummary": "{count} addresses allowed", + "policyAuthEmailOtpCallout": "Enabling email whitelist sends a one-time password to the visitor's email on login.", + "policyAuthHeaderAuthTitle": "Basic Header Auth", + "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", + "policyAuthHeaderAuthSummary": "Header configured", + "policyAuthHeaderName": "Header name", + "policyAuthHeaderValue": "Expected value", + "policyAccessRulesTitle": "Access Rules", + "policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.", + "policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.", + "policyAccessRulesHowItWorks": "Rules match requests by path, IP address, location, or other criteria. Each rule applies an action: bypass authentication, block access, or pass to authentication. If no rule matches, traffic continues to authentication.", + "policyAccessRulesFallthroughOff": "When rules are disabled, all traffic passes through to authentication.", + "policyAccessRulesFallthroughOn": "When no rule matches, traffic passes through to authentication.", + "rulesPlaceholderCidr": "10.0.0.0/8", + "rulesPlaceholderPath": "/admin/*", + "rulesPlaceholderGeo": "RU, KP", "rulesSave": "Save Rules", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", @@ -3045,7 +3089,7 @@ "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Default Identity Provider", - "defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.", + "defaultIdentityProviderDescription": "The user will be automatically redirected to this identity provider for authentication.", "editInternalResourceDialogNetworkSettings": "Network Settings", "editInternalResourceDialogAccessPolicy": "Access Policy", "editInternalResourceDialogAddRoles": "Add Roles", diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx index 5c02e8c8f..81a7db9ca 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -1,6 +1,5 @@ "use client"; -import ActionBanner from "@app/components/ActionBanner"; import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import { SettingsContainer, @@ -45,9 +44,8 @@ import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm"; import { Binary, Bot, InfoIcon, Key } from "lucide-react"; -import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; +import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useForm, useWatch } from "react-hook-form"; @@ -184,10 +182,6 @@ export default function ResourceAuthenticationPage() { return <>; } - console.log({ - shared: policies.sharedPolicy - }); - return ( <> @@ -314,30 +308,6 @@ export default function ResourceAuthenticationPage() { policy={policies.sharedPolicy} key={policies.sharedPolicy.resourcePolicyId} > - - } - description={t( - "resourcePolicySharedDescription" - )} - actions={ - - } - /> diff --git a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx deleted file mode 100644 index c4d17e77f..000000000 --- a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx +++ /dev/null @@ -1,530 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import z from "zod"; - -import { createPolicySchema, type PolicyFormValues } from "."; - -import { SwitchInput } from "@app/components/SwitchInput"; -import { Button } from "@app/components/ui/button"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { cn } from "@app/lib/cn"; -import { Binary, Bot, Key, Plus } from "lucide-react"; - -import { useEffect, useState } from "react"; -import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; - -// ─── CreatePolicyAuthMethodsSectionForm ─────────────────────────────────────── - -const setPasswordSchema = z.object({ - password: z.string().min(4).max(100) -}); - -const setPincodeSchema = z.object({ - pincode: z.string().length(6) -}); - -const setHeaderAuthSchema = z.object({ - user: z.string().min(4).max(100), - password: z.string().min(4).max(100), - extendedCompatibility: z.boolean() -}); - -export type CreatePolicyAuthMethodsSectionFormProps = { - form: UseFormReturn; -}; - -export function CreatePolicyAuthMethodsSectionForm({ - form: parentForm -}: CreatePolicyAuthMethodsSectionFormProps) { - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - password: true, - pincode: true, - headerAuth: true - }) - ), - defaultValues: { - password: null, - pincode: null, - headerAuth: null - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue("password", values.password as any); - parentForm.setValue("pincode", values.pincode as any); - parentForm.setValue("headerAuth", values.headerAuth as any); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const password = useWatch({ - control: form.control, - name: "password" - }); - const pincode = useWatch({ - control: form.control, - name: "pincode" - }); - const headerAuth = useWatch({ - control: form.control, - name: "headerAuth" - }); - - const passwordForm = useForm({ - resolver: zodResolver(setPasswordSchema), - defaultValues: { password: "" } - }); - - const pincodeForm = useForm({ - resolver: zodResolver(setPincodeSchema), - defaultValues: { pincode: "" } - }); - - const headerAuthForm = useForm({ - resolver: zodResolver(setHeaderAuthSchema), - defaultValues: { user: "", password: "", extendedCompatibility: true } - }); - - if (!isExpanded) { - return ( - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - - - ); - } - - return ( - <> - {/* Password Credenza */} - { - setIsSetPasswordOpen(val); - if (!val) passwordForm.reset(); - }} - > - - - - {t("resourcePasswordSetupTitle")} - - - {t("resourcePasswordSetupTitleDescription")} - - - -
- { - form.setValue("password", data); - setIsSetPasswordOpen(false); - passwordForm.reset(); - })} - className="space-y-4" - id="set-password-form" - > - ( - - - {t("password")} - - - - - - - )} - /> - - -
- - - - - - -
-
- - {/* Pincode Credenza */} - { - setIsSetPincodeOpen(val); - if (!val) pincodeForm.reset(); - }} - > - - - - {t("resourcePincodeSetupTitle")} - - - {t("resourcePincodeSetupTitleDescription")} - - - -
- { - form.setValue("pincode", data); - setIsSetPincodeOpen(false); - pincodeForm.reset(); - })} - className="space-y-4" - id="set-pincode-form" - > - ( - - - {t("resourcePincode")} - - -
- - - - - - - - - - -
-
- -
- )} - /> - - -
- - - - - - -
-
- - {/* Header Auth Credenza */} - { - setIsSetHeaderAuthOpen(val); - if (!val) headerAuthForm.reset(); - }} - > - - - - {t("resourceHeaderAuthSetupTitle")} - - - {t("resourceHeaderAuthSetupTitleDescription")} - - - -
- { - form.setValue("headerAuth", data); - setIsSetHeaderAuthOpen(false); - headerAuthForm.reset(); - } - )} - className="space-y-4" - id="set-header-auth-form" - > - ( - - {t("user")} - - - - - - )} - /> - ( - - - {t("password")} - - - - - - - )} - /> - ( - - - - - - - )} - /> - - -
- - - - - - -
-
- - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - {/* Password row */} -
-
- - - {t("resourcePasswordProtection", { - status: password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header auth row */} -
-
- - - {headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - ); -} diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index c422a876a..17bc8b634 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -19,7 +19,11 @@ import { build } from "@server/build"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { type PolicyFormValues, createPolicySchema } from "."; +import { + type PolicyFormValues, + createPolicySchema, + createPolicySchemaWithI18n +} from "."; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgs, type ResourcePolicy } from "@server/db"; @@ -37,10 +41,8 @@ import { import { Input } from "@app/components/ui/input"; import { useMemo, useTransition } from "react"; import { useForm } from "react-hook-form"; -import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm"; -import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm"; -import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm"; -import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; +import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; +import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; @@ -78,8 +80,13 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { }) ); + const policySchema = useMemo( + () => createPolicySchemaWithI18n(t, createPolicySchema), + [t] + ); + const form = useForm({ - resolver: zodResolver(createPolicySchema) as any, + resolver: zodResolver(policySchema) as any, defaultValues: { name: "", sso: true, @@ -245,18 +252,17 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { - - - - ; - emailEnabled: boolean; -}; - -export function CreatePolicyOtpEmailSectionForm({ - form: parentForm, - emailEnabled -}: CreatePolicyOtpEmailSectionFormProps) { - const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - emailWhitelistEnabled: true, - emails: true - }) - ), - defaultValues: { - emailWhitelistEnabled: false, - emails: [] - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue( - "emailWhitelistEnabled", - values.emailWhitelistEnabled as boolean - ); - parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const whitelistEnabled = useWatch({ - control: form.control, - name: "emailWhitelistEnabled" - }); - - if (!isExpanded) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - - - ); - } - - return ( -
- - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - form.setValue("emailWhitelistEnabled", val); - }} - disabled={!emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t("otpEmailEnter")} - tags={form.getValues().emails} - setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - )} - - - -
- ); -} diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx index 4550d5bfb..c78ebd37f 100644 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -11,13 +11,15 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; -import z from "zod"; - -import { createPolicySchema, type PolicyFormValues } from "."; +import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; import { toast } from "@app/hooks/useToast"; +import { + validatePolicyRulePriority, + validatePolicyRuleValue +} from "./policy-access-rule-validation"; -import { SwitchInput } from "@app/components/SwitchInput"; import { Button } from "@app/components/ui/button"; +import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; import { Command, CommandEmpty, @@ -26,15 +28,6 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { Input } from "@app/components/ui/input"; import { Popover, @@ -60,11 +53,6 @@ import { import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; import { ColumnDef, flexRender, @@ -79,14 +67,10 @@ import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; -// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── +import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; +import { createEmptyRule } from "./policy-access-rule-utils"; -const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); +// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── type LocalRule = { ruleId: number; @@ -111,19 +95,15 @@ export function CreatePolicyRulesSectionForm({ isMaxmindAsnAvailable }: CreatePolicyRulesSectionFormProps) { const t = useTranslations(); - const [isExpanded, setIsExpanded] = useState(false); const [rules, setRules] = useState([]); - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const rulesFormSchema = useMemo( + () => createPolicyRulesSectionSchema(t), + [t] + ); const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - applyRules: true, - rules: true - }) - ), + resolver: zodResolver(rulesFormSchema), defaultValues: { applyRules: false, rules: [] @@ -143,15 +123,6 @@ export function CreatePolicyRulesSectionForm({ name: "applyRules" }); - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "PATH", - value: "" - } - }); - const RuleAction = useMemo( () => ({ ACCEPT: t("alwaysAllow"), @@ -190,84 +161,11 @@ export function CreatePolicyRulesSectionForm({ [form] ); - const addRule = useCallback( - function addRule(data: z.infer) { - const isDuplicate = rules.some( - (rule) => - rule.action === data.action && - rule.match === data.match && - rule.value === data.value - ); - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicate"), - description: t("rulesErrorDuplicateDescription") - }); - return; - } - if (data.match === "CIDR" && !isValidCIDR(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t("rulesErrorInvalidIpAddressRangeDescription") - }); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: t("rulesErrorInvalidCountryDescription") || "" - }); - return; - } - - let priority = data.priority; - if (priority === undefined) { - priority = - rules.reduce( - (acc, rule) => - rule.priority > acc ? rule.priority : acc, - 0 - ) + 1; - } - - const updatedRules = [ - ...rules, - { - ...data, - ruleId: new Date().getTime(), - new: true, - priority, - enabled: true - } - ]; - setRules(updatedRules); - syncFormRules(updatedRules); - addRuleForm.reset(); - }, - [rules, t, addRuleForm, syncFormRules] - ); + const addEmptyRule = useCallback(() => { + const updatedRules = [...rules, createEmptyRule(rules)]; + setRules(updatedRules); + syncFormRules(updatedRules); + }, [rules, syncFormRules]); const removeRule = useCallback( function removeRule(ruleId: number) { @@ -291,63 +189,63 @@ export function CreatePolicyRulesSectionForm({ [rules, syncFormRules] ); - const getValueHelpText = useCallback( - function getValueHelpText(type: string) { - switch (type) { - case "CIDR": - return t("rulesMatchIpAddressRangeDescription"); - case "IP": - return t("rulesMatchIpAddress"); - case "PATH": - return t("rulesMatchUrl"); - case "COUNTRY": - return t("rulesMatchCountry"); - case "ASN": - return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; - } - }, - [t] - ); - const columns: ColumnDef[] = useMemo( () => [ { accessorKey: "priority", + size: 96, + maxSize: 96, header: ({ column }) => ( - +
+ +
), cell: ({ row }) => ( e.currentTarget.focus()} onBlur={(e) => { - const parsed = z.coerce - .number() - .int() - .optional() - .safeParse(e.target.value); - if (!parsed.success) { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { toast({ variant: "destructive", - title: t("rulesErrorInvalidPriority"), + ...validated.toast + }); + return; + } + const duplicatePriority = rules.some( + (rule) => + rule.ruleId !== row.original.ruleId && + rule.priority === validated.data + ); + if (duplicatePriority) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicatePriority"), description: t( - "rulesErrorInvalidPriorityDescription" + "rulesErrorDuplicatePriorityDescription" ) }); return; } updateRule(row.original.ruleId, { - priority: parsed.data + priority: validated.data }); }} /> @@ -355,6 +253,8 @@ export function CreatePolicyRulesSectionForm({ }, { accessorKey: "action", + size: 160, + maxSize: 160, header: () => {t("rulesAction")}, cell: ({ row }) => ( + onBlur={(e) => { + const validated = validatePolicyRuleValue( + t, + row.original.match, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } updateRule(row.original.ruleId, { - value: e.target.value - }) - } + value: validated.data + }); + }} /> ) }, @@ -589,19 +503,23 @@ export function CreatePolicyRulesSectionForm({ accessorKey: "enabled", header: () => {t("enabled")}, cell: ({ row }) => ( - - updateRule(row.original.ruleId, { enabled: val }) - } - /> +
+ + updateRule(row.original.ruleId, { + enabled: val + }) + } + /> +
) }, { id: "actions", - header: () => {t("actions")}, + header: () => null, cell: ({ row }) => ( -
+
- - - ); - } + const addRuleButton = ( + + ); return ( - {t("rulesResource")} + {t("policyAccessRulesTitle")} {t("rulesResourceDescription")} @@ -670,421 +571,128 @@ export function CreatePolicyRulesSectionForm({
-
- { - form.setValue("applyRules", val); - }} - /> -
+ { + form.setValue("applyRules", val); + }} + /> -
- -
- ( - - - {t("rulesAction")} - - - - - - - )} - /> - ( - - - {t("rulesMatchType")} - - - - - - - )} - /> - ( - - - - {addRuleForm.watch("match") === - "COUNTRY" ? ( - - - - - - - - - - {t( - "noCountryFound" - )} - - - {COUNTRIES.map( - ( - country - ) => ( - { - field.onChange( - country.code - ); - setOpenAddRuleCountrySelect( - false - ); - }} - > - - { - country.name - }{" "} - ( - { - country.code - } - - ) - - ) - )} - - - - - - ) : addRuleForm.watch( - "match" - ) === "ASN" ? ( - - - - - - - - - - No ASN - found. - Use the - custom - input - below. - - - {MAJOR_ASNS.map( - ( - asn - ) => ( - { - field.onChange( - asn.code - ); - setOpenAddRuleAsnSelect( - false - ); - }} - > - - { - asn.name - }{" "} - ( - { - asn.code - } - - ) - - ) - )} - - - -
- { - if ( - e.key === - "Enter" - ) { - const value = - e.currentTarget.value - .toUpperCase() - .replace( - /^AS/, - "" - ); - if ( - /^\d+$/.test( - value - ) - ) { - field.onChange( - "AS" + - value - ); - setOpenAddRuleAsnSelect( - false - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - )} -
- -
- )} - /> - -
-
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const isActionsColumn = - cell.column.id === "actions"; - return ( - + ); } - > - {flexRender( - cell.column.columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - - {t("rulesNoOne")} - - - )} - -
+ )} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => { + const columnId = + cell.column.id; + const isActionsColumn = + columnId === + "actions"; + const isPriorityColumn = + columnId === + "priority"; + const isActionColumn = + columnId === + "action"; + const isMatchColumn = + columnId === + "match"; + return ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + )} + + + {table.getRowModel().rows?.length > 0 && + addRuleButton} + + )}
diff --git a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx deleted file mode 100644 index 132363fc1..000000000 --- a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { createPolicySchema, type PolicyFormValues } from "."; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; - -// ─── CreatePolicyUsersRolesSectionForm ──────────────────────────────────────── - -export type CreatePolicyUsersRolesSectionFormProps = { - form: UseFormReturn; - allRoles: { id: string; text: string }[]; - allUsers: { id: string; text: string }[]; - allIdps: { id: number; text: string }[]; -}; - -export function CreatePolicyUsersRolesSectionForm({ - form: parentForm, - allRoles, - allUsers, - allIdps -}: CreatePolicyUsersRolesSectionFormProps) { - const t = useTranslations(); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - sso: true, - skipToIdpId: true, - roles: true, - users: true - }) - ), - defaultValues: { - sso: true, - skipToIdpId: null, - roles: [], - users: [] - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue("sso", values.sso as boolean); - parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null); - parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]); - parentForm.setValue("users", values.users as [Tag, ...Tag[]]); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const ssoEnabled = useWatch({ control: form.control, name: "sso" }); - const selectedIdpId = useWatch({ - control: form.control, - name: "skipToIdpId" - }); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - - return ( -
- - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - form.setValue("sso", val); - }} - /> - - {ssoEnabled && ( - <> - ( - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t("resourceRoleDescription")} - - - )} - /> - ( - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t("defaultIdentityProviderDescription")} -

-
- )} -
-
-
-
- ); -} diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx deleted file mode 100644 index 1fa241753..000000000 --- a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx +++ /dev/null @@ -1,671 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { useEnvContext } from "@app/hooks/useEnvContext"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import z from "zod"; - -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useRouter } from "next/navigation"; -import { createPolicySchema } from "."; - -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { SwitchInput } from "@app/components/SwitchInput"; -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 { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { Binary, Bot, Key, Plus } from "lucide-react"; - -import { cn } from "@app/lib/cn"; -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { useActionState, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; -import type { AxiosResponse } from "axios"; - -// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── - -const setPasswordSchema = z.object({ - password: z.string().min(4).max(100) -}); - -const setPincodeSchema = z.object({ - pincode: z.string().length(6) -}); - -const setHeaderAuthSchema = z.object({ - user: z.string().min(4).max(100), - password: z.string().min(4).max(100), - extendedCompatibility: z.boolean() -}); - -export function EditPolicyAuthMethodsSectionForm({ - readonly -}: { - readonly?: boolean; -}) { - const { policy } = useResourcePolicyContext(); - const router = useRouter(); - - const api = createApiClient(useEnvContext()); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - password: true, - pincode: true, - headerAuth: true - }) - ) - }); - - const t = useTranslations(); - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const password = form.watch("password"); - const pincode = form.watch("pincode"); - const headerAuth = form.watch("headerAuth"); - - // If explicitly removed (set to `null`) it means the value has been removed - // in the other case (`undefined` or object value), check if the value has been modified - // and fallback to the policy default value - const hasPassword = - password !== null ? Boolean(password ?? policy.passwordId) : false; - - const hasPincode = - pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false; - - const hasHeaderAuth = - headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false; - - const [isExpanded, setIsExpanded] = useState( - hasPassword || hasPincode || hasHeaderAuth - ); - - const passwordForm = useForm({ - resolver: zodResolver(setPasswordSchema), - defaultValues: { password: "" } - }); - - const pincodeForm = useForm({ - resolver: zodResolver(setPincodeSchema), - defaultValues: { pincode: "" } - }); - - const headerAuthForm = useForm({ - resolver: zodResolver(setHeaderAuthSchema), - defaultValues: { user: "", password: "", extendedCompatibility: true } - }); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - if (readonly) return; - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - - const responseArray: Array | void>> = []; - - if (typeof payload.password !== "undefined") { - responseArray.push( - api - .put>( - `/resource-policy/${policy.resourcePolicyId}/password`, - { - password: payload.password?.password ?? null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }) - ); - } - - if (typeof payload.pincode !== "undefined") { - responseArray.push( - api - .put>( - `/resource-policy/${policy.resourcePolicyId}/pincode`, - { - pincode: payload.pincode?.pincode ?? null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }) - ); - } - - if (typeof payload.headerAuth !== "undefined") { - responseArray.push( - api - .put>( - `/resource-policy/${policy.resourcePolicyId}/header-auth`, - { - headerAuth: payload.headerAuth - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }) - ); - } - - try { - const responseList = await Promise.all(responseArray); - - if (responseList.every((res) => res && res.status === 200)) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - if (!isExpanded) { - return ( - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - {!readonly ? ( - - ) : ( -
-

{t("resourcePolicyAuthMethodsEmpty")}

-
- )} -
-
- ); - } - - return ( - <> - {/* Password Credenza */} - { - setIsSetPasswordOpen(val); - if (!val) passwordForm.reset(); - }} - > - - - - {t("resourcePasswordSetupTitle")} - - - {t("resourcePasswordSetupTitleDescription")} - - - -
- { - form.setValue("password", data); - setIsSetPasswordOpen(false); - passwordForm.reset(); - })} - className="space-y-4" - id="set-password-form" - > - ( - - - {t("password")} - - - - - - - )} - /> - - -
- - - - - - -
-
- - {/* Pincode Credenza */} - { - setIsSetPincodeOpen(val); - if (!val) pincodeForm.reset(); - }} - > - - - - {t("resourcePincodeSetupTitle")} - - - {t("resourcePincodeSetupTitleDescription")} - - - -
- { - form.setValue("pincode", data); - setIsSetPincodeOpen(false); - pincodeForm.reset(); - })} - className="space-y-4" - id="set-pincode-form" - > - ( - - - {t("resourcePincode")} - - -
- - - - - - - - - - -
-
- -
- )} - /> - - -
- - - - - - -
-
- - {/* Header Auth Credenza */} - { - setIsSetHeaderAuthOpen(val); - if (!val) headerAuthForm.reset(); - }} - > - - - - {t("resourceHeaderAuthSetupTitle")} - - - {t("resourceHeaderAuthSetupTitleDescription")} - - - -
- { - form.setValue("headerAuth", data); - setIsSetHeaderAuthOpen(false); - headerAuthForm.reset(); - } - )} - className="space-y-4" - id="set-header-auth-form" - > - ( - - {t("user")} - - - - - - )} - /> - ( - - - {t("password")} - - - - - - - )} - /> - ( - - - - - - - )} - /> - - -
- - - - - - -
-
- -
- - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - {/* Password row */} -
-
- - - {t("resourcePasswordProtection", { - status: hasPassword - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: hasPincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header auth row */} -
-
- - - {hasHeaderAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
- - - - -
-
- - - ); -} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index bed4a4647..5acc6de72 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -12,17 +12,11 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { createApiClient } from "@app/lib/api"; -import { useRouter } from "next/navigation"; - import { useMemo } from "react"; -import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; -import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm"; -import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm"; -import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm"; - -// ─── EditPolicyForm ───────────────────────────────────────────────────────── +import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; +import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; export type EditPolicyFormProps = { hidePolicyNameForm?: boolean; @@ -35,19 +29,15 @@ export function EditPolicyForm({ readonly, resourceId }: EditPolicyFormProps) { - const { org } = useOrgContext(); const t = useTranslations(); + const { org } = useOrgContext(); const { env } = useEnvContext(); - const api = createApiClient({ env }); - // const [, formAction, isSubmitting] = useActionState(onSubmit, null); const { isPaidUser } = usePaidStatus(); - const router = useRouter(); - // In overlay mode (resourceId provided), policy-level sections are locked. // Rules and users/roles sections handle their own hybrid logic via resourceId. const isOverlay = resourceId !== undefined; - const policyLevelReadonly = readonly || isOverlay; + const showTabs = !hidePolicyNameForm && !isOverlay; const isMaxmindAvailable = !!( env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 @@ -81,32 +71,54 @@ export function EditPolicyForm({ return <>; } + const authSection = ( + + ); + + const rulesSection = ( + + ); + + if (showTabs) { + return ( + + + {authSection} + {rulesSection} + + ); + } + return ( - {!hidePolicyNameForm && ( - + {!hidePolicyNameForm && !isOverlay && ( + )} - + {authSection} - - - - - + {rulesSection} ); } diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx index 87c1b5cdc..5acb6fd7b 100644 --- a/src/components/resource-policy/EditPolicyNameSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -137,7 +137,7 @@ export function EditPolicyNameSectionForm({ - + ({ - id: email.whiteListId.toString(), - text: email.email - })) - } - }); - - const whitelistEnabled = useWatch({ - control: form.control, - name: "emailWhitelistEnabled" - }); - - const [isExpanded, setIsExpanded] = useState(whitelistEnabled); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - - async function onSubmit() { - if (readonly) return; - const isValid = await form.trigger(); - - if (!isValid) return; - - const payload = form.getValues(); - - try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}/whitelist`, - { - emailWhitelistEnabled: payload.emailWhitelistEnabled, - emails: payload.emails?.map((e) => e.text) ?? [] - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - if (!isExpanded) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - {!readonly ? ( - - ) : ( -
-

{t("resourcePolicyOtpEmpty")}

-
- )} -
-
- ); - } - - return ( -
- - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - form.setValue("emailWhitelistEnabled", val); - }} - disabled={readonly || !emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag) - .success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t( - "otpEmailEnter" - )} - tags={ - form.getValues() - .emails ?? [] - } - setTags={(newEmails) => { - if (!readonly) { - form.setValue( - "emails", - newEmails as [ - Tag, - ...Tag[] - ] - ); - } - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - )} - - - - - - - -
- - ); -} diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx deleted file mode 100644 index 15669272b..000000000 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ /dev/null @@ -1,1649 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import z from "zod"; - -import { toast } from "@app/hooks/useToast"; -import { createPolicySchema, type PolicyFormValues } from "."; - -import { SwitchInput } from "@app/components/SwitchInput"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; - -import { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - REGIONS, - getRegionNameById, - isValidRegionId -} from "@server/db/regions"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { - ArrowUpDown, - Check, - ChevronsUpDown, - LockIcon, - Plus -} from "lucide-react"; - -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useTransition -} from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { resourceQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import type { AxiosResponse } from "axios"; -import { useRouter } from "next/navigation"; - -// ─── PolicyRulesSection ─────────────────────────────────────────────────────── - -const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); - -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; - fromPolicy?: boolean; -}; - -type PolicyRulesSectionProps = { - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; - readonly?: boolean; - resourceId?: number; -}; - -export function EditPolicyRulesSectionForm({ - isMaxmindAvailable, - isMaxmindAsnAvailable, - readonly, - resourceId -}: PolicyRulesSectionProps) { - const t = useTranslations(); - - const { policy } = useResourcePolicyContext(); - const api = createApiClient(useEnvContext()); - const router = useRouter(); - - const isResourceOverlay = resourceId !== undefined; - - // ── Fetch resource-specific rules when in overlay mode ─────────────────── - const { data: resourceRulesData } = useQuery({ - ...resourceQueries.resourceRules({ resourceId: resourceId! }), - enabled: isResourceOverlay - }); - - const deletedResourceRuleIdsRef = useRef>(new Set()); - const [resourceRulesInitialized, setResourceRulesInitialized] = - useState(false); - - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - rules: true, - applyRules: true - }) - ), - defaultValues: { - applyRules: policy.applyRules, - rules: policy.rules - } - }); - - const rulesEnabled = useWatch({ - control: form.control, - name: "applyRules" - }); - - const [rules, setRules] = useState( - policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) - ); - const [isExpanded, setIsExpanded] = useState( - rulesEnabled || isResourceOverlay - ); - - // Initialize resource-specific rules once fetched - useEffect(() => { - if (!isResourceOverlay || resourceRulesInitialized) return; - if (!resourceRulesData) return; - - const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); - const resourceSpecific: LocalRule[] = resourceRulesData - .filter((r) => !policyRuleIds.has(r.ruleId)) - .map((r) => ({ - ruleId: r.ruleId, - action: r.action as "ACCEPT" | "DROP" | "PASS", - match: r.match, - value: r.value, - priority: r.priority, - enabled: r.enabled, - fromPolicy: false - })); - - setRules([ - ...resourceSpecific, - ...policy.rules.map((r) => ({ ...r, fromPolicy: true })) - ]); - setResourceRulesInitialized(true); - }, [ - isResourceOverlay, - resourceRulesData, - resourceRulesInitialized, - policy.rules - ]); - - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); - const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = - useState(false); - - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "PATH", - value: "" - } - }); - - const RuleAction = useMemo( - () => ({ - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - }), - [t] - ); - - const RuleMatch = useMemo( - () => ({ - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN", - REGION: t("region") - }), - [t] - ); - - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); - }, - [form] - ); - - const addRule = useCallback( - function addRule(data: z.infer) { - const isDuplicate = rules.some( - (rule) => - rule.action === data.action && - rule.match === data.match && - rule.value === data.value - ); - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicate"), - description: t("rulesErrorDuplicateDescription") - }); - return; - } - if (data.match === "CIDR" && !isValidCIDR(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t("rulesErrorInvalidIpAddressRangeDescription") - }); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: t("rulesErrorInvalidCountryDescription") || "" - }); - return; - } - if (data.match === "REGION" && !isValidRegionId(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidRegion"), - description: t("rulesErrorInvalidRegionDescription") || "" - }); - return; - } - - let priority = data.priority; - if (priority === undefined) { - priority = - rules.reduce( - (acc, rule) => - rule.priority > acc ? rule.priority : acc, - 0 - ) + 1; - } - - const updatedRules = [ - ...rules, - { - ...data, - ruleId: new Date().getTime(), - new: true, - priority, - enabled: true - } - ]; - setRules(updatedRules); - syncFormRules(updatedRules); - addRuleForm.reset(); - }, - [rules, t, addRuleForm, syncFormRules] - ); - - const removeRule = useCallback( - function removeRule(ruleId: number) { - const rule = rules.find((r) => r.ruleId === ruleId); - if (!rule || rule.fromPolicy) return; // cannot remove policy rules - // Track deletion for resource overlay mode (only for existing DB rules) - if (isResourceOverlay && !rule.new) { - deletedResourceRuleIdsRef.current.add(ruleId); - } - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules, isResourceOverlay] - ); - - const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const getValueHelpText = useCallback( - function getValueHelpText(type: string) { - switch (type) { - case "CIDR": - return t("rulesMatchIpAddressRangeDescription"); - case "IP": - return t("rulesMatchIpAddress"); - case "PATH": - return t("rulesMatchUrl"); - case "COUNTRY": - return t("rulesMatchCountry"); - case "ASN": - return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; - case "REGION": - return t("rulesMatchRegion"); - } - }, - [t] - ); - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const isLocked = row.original.fromPolicy; - if (isLocked) { - return ( - - — - - ); - } - return ( - e.currentTarget.focus()} - onBlur={(e) => { - const parsed = z.coerce - .number() - .int() - .optional() - .safeParse(e.target.value); - if (!parsed.success) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidPriority"), - description: t( - "rulesErrorInvalidPriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: parsed.data - }); - }} - /> - ); - } - }, - { - accessorKey: "action", - header: () => {t("rulesAction")}, - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "match", - header: () => ( - {t("rulesMatchType")} - ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "value", - header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( - - - - - - - - - - {t("noCountryFound")} - - - {COUNTRIES.map((country) => ( - - updateRule( - row.original.ruleId, - { - value: country.code - } - ) - } - > - - {country.name} ( - {country.code}) - - ))} - - - - - - ) : row.original.match === "ASN" ? ( - - - - - - - - - - No ASN found. Enter a custom ASN - below. - - - {MAJOR_ASNS.map((asn) => ( - - updateRule( - row.original.ruleId, - { value: asn.code } - ) - } - > - - {asn.name} ({asn.code}) - - ))} - - - -
- - asn.code === - row.original.value - ) - ? row.original.value - : "" - } - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = - e.currentTarget.value - .toUpperCase() - .replace(/^AS/, ""); - if (/^\d+$/.test(value)) { - updateRule( - row.original.ruleId, - { value: "AS" + value } - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : row.original.match === "REGION" ? ( - - - - - - - - - - {t("noRegionFound")} - - {REGIONS.map((continent) => ( - - - updateRule( - row.original.ruleId, - { - value: continent.id - } - ) - } - > - - {t(continent.name)} ( - {continent.id}) - - {continent.includes.map( - (subregion) => ( - - updateRule( - row.original - .ruleId, - { - value: subregion.id - } - ) - } - > - - {t(subregion.name)}{" "} - ({subregion.id}) - - ) - )} - - ))} - - - - - ) : ( - - updateRule(row.original.ruleId, { - value: e.target.value - }) - } - /> - ) - }, - { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( - - updateRule(row.original.ruleId, { enabled: val }) - } - /> - ) - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => ( -
- {row.original.fromPolicy ? ( - - ) : ( - - )} -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule, - readonly - ] - ); - - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - - const [isPending, startTransition] = useTransition(); - - async function saveRules() { - if (readonly) return; - - if (isResourceOverlay) { - await saveResourceOverlayRules(); - return; - } - - const isValid = await form.trigger(); - if (!isValid) return; - - const payload = { - applyRules: form.getValues("applyRules") ?? false, - rules: rules.map(({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - })) - }; - - try { - const res = await api - .put< - AxiosResponse<{}> - >(`/resource-policy/${policy.resourcePolicyId}/rules`, payload) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - async function saveResourceOverlayRules() { - try { - const newRules = rules.filter((r) => !r.fromPolicy && r.new); - const updatedRules = rules.filter( - (r) => !r.fromPolicy && !r.new && r.updated - ); - const deletedIds = [...deletedResourceRuleIdsRef.current]; - - await Promise.all([ - ...newRules.map((r) => - api.put(`/resource/${resourceId}/rule`, { - action: r.action, - match: r.match, - value: r.value, - priority: r.priority, - enabled: r.enabled - }) - ), - ...updatedRules.map((r) => - api.post(`/resource/${resourceId}/rule/${r.ruleId}`, { - action: r.action, - match: r.match, - value: r.value, - priority: r.priority, - enabled: r.enabled - }) - ), - ...deletedIds.map((id) => - api.delete(`/resource/${resourceId}/rule/${id}`) - ) - ]); - - deletedResourceRuleIdsRef.current = new Set(); - - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - } - } - - if (!isExpanded) { - return ( - - - - {t("rulesResource")} - - - {t("rulesResourcePolicyDescription")} - - - - {!readonly ? ( - - ) : ( -
-

{t("resourcePolicyRulesEmpty")}

-
- )} -
-
- ); - } - - return ( - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- { - form.setValue("applyRules", val); - }} - disabled={readonly || isResourceOverlay} - /> -
- -
- -
- ( - - - {t("rulesAction")} - - - - - - - )} - /> - ( - - - {t("rulesMatchType")} - - - - - - - )} - /> - ( - - - - {addRuleForm.watch("match") === - "COUNTRY" ? ( - - - - - - - - - - {t( - "noCountryFound" - )} - - - {COUNTRIES.map( - ( - country - ) => ( - { - field.onChange( - country.code - ); - setOpenAddRuleCountrySelect( - false - ); - }} - > - - { - country.name - }{" "} - ( - { - country.code - } - - ) - - ) - )} - - - - - - ) : addRuleForm.watch( - "match" - ) === "ASN" ? ( - - - - - - - - - - No ASN - found. - Use the - custom - input - below. - - - {MAJOR_ASNS.map( - ( - asn - ) => ( - { - field.onChange( - asn.code - ); - setOpenAddRuleAsnSelect( - false - ); - }} - > - - { - asn.name - }{" "} - ( - { - asn.code - } - - ) - - ) - )} - - - -
- { - if ( - e.key === - "Enter" - ) { - const value = - e.currentTarget.value - .toUpperCase() - .replace( - /^AS/, - "" - ); - if ( - /^\d+$/.test( - value - ) - ) { - field.onChange( - "AS" + - value - ); - setOpenAddRuleAsnSelect( - false - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : addRuleForm.watch( - "match" - ) === "REGION" ? ( - - - - - - - - - - {t( - "noRegionFound" - )} - - {REGIONS.map( - ( - continent - ) => ( - - { - field.onChange( - continent.id - ); - setOpenAddRuleRegionSelect( - false - ); - }} - > - - {t( - continent.name - )}{" "} - ( - { - continent.id - } - ) - - {continent.includes.map( - ( - subregion - ) => ( - { - field.onChange( - subregion.id - ); - setOpenAddRuleRegionSelect( - false - ); - }} - > - - {t( - subregion.name - )}{" "} - ( - { - subregion.id - } - ) - - ) - )} - - ) - )} - - - - - ) : ( - - )} -
- -
- )} - /> - -
-
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const isActionsColumn = - cell.column.id === "actions"; - return ( - - {flexRender( - cell.column.columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - - {t("rulesNoOne")} - - - )} - -
-
-
- - - -
- ); -} diff --git a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx deleted file mode 100644 index 2fa5103ba..000000000 --- a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx +++ /dev/null @@ -1,530 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { useEnvContext } from "@app/hooks/useEnvContext"; - -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { UserType } from "@server/types/UserTypes"; -import { useTranslations } from "next-intl"; - -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import type { AxiosResponse } from "axios"; -import { useRouter } from "next/navigation"; -import { createPolicySchema } from "."; - -import { - RolesSelector, - type SelectedRole -} from "@app/components/roles-selector"; -import { UsersSelector } from "@app/components/users-selector"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; - -import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; -import { resourceQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import { useActionState, useEffect, useMemo, useRef, useState } from "react"; -import { useForm, useWatch } from "react-hook-form"; - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - -type PolicyUsersRolesSectionProps = { - orgId: string; - allIdps: { id: number; text: string }[]; - readonly?: boolean; - resourceId?: number; -}; - -type OverlaySelectedRole = SelectedRole & { isAdmin: boolean }; - -export function EditPolicyUsersRolesSectionForm({ - orgId, - allIdps, - readonly, - resourceId -}: PolicyUsersRolesSectionProps) { - const t = useTranslations(); - - const router = useRouter(); - - const { policy } = useResourcePolicyContext(); - - const api = createApiClient(useEnvContext()); - - // ── Resource overlay: fetch resource-specific roles & users ────────────── - const isResourceOverlay = resourceId !== undefined; - - const { data: resourceRolesData } = useQuery({ - ...resourceQueries.resourceRoles({ resourceId: resourceId! }), - enabled: isResourceOverlay - }); - - const { data: resourceUsersData } = useQuery({ - ...resourceQueries.resourceUsers({ resourceId: resourceId! }), - enabled: isResourceOverlay - }); - - // IDs from the policy (locked — cannot be removed) - const policyRoleLockedIds = useMemo( - () => new Set(policy.roles.map((r) => r.roleId.toString())), - [policy.roles] - ); - const policyUserLockedIds = useMemo( - () => new Set(policy.users.map((u) => u.userId)), - [policy.users] - ); - - // Policy entries mapped to selector format - const policyRoleItems = useMemo( - () => - policy.roles.map((r) => ({ - id: r.roleId.toString(), - text: r.name, - isAdmin: false - })), - [policy.roles] - ); - const policyUserItems = useMemo( - () => - policy.users.map((u) => ({ - id: u.userId, - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })), - [policy.users] - ); - - // Track the initial resource-specific roles/users for diffing on save - const initialResourceRoleIdsRef = useRef>(new Set()); - const initialResourceUserIdsRef = useRef>(new Set()); - - // Combined selected roles/users (policy + resource-specific) - const [combinedRoles, setCombinedRoles] = - useState(policyRoleItems); - const [combinedUsers, setCombinedUsers] = useState(policyUserItems); - const [resourceRolesInitialized, setResourceRolesInitialized] = - useState(false); - const [resourceUsersInitialized, setResourceUsersInitialized] = - useState(false); - - useEffect(() => { - if (!isResourceOverlay || resourceRolesInitialized) return; - if (!resourceRolesData) return; - - const resourceSpecific = resourceRolesData - .filter((r) => !policyRoleLockedIds.has(r.roleId.toString())) - .map((r) => ({ - id: r.roleId.toString(), - text: r.name, - isAdmin: Boolean(r.isAdmin) - })); - - initialResourceRoleIdsRef.current = new Set( - resourceSpecific.map((r) => r.id) - ); - setCombinedRoles( - [...policyRoleItems, ...resourceSpecific].filter( - (role) => !role.isAdmin - ) - ); - setResourceRolesInitialized(true); - }, [ - isResourceOverlay, - resourceRolesData, - resourceRolesInitialized, - policyRoleItems, - policyRoleLockedIds - ]); - - useEffect(() => { - if (!isResourceOverlay || resourceUsersInitialized) return; - if (!resourceUsersData) return; - - const resourceSpecific = resourceUsersData - .filter((u) => !policyUserLockedIds.has(u.userId)) - .map((u) => ({ - id: u.userId, - text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); - - initialResourceUserIdsRef.current = new Set( - resourceSpecific.map((u) => u.id) - ); - setCombinedUsers([...policyUserItems, ...resourceSpecific]); - setResourceUsersInitialized(true); - }, [ - isResourceOverlay, - resourceUsersData, - resourceUsersInitialized, - policyUserItems, - policyUserLockedIds - ]); - - // ── Standard policy form (non-overlay) ────────────────────────────────── - const form = useForm({ - resolver: zodResolver( - createPolicySchema.pick({ - sso: true, - skipToIdpId: true, - users: true, - roles: true - }) - ), - defaultValues: { - sso: policy.sso, - skipToIdpId: policy.idpId, - roles: policyRoleItems, - users: policyUserItems - } - }); - - const ssoEnabled = useWatch({ control: form.control, name: "sso" }); - const selectedIdpId = useWatch({ - control: form.control, - name: "skipToIdpId" - }); - - const [, formAction, isSubmitting] = useActionState(onSubmit, null); - const [isSavingOverlay, setIsSavingOverlay] = useState(false); - - async function onSubmit() { - if (readonly) return; - - if (isResourceOverlay) { - await saveResourceOverlay(); - return; - } - - const isValid = await form.trigger(); - if (!isValid) return; - - const payload = form.getValues(); - - try { - const res = await api - .put>( - `/resource-policy/${policy.resourcePolicyId}/access-control`, - { - sso: payload.sso, - userIds: payload.users.map((user) => user.id), - roleIds: payload.roles.map((role) => Number(role.id)), - skipToIdpId: payload.skipToIdpId - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: t("policyErrorUpdateMessageDescription") - }); - } - } - - async function saveResourceOverlay() { - setIsSavingOverlay(true); - try { - // Compute which roles/users are resource-specific (non-locked) - const currentResourceRoleIds = combinedRoles - .filter((r) => !policyRoleLockedIds.has(r.id)) - .map((r) => Number(r.id)); - const currentResourceUserIds = combinedUsers - .filter((u) => !policyUserLockedIds.has(u.id)) - .map((u) => u.id); - - // Use bulk-set endpoints (session-authenticated) which replace - // all resource-specific roles/users in one call - await Promise.all([ - api.post(`/resource/${resourceId}/roles`, { - roleIds: currentResourceRoleIds - }), - api.post(`/resource/${resourceId}/users`, { - userIds: currentResourceUserIds - }) - ]); - - // Update refs to reflect new state - initialResourceRoleIdsRef.current = new Set( - currentResourceRoleIds.map(String) - ); - initialResourceUserIdsRef.current = new Set(currentResourceUserIds); - - toast({ - title: t("success"), - description: t("policyUpdatedSuccess") - }); - router.refresh(); - } catch (e) { - toast({ - variant: "destructive", - title: t("policyErrorUpdate"), - description: formatAxiosError( - e, - t("policyErrorUpdateDescription") - ) - }); - } finally { - setIsSavingOverlay(false); - } - } - - const isLoading = - isResourceOverlay && - (!resourceRolesInitialized || !resourceUsersInitialized); - - return ( -
- - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - form.setValue("sso", val); - }} - disabled={readonly || isResourceOverlay} - /> - - {ssoEnabled && ( - <> - - {t("roles")} - - {isResourceOverlay ? ( - !role.isAdmin - )} - onSelectRoles={(roles) => { - setCombinedRoles( - roles - .map( - (role) => ({ - ...role, - isAdmin: - Boolean( - role.isAdmin - ) - }) - ) - .filter( - (role) => - !role.isAdmin - ) - ); - }} - disabled={isLoading} - restrictAdminRole - lockedIds={ - policyRoleLockedIds - } - /> - ) : ( - ( - - form.setValue( - "roles", - roles - ) - } - disabled={readonly} - restrictAdminRole - /> - )} - /> - )} - - - - {t("resourceRoleDescription")} - - - - - {t("users")} - - {isResourceOverlay ? ( - - ) : ( - ( - - form.setValue( - "users", - users - ) - } - disabled={readonly} - /> - )} - /> - )} - - - - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} -
-
- - - - -
-
- - ); -} diff --git a/src/components/resource-policy/PolicyAccessRulesIntro.tsx b/src/components/resource-policy/PolicyAccessRulesIntro.tsx new file mode 100644 index 000000000..60d9b0984 --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesIntro.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { useTranslations } from "next-intl"; + +export type PolicyAccessRulesIntroProps = { + rulesEnabled: boolean; + onRulesEnabledChange: (enabled: boolean) => void; + disableToggle?: boolean; +}; + +export function PolicyAccessRulesIntro({ + rulesEnabled, + onRulesEnabledChange, + disableToggle +}: PolicyAccessRulesIntroProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx new file mode 100644 index 000000000..daa7eba32 --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -0,0 +1,1095 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import { toast } from "@app/hooks/useToast"; +import { + createPolicyRulesSectionSchema, + validatePolicyRulePriority, + validatePolicyRuleValue, + validatePolicyRulesForSave, + type PolicyFormValues +} from "."; + +import { Button } from "@app/components/ui/button"; +import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { REGIONS, getRegionNameById } from "@server/db/regions"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Check, + ChevronsUpDown, + LockIcon, + Plus +} from "lucide-react"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { resourceQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; +import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; +import { createEmptyRule } from "./policy-access-rule-utils"; + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; + fromPolicy?: boolean; +}; + +type PolicyAccessRulesSectionEditProps = { + mode: "edit"; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + readonly?: boolean; + resourceId?: number; +}; + +type PolicyAccessRulesSectionCreateProps = { + mode: "create"; + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export type PolicyAccessRulesSectionProps = + | PolicyAccessRulesSectionEditProps + | PolicyAccessRulesSectionCreateProps; + +export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) { + if (props.mode === "create") { + return ; + } + return ; +} + +function PolicyAccessRulesSectionEdit({ + isMaxmindAvailable, + isMaxmindAsnAvailable, + readonly, + resourceId +}: PolicyAccessRulesSectionEditProps) { + const t = useTranslations(); + + const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + + const isResourceOverlay = resourceId !== undefined; + + // ── Fetch resource-specific rules when in overlay mode ─────────────────── + const { data: resourceRulesData } = useQuery({ + ...resourceQueries.resourceRules({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + const deletedResourceRuleIdsRef = useRef>(new Set()); + const [resourceRulesInitialized, setResourceRulesInitialized] = + useState(false); + + const rulesFormSchema = useMemo( + () => createPolicyRulesSectionSchema(t), + [t] + ); + + const form = useForm({ + resolver: zodResolver(rulesFormSchema), + defaultValues: { + applyRules: policy.applyRules, + rules: policy.rules + } + }); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const [rules, setRules] = useState( + policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) + ); + + // Initialize resource-specific rules once fetched + useEffect(() => { + if (!isResourceOverlay || resourceRulesInitialized) return; + if (!resourceRulesData) return; + + const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); + const resourceSpecific: LocalRule[] = resourceRulesData + .filter((r) => !policyRuleIds.has(r.ruleId)) + .map((r) => ({ + ruleId: r.ruleId, + action: r.action as "ACCEPT" | "DROP" | "PASS", + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled, + fromPolicy: false + })); + + setRules([ + ...resourceSpecific, + ...policy.rules.map((r) => ({ ...r, fromPolicy: true })) + ]); + setResourceRulesInitialized(true); + }, [ + isResourceOverlay, + resourceRulesData, + resourceRulesInitialized, + policy.rules + ]); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN", + REGION: t("region") + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addEmptyRule = useCallback(() => { + const updatedRules = [...rules, createEmptyRule(rules)]; + setRules(updatedRules); + syncFormRules(updatedRules); + }, [rules, syncFormRules]); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const rule = rules.find((r) => r.ruleId === ruleId); + if (!rule || rule.fromPolicy) return; // cannot remove policy rules + // Track deletion for resource overlay mode (only for existing DB rules) + if (isResourceOverlay && !rule.new) { + deletedResourceRuleIdsRef.current.add(ruleId); + } + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules, isResourceOverlay] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const sortedRules = useMemo( + () => [...rules].sort((a, b) => a.priority - b.priority), + [rules] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + size: 96, + maxSize: 96, + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + const duplicatePriority = rules.some( + (rule) => + rule.ruleId !== row.original.ruleId && + rule.priority === validated.data + ); + if (duplicatePriority) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicatePriority"), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: validated.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + size: 160, + maxSize: 160, + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + size: 144, + maxSize: 144, + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : row.original.match === "REGION" ? ( + + + + + + + + + + {t("noRegionFound")} + + {REGIONS.map((continent) => ( + + + updateRule( + row.original.ruleId, + { + value: continent.id + } + ) + } + > + + {t(continent.name)} ( + {continent.id}) + + {continent.includes.map( + (subregion) => ( + + updateRule( + row.original + .ruleId, + { + value: subregion.id + } + ) + } + > + + {t(subregion.name)}{" "} + ({subregion.id}) + + ) + )} + + ))} + + + + + ) : ( + { + const validated = validatePolicyRuleValue( + t, + row.original.match, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + updateRule(row.original.ruleId, { + value: validated.data + }); + }} + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( +
+ + updateRule(row.original.ruleId, { + enabled: val + }) + } + /> +
+ ) + }, + { + id: "actions", + header: () => null, + cell: ({ row }) => ( +
+ {row.original.fromPolicy ? ( + + ) : ( + + )} +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule, + readonly, + rules + ] + ); + + const table = useReactTable({ + data: sortedRules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + const [isPending, startTransition] = useTransition(); + + async function saveRules() { + if (readonly) return; + + const applyRules = form.getValues("applyRules") ?? false; + const rulesPayload = rules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ); + const validation = validatePolicyRulesForSave( + t, + rulesPayload, + applyRules + ); + if (!validation.success) { + toast({ + variant: "destructive", + ...validation.toast + }); + return; + } + + if (isResourceOverlay) { + await saveResourceOverlayRules(); + return; + } + + const isValid = await form.trigger(); + if (!isValid) return; + + const payload = { + applyRules, + rules: rulesPayload + }; + + try { + const res = await api + .put< + AxiosResponse<{}> + >(`/resource-policy/${policy.resourcePolicyId}/rules`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + async function saveResourceOverlayRules() { + try { + const newRules = rules.filter((r) => !r.fromPolicy && r.new); + const updatedRules = rules.filter( + (r) => !r.fromPolicy && !r.new && r.updated + ); + const deletedIds = [...deletedResourceRuleIdsRef.current]; + + await Promise.all([ + ...newRules.map((r) => + api.put(`/resource/${resourceId}/rule`, { + action: r.action, + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + ...updatedRules.map((r) => + api.post(`/resource/${resourceId}/rule/${r.ruleId}`, { + action: r.action, + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + ...deletedIds.map((id) => + api.delete(`/resource/${resourceId}/rule/${id}`) + ) + ]); + + deletedResourceRuleIdsRef.current = new Set(); + + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + } + } + + const addRuleButton = ( + + ); + + return ( + + + + {t("policyAccessRulesTitle")} + + + {t("rulesResourceDescription")} + + + +
+ { + form.setValue("applyRules", val); + }} + disableToggle={readonly || isResourceOverlay} + /> + + {rulesEnabled && ( + <> + + + {table + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers.map( + (header) => { + const columnId = + header.column.id; + const isActionsColumn = + columnId === + "actions"; + const isPriorityColumn = + columnId === + "priority"; + const isActionColumn = + columnId === + "action"; + const isMatchColumn = + columnId === + "match"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ); + } + )} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => { + const columnId = + cell.column.id; + const isActionsColumn = + columnId === + "actions"; + const isPriorityColumn = + columnId === + "priority"; + const isActionColumn = + columnId === + "action"; + const isMatchColumn = + columnId === + "match"; + return ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + )} + +
+ {table.getRowModel().rows?.length > 0 && + addRuleButton} + + )} +
+
+ + + +
+ ); +} + +function PolicyAccessRulesSectionCreate({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyAccessRulesSectionCreateProps) { + return ( + + ); +} diff --git a/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx b/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx new file mode 100644 index 000000000..2daebb9f5 --- /dev/null +++ b/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +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 { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { + setHeaderAuthSchema, + setPasswordSchema, + setPincodeSchema +} from "./policy-auth-method-id"; + +type CredenzaShellProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + formId: string; + submitLabel: string; + children: React.ReactNode; +}; + +function CredenzaShell({ + open, + onOpenChange, + title, + description, + formId, + submitLabel, + children +}: CredenzaShellProps) { + const t = useTranslations(); + + return ( + + + + {title} + {description} + + {children} + + + + + + + + + ); +} + +type PasscodeCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultPassword?: string; + existingConfigured?: boolean; + onSave: (password: string) => void; +}; + +export function PasscodeCredenza({ + open, + onOpenChange, + defaultPassword = "", + existingConfigured, + onSave +}: PasscodeCredenzaProps) { + const t = useTranslations(); + const form = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: defaultPassword } + }); + + useEffect(() => { + if (open) { + form.reset({ password: defaultPassword }); + } + }, [open, defaultPassword, form]); + + return ( + +
+ { + onSave(data.password); + onOpenChange(false); + form.reset(); + })} + className="space-y-4" + > + ( + + + {t("policyAuthPasscodeTitle")} + + + + + + + )} + /> + + +
+ ); +} + +type PincodeCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultPincode?: string; + onSave: (pincode: string) => void; +}; + +export function PincodeCredenza({ + open, + onOpenChange, + defaultPincode = "", + onSave +}: PincodeCredenzaProps) { + const t = useTranslations(); + const form = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: defaultPincode } + }); + + useEffect(() => { + if (open) { + form.reset({ pincode: defaultPincode }); + } + }, [open, defaultPincode, form]); + + return ( + +
+ { + onSave(data.pincode); + onOpenChange(false); + form.reset(); + })} + className="space-y-4" + > + ( + + {t("resourcePincode")} + +
+ + + {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+
+ +
+ )} + /> + + +
+ ); +} + +type HeaderAuthCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultValues?: { + user: string; + password: string; + extendedCompatibility: boolean; + }; + existingConfigured?: boolean; + onSave: (values: z.infer) => void; +}; + +export function HeaderAuthCredenza({ + open, + onOpenChange, + defaultValues, + existingConfigured, + onSave +}: HeaderAuthCredenzaProps) { + const t = useTranslations(); + const form = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { + user: "", + password: "", + extendedCompatibility: true, + ...defaultValues + } + }); + + useEffect(() => { + if (open) { + form.reset({ + user: defaultValues?.user ?? "", + password: defaultValues?.password ?? "", + extendedCompatibility: + defaultValues?.extendedCompatibility ?? true + }); + } + }, [open, defaultValues, form]); + + return ( + +
+ { + onSave(data); + onOpenChange(false); + form.reset(); + })} + className="space-y-4" + > + ( + + + {t("policyAuthHeaderName")} + + + + + + + )} + /> + ( + + + {t("policyAuthHeaderValue")} + + + + + + + )} + /> + ( + + + + + + )} + /> + + +
+ ); +} + +type EmailCredenzaProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + emailEnabled: boolean; + disabled?: boolean; + emails: Tag[]; + onEmailsChange: (emails: Tag[]) => void; +}; + +export function EmailCredenza({ + open, + onOpenChange, + emailEnabled, + disabled, + emails, + onEmailsChange +}: EmailCredenzaProps) { + const t = useTranslations(); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + return ( + + + + {t("policyAuthEmailTitle")} + + {t("policyAuthEmailDescription")} + + + +
+ {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + {emailEnabled && ( +

+ {t("otpEmailWhitelistListDescription")} +

+ )} + {emailEnabled && ( + + + {t("otpEmailWhitelistList")} + + + { + if (!disabled) { + onEmailsChange( + newEmails as Tag[] + ); + } + }} + validateTag={(tag) => + z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/ + ) + ) + .safeParse(tag).success + } + allowDuplicates={false} + sortTags + size="sm" + disabled={disabled} + /> + + + {t("otpEmailEnterDescription")} + + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthMethodRow.tsx b/src/components/resource-policy/PolicyAuthMethodRow.tsx new file mode 100644 index 000000000..c1fac1a7f --- /dev/null +++ b/src/components/resource-policy/PolicyAuthMethodRow.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Switch } from "@app/components/ui/switch"; +import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; + +export type PolicyAuthMethodRowProps = { + id: string; + title: string; + description: string; + summary: string; + active: boolean; + onConfigure: () => void; + onToggle: (active: boolean) => void; + disabled?: boolean; + configureDisabled?: boolean; +}; + +export function PolicyAuthMethodRow({ + id, + title, + description, + summary, + active, + onConfigure, + onToggle, + disabled, + configureDisabled = disabled +}: PolicyAuthMethodRowProps) { + const t = useTranslations(); + const canEdit = active && !configureDisabled; + const canEnable = !active && !disabled; + const isRowInteractive = canEdit || canEnable; + + const handleRowClick = () => { + if (canEdit) { + onConfigure(); + return; + } + if (canEnable) { + onToggle(true); + } + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRowClick(); + } + } + : undefined + } + role={isRowInteractive ? "button" : undefined} + tabIndex={isRowInteractive ? 0 : undefined} + > +
+
+ +
+ + {title} +
+

+ {active ? summary : description} +

+
+
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {active && ( + + )} + +
+
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthSsoSection.tsx b/src/components/resource-policy/PolicyAuthSsoSection.tsx new file mode 100644 index 000000000..67f7ead27 --- /dev/null +++ b/src/components/resource-policy/PolicyAuthSsoSection.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { SettingsSectionForm } from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { FormDescription, FormItem, FormLabel } from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; + +export type PolicyAuthSsoSectionProps = { + sso: boolean; + onSsoChange: (active: boolean) => void; + skipToIdpId: number | null | undefined; + onSkipToIdpChange: (id: number | null) => void; + allIdps: { id: number; text: string }[]; + rolesEditor: React.ReactNode; + usersEditor: React.ReactNode; + disabled?: boolean; + idpDisabled?: boolean; +}; + +export function PolicyAuthSsoSection({ + sso, + onSsoChange, + skipToIdpId, + onSkipToIdpChange, + allIdps, + rolesEditor, + usersEditor, + disabled, + idpDisabled +}: PolicyAuthSsoSectionProps) { + const t = useTranslations(); + const [showIdpSelect, setShowIdpSelect] = useState(skipToIdpId != null); + + useEffect(() => { + if (skipToIdpId != null) { + setShowIdpSelect(true); + } + }, [skipToIdpId]); + + const idpSelectDisabled = idpDisabled ?? disabled; + + return ( +
+ + + {sso && ( + + + {t("roles")} + {rolesEditor} + + + {t("users")} + {usersEditor} + + {allIdps.length > 0 && ( +
+ {skipToIdpId == null && !showIdpSelect ? ( + + ) : ( + <> + + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+ + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthStackSection.tsx b/src/components/resource-policy/PolicyAuthStackSection.tsx new file mode 100644 index 000000000..f11629692 --- /dev/null +++ b/src/components/resource-policy/PolicyAuthStackSection.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { type UseFormReturn } from "react-hook-form"; +import type { PolicyFormValues } from "."; +import { PolicyAuthStackSectionCreate } from "./PolicyAuthStackSectionCreate"; +import { PolicyAuthStackSectionEdit } from "./PolicyAuthStackSectionEdit"; + +type PolicyAuthStackSectionEditProps = { + mode: "edit"; + orgId: string; + allIdps: { id: number; text: string }[]; + emailEnabled: boolean; + readonly?: boolean; + resourceId?: number; +}; + +type PolicyAuthStackSectionCreateProps = { + mode: "create"; + form: UseFormReturn; + orgId: string; + allIdps: { id: number; text: string }[]; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + emailEnabled: boolean; +}; + +export type PolicyAuthStackSectionProps = + | PolicyAuthStackSectionEditProps + | PolicyAuthStackSectionCreateProps; + +export function PolicyAuthStackSection(props: PolicyAuthStackSectionProps) { + if (props.mode === "create") { + const { mode: _, ...createProps } = props; + return ; + } + const { mode: _, ...editProps } = props; + return ; +} diff --git a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx new file mode 100644 index 000000000..c462cd9ae --- /dev/null +++ b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle, + SettingsSectionTitle +} from "@app/components/Settings"; +import { TagInput } from "@app/components/tags/tag-input"; +import { FormField } from "@app/components/ui/form"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { type UseFormReturn, useWatch } from "react-hook-form"; +import type { PolicyFormValues } from "."; +import { + EmailCredenza, + HeaderAuthCredenza, + PasscodeCredenza, + PincodeCredenza +} from "./PolicyAuthMethodCredenzas"; +import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow"; +import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection"; +import type { PolicyAuthMethodId } from "./policy-auth-method-id"; +import { + getEmailWhitelistSummary, + getHeaderAuthSummary, + getPasscodeSummary, + getPincodeSummary +} from "./policy-auth-summaries"; + +export type PolicyAuthStackSectionCreateProps = { + form: UseFormReturn; + orgId: string; + allIdps: { id: number; text: string }[]; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + emailEnabled: boolean; +}; + +export function PolicyAuthStackSectionCreate({ + form: parentForm, + allIdps, + allRoles, + allUsers, + emailEnabled +}: PolicyAuthStackSectionCreateProps) { + const t = useTranslations(); + const [editingMethod, setEditingMethod] = + useState(null); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + const sso = useWatch({ control: parentForm.control, name: "sso" }); + const skipToIdpId = useWatch({ + control: parentForm.control, + name: "skipToIdpId" + }); + const password = useWatch({ + control: parentForm.control, + name: "password" + }); + const pincode = useWatch({ control: parentForm.control, name: "pincode" }); + const headerAuth = useWatch({ + control: parentForm.control, + name: "headerAuth" + }); + const emailWhitelistEnabled = useWatch({ + control: parentForm.control, + name: "emailWhitelistEnabled" + }); + const emails = + useWatch({ control: parentForm.control, name: "emails" }) ?? []; + + const passcodeActive = Boolean(password); + const pinActive = Boolean(pincode); + const headerAuthActive = Boolean(headerAuth); + + const closeCredenza = () => setEditingMethod(null); + + const handleToggle = ( + method: PolicyAuthMethodId, + active: boolean, + onDisable: () => void, + onEnable?: () => void + ) => { + if (active) { + onEnable?.(); + setEditingMethod(method); + return; + } + onDisable(); + setEditingMethod((current) => (current === method ? null : current)); + }; + + return ( + + + + {t("policyAuthStackTitle")} + + + {t("policyAuthStackDescription")} + + + +
+ + parentForm.setValue("sso", active) + } + skipToIdpId={skipToIdpId} + onSkipToIdpChange={(id) => + parentForm.setValue("skipToIdpId", id) + } + allIdps={allIdps} + rolesEditor={ + ( + + field.onChange(newRoles) + } + autocompleteOptions={allRoles} + allowDuplicates={false} + size="sm" + /> + )} + /> + } + usersEditor={ + ( + + field.onChange(newUsers) + } + autocompleteOptions={allUsers} + allowDuplicates={false} + size="sm" + /> + )} + /> + } + /> +
+ + + + {t("policyAuthOtherMethodsTitle")} + + + {t("policyAuthOtherMethodsDescription")} + + + +
+ setEditingMethod("pincode")} + onToggle={(active) => + handleToggle("pincode", active, () => + parentForm.setValue("pincode", null) + ) + } + /> + + setEditingMethod("passcode")} + onToggle={(active) => + handleToggle("passcode", active, () => + parentForm.setValue("password", null) + ) + } + /> + + setEditingMethod("email")} + onToggle={(active) => + handleToggle( + "email", + active, + () => + parentForm.setValue( + "emailWhitelistEnabled", + false + ), + () => + parentForm.setValue( + "emailWhitelistEnabled", + true + ) + ) + } + disabled={!emailEnabled} + /> + + setEditingMethod("headerAuth")} + onToggle={(active) => + handleToggle("headerAuth", active, () => + parentForm.setValue("headerAuth", null) + ) + } + /> +
+ + !open && closeCredenza()} + defaultPincode={pincode?.pincode ?? ""} + onSave={(value) => { + parentForm.setValue("pincode", { pincode: value }); + }} + /> + + !open && closeCredenza()} + defaultPassword={password?.password ?? ""} + onSave={(value) => { + parentForm.setValue("password", { password: value }); + }} + /> + + !open && closeCredenza()} + emailEnabled={emailEnabled} + emails={emails} + onEmailsChange={(value) => + parentForm.setValue( + "emails", + value as PolicyFormValues["emails"] + ) + } + /> + + !open && closeCredenza()} + defaultValues={ + headerAuth + ? { + user: headerAuth.user, + password: headerAuth.password, + extendedCompatibility: + headerAuth.extendedCompatibility + } + : undefined + } + onSave={(value) => { + parentForm.setValue("headerAuth", value); + }} + /> +
+
+ ); +} diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx new file mode 100644 index 000000000..f24e4360d --- /dev/null +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -0,0 +1,694 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + RolesSelector, + type SelectedRole +} from "@app/components/roles-selector"; +import { UsersSelector } from "@app/components/users-selector"; +import { Button } from "@app/components/ui/button"; +import { Form, FormField } from "@app/components/ui/form"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { resourceQueries } from "@app/lib/queries"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useMemo, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { createPolicySchema } from "."; +import { + EmailCredenza, + HeaderAuthCredenza, + PasscodeCredenza, + PincodeCredenza +} from "./PolicyAuthMethodCredenzas"; +import { PolicyAuthMethodRow } from "./PolicyAuthMethodRow"; +import { PolicyAuthSsoSection } from "./PolicyAuthSsoSection"; +import type { PolicyAuthMethodId } from "./policy-auth-method-id"; +import { + getEmailWhitelistSummary, + getHeaderAuthSummary, + getPasscodeSummary, + getPincodeSummary +} from "./policy-auth-summaries"; + +type OverlaySelectedRole = SelectedRole & { isAdmin: boolean }; + +const authStackSchema = createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + roles: true, + users: true, + password: true, + pincode: true, + headerAuth: true, + emailWhitelistEnabled: true, + emails: true +}); + +export type PolicyAuthStackSectionEditProps = { + orgId: string; + allIdps: { id: number; text: string }[]; + emailEnabled: boolean; + readonly?: boolean; + resourceId?: number; +}; + +export function PolicyAuthStackSectionEdit({ + orgId, + allIdps, + emailEnabled, + readonly, + resourceId +}: PolicyAuthStackSectionEditProps) { + const t = useTranslations(); + const router = useRouter(); + const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + + const isResourceOverlay = resourceId !== undefined; + const authReadonly = readonly || isResourceOverlay; + + const policyRoleItems = useMemo( + () => + policy.roles.map((r) => ({ + id: r.roleId.toString(), + text: r.name, + isAdmin: false + })), + [policy.roles] + ); + const policyUserItems = useMemo( + () => + policy.users.map((u) => ({ + id: u.userId, + text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })), + [policy.users] + ); + + const policyRoleLockedIds = useMemo( + () => new Set(policy.roles.map((r) => r.roleId.toString())), + [policy.roles] + ); + const policyUserLockedIds = useMemo( + () => new Set(policy.users.map((u) => u.userId)), + [policy.users] + ); + + const { data: resourceRolesData } = useQuery({ + ...resourceQueries.resourceRoles({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + const { data: resourceUsersData } = useQuery({ + ...resourceQueries.resourceUsers({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + const [combinedRoles, setCombinedRoles] = + useState(policyRoleItems); + const [combinedUsers, setCombinedUsers] = useState(policyUserItems); + const [resourceRolesInitialized, setResourceRolesInitialized] = + useState(false); + const [resourceUsersInitialized, setResourceUsersInitialized] = + useState(false); + const initialResourceRoleIdsRef = useRef>(new Set()); + const initialResourceUserIdsRef = useRef>(new Set()); + + useEffect(() => { + if (!isResourceOverlay || resourceRolesInitialized) return; + if (!resourceRolesData) return; + const resourceSpecific = resourceRolesData + .filter((r) => !policyRoleLockedIds.has(r.roleId.toString())) + .map((r) => ({ + id: r.roleId.toString(), + text: r.name, + isAdmin: Boolean(r.isAdmin) + })); + initialResourceRoleIdsRef.current = new Set( + resourceSpecific.map((r) => r.id) + ); + setCombinedRoles( + [...policyRoleItems, ...resourceSpecific].filter( + (role) => !role.isAdmin + ) + ); + setResourceRolesInitialized(true); + }, [ + isResourceOverlay, + resourceRolesData, + resourceRolesInitialized, + policyRoleItems, + policyRoleLockedIds + ]); + + useEffect(() => { + if (!isResourceOverlay || resourceUsersInitialized) return; + if (!resourceUsersData) return; + const resourceSpecific = resourceUsersData + .filter((u) => !policyUserLockedIds.has(u.userId)) + .map((u) => ({ + id: u.userId, + text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })); + initialResourceUserIdsRef.current = new Set( + resourceSpecific.map((u) => u.id) + ); + setCombinedUsers([...policyUserItems, ...resourceSpecific]); + setResourceUsersInitialized(true); + }, [ + isResourceOverlay, + resourceUsersData, + resourceUsersInitialized, + policyUserItems, + policyUserLockedIds + ]); + + const form = useForm({ + resolver: zodResolver(authStackSchema), + defaultValues: { + sso: policy.sso, + skipToIdpId: policy.idpId, + roles: policyRoleItems, + users: policyUserItems, + password: policy.passwordId ? { password: "" } : null, + pincode: policy.pincodeId ? { pincode: "" } : null, + headerAuth: policy.headerAuth + ? { + user: "", + password: "", + extendedCompatibility: + policy.headerAuth.extendedCompability ?? true + } + : null, + emailWhitelistEnabled: policy.emailWhitelistEnabled, + emails: policy.emailWhiteList.map((email) => ({ + id: email.whiteListId.toString(), + text: email.email + })) + } + }); + + const [passcodeActive, setPasscodeActive] = useState( + Boolean(policy.passwordId) + ); + const [pinActive, setPinActive] = useState(Boolean(policy.pincodeId)); + const [headerAuthActive, setHeaderAuthActive] = useState( + Boolean(policy.headerAuth) + ); + const [editingMethod, setEditingMethod] = + useState(null); + + const sso = useWatch({ control: form.control, name: "sso" }); + const skipToIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const roles = useWatch({ control: form.control, name: "roles" }) ?? []; + const users = useWatch({ control: form.control, name: "users" }) ?? []; + const password = useWatch({ control: form.control, name: "password" }); + const pincode = useWatch({ control: form.control, name: "pincode" }); + const headerAuth = useWatch({ control: form.control, name: "headerAuth" }); + const emailWhitelistEnabled = useWatch({ + control: form.control, + name: "emailWhitelistEnabled" + }); + const emails = useWatch({ control: form.control, name: "emails" }) ?? []; + + const overlayRoles = combinedRoles.filter((r) => !r.isAdmin); + const overlayUsers = combinedUsers; + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + const [isSavingOverlay, setIsSavingOverlay] = useState(false); + + async function onSubmit() { + if (readonly && !isResourceOverlay) return; + + if (isResourceOverlay) { + await saveResourceOverlay(); + return; + } + + const isValid = await form.trigger(); + if (!isValid) return; + + const payload = form.getValues(); + const requests: Array | void>> = []; + + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/access-control`, + { + sso: payload.sso, + userIds: payload.users.map((user) => user.id), + roleIds: payload.roles.map((role) => Number(role.id)), + skipToIdpId: payload.skipToIdpId + } + ) + .catch(handleError) + ); + + if (passcodeActive && payload.password?.password) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/password`, + { password: payload.password.password } + ) + .catch(handleError) + ); + } else if (!passcodeActive && policy.passwordId) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/password`, + { password: null } + ) + .catch(handleError) + ); + } + + if (pinActive && payload.pincode?.pincode?.length === 6) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { pincode: payload.pincode.pincode } + ) + .catch(handleError) + ); + } else if (!pinActive && policy.pincodeId) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { pincode: null } + ) + .catch(handleError) + ); + } + + if ( + headerAuthActive && + payload.headerAuth?.user && + payload.headerAuth?.password + ) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { headerAuth: payload.headerAuth } + ) + .catch(handleError) + ); + } else if (!headerAuthActive && policy.headerAuth) { + requests.push( + api + .put( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { headerAuth: null } + ) + .catch(handleError) + ); + } + + requests.push( + api + .put(`/resource-policy/${policy.resourcePolicyId}/whitelist`, { + emailWhitelistEnabled: payload.emailWhitelistEnabled, + emails: payload.emails?.map((e) => e.text) ?? [] + }) + .catch(handleError) + ); + + try { + const results = await Promise.all(requests); + if (results.every((res) => res && res.status === 200)) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + function handleError(e: unknown) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError(e, t("policyErrorUpdateDescription")) + }); + } + + async function saveResourceOverlay() { + setIsSavingOverlay(true); + try { + const currentResourceRoleIds = combinedRoles + .filter((r) => !policyRoleLockedIds.has(r.id)) + .map((r) => Number(r.id)); + const currentResourceUserIds = combinedUsers + .filter((u) => !policyUserLockedIds.has(u.id)) + .map((u) => u.id); + + await Promise.all([ + api.post(`/resource/${resourceId}/roles`, { + roleIds: currentResourceRoleIds + }), + api.post(`/resource/${resourceId}/users`, { + userIds: currentResourceUserIds + }) + ]); + + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + } finally { + setIsSavingOverlay(false); + } + } + + const isLoading = + isResourceOverlay && + (!resourceRolesInitialized || !resourceUsersInitialized); + + const closeCredenza = () => setEditingMethod(null); + + const openMethodEditor = (method: PolicyAuthMethodId) => { + setEditingMethod(method); + }; + + const handleToggle = ( + method: PolicyAuthMethodId, + active: boolean, + onDisable: () => void, + onEnable?: () => void + ) => { + if (active) { + onEnable?.(); + openMethodEditor(method); + return; + } + onDisable(); + setEditingMethod((current) => (current === method ? null : current)); + }; + + return ( +
+ + + + + {t("policyAuthStackTitle")} + + + {t("policyAuthStackDescription")} + + + +
+ + form.setValue("sso", active) + } + skipToIdpId={skipToIdpId} + onSkipToIdpChange={(id) => + form.setValue("skipToIdpId", id) + } + allIdps={allIdps} + disabled={authReadonly} + idpDisabled={authReadonly} + rolesEditor={ + isResourceOverlay ? ( + + setCombinedRoles( + selected.map((role) => ({ + ...role, + isAdmin: Boolean( + role.isAdmin + ) + })) + ) + } + disabled={isLoading} + restrictAdminRole + lockedIds={policyRoleLockedIds} + /> + ) : ( + ( + + form.setValue( + "roles", + selected + ) + } + disabled={readonly} + restrictAdminRole + /> + )} + /> + ) + } + usersEditor={ + isResourceOverlay ? ( + + ) : ( + ( + + form.setValue( + "users", + selected + ) + } + disabled={readonly} + /> + )} + /> + ) + } + /> +
+ + + + {t("policyAuthOtherMethodsTitle")} + + + {t("policyAuthOtherMethodsDescription")} + + + +
+ openMethodEditor("pincode")} + onToggle={(active) => + handleToggle("pincode", active, () => { + setPinActive(false); + form.setValue("pincode", null); + }) + } + disabled={authReadonly} + /> + + openMethodEditor("passcode")} + onToggle={(active) => + handleToggle("passcode", active, () => { + setPasscodeActive(false); + form.setValue("password", null); + }) + } + disabled={authReadonly} + /> + + openMethodEditor("email")} + onToggle={(active) => + handleToggle( + "email", + active, + () => + form.setValue( + "emailWhitelistEnabled", + false + ), + () => + form.setValue( + "emailWhitelistEnabled", + true + ) + ) + } + disabled={authReadonly || !emailEnabled} + /> + + + openMethodEditor("headerAuth") + } + onToggle={(active) => + handleToggle("headerAuth", active, () => { + setHeaderAuthActive(false); + form.setValue("headerAuth", null); + }) + } + disabled={authReadonly} + /> +
+ + !open && closeCredenza()} + defaultPincode={pincode?.pincode ?? ""} + onSave={(value) => { + form.setValue("pincode", { pincode: value }); + setPinActive(true); + }} + /> + + !open && closeCredenza()} + defaultPassword={password?.password ?? ""} + existingConfigured={Boolean(policy.passwordId)} + onSave={(value) => { + form.setValue("password", { password: value }); + setPasscodeActive(true); + }} + /> + + !open && closeCredenza()} + emailEnabled={emailEnabled} + disabled={authReadonly} + emails={emails} + onEmailsChange={(value) => + form.setValue("emails", value) + } + /> + + !open && closeCredenza()} + defaultValues={ + headerAuth + ? { + user: headerAuth.user, + password: headerAuth.password, + extendedCompatibility: + headerAuth.extendedCompatibility + } + : undefined + } + existingConfigured={Boolean(policy.headerAuth)} + onSave={(value) => { + form.setValue("headerAuth", value); + setHeaderAuthActive(true); + }} + /> +
+ + + +
+
+ + ); +} diff --git a/src/components/resource-policy/ResourcePolicySubForms.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx deleted file mode 100644 index 63f5b2a03..000000000 --- a/src/components/resource-policy/ResourcePolicySubForms.tsx +++ /dev/null @@ -1,1918 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionForm, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { toast } from "@app/hooks/useToast"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot -} from "@app/components/ui/input-otp"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern -} from "@server/lib/validators"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { - ArrowUpDown, - Binary, - Bot, - Check, - ChevronsUpDown, - InfoIcon, - Key, - Plus -} from "lucide-react"; -import { useTranslations } from "next-intl"; - -import { useCallback, useMemo, useState } from "react"; -import { UseFormReturn, useForm, useWatch } from "react-hook-form"; -import z from "zod"; -import type { PolicyFormValues } from "."; - -const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); - -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; -}; - -// ─── PolicyNameSection ────────────────────────────────────────────────── -type PolicyNameSectionProps = { - form: UseFormReturn; - isEditing?: boolean; -}; - -export function PolicyNameSection({ form }: PolicyNameSectionProps) { - const t = useTranslations(); - return ( - - - - {t("resourcePolicyName")} - - - {t("resourcePolicyNameDescription")} - - - - - ( - - {t("name")} - - - - - - )} - /> - - - -
- -
-
- ); -} - -// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── - -type PolicyUsersRolesSectionProps = { - form: UseFormReturn; - allRoles: { id: string; text: string }[]; - allUsers: { id: string; text: string }[]; - allIdps: { id: number; text: string }[]; -}; - -export function PolicyUsersRolesSection({ - form, - allRoles, - allUsers, - allIdps -}: PolicyUsersRolesSectionProps) { - const t = useTranslations(); - const ssoEnabled = useWatch({ control: form.control, name: "sso" }); - const selectedIdpId = useWatch({ - control: form.control, - name: "skipToIdpId" - }); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - - return ( - - - - {t("resourceUsersRoles")} - - - {t("resourcePolicyUsersRolesDescription")} - - - - - { - console.log(`form.setValue("sso", ${val})`); - form.setValue("sso", val); - }} - /> - - {ssoEnabled && ( - <> - ( - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t("resourceRoleDescription")} - - - )} - /> - ( - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t("defaultIdentityProviderDescription")} -

-
- )} -
-
-
- ); -} - -// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── - -const setPasswordSchema = z.object({ - password: z.string().min(4).max(100) -}); - -const setPincodeSchema = z.object({ - pincode: z.string().length(6) -}); - -const setHeaderAuthSchema = z.object({ - user: z.string().min(4).max(100), - password: z.string().min(4).max(100), - extendedCompatibility: z.boolean() -}); - -type PolicyAuthMethodsSectionProps = { - form: UseFormReturn; -}; - -export function PolicyAuthMethodsSection({ - form -}: PolicyAuthMethodsSectionProps) { - const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const password = form.watch("password"); - const pincode = form.watch("pincode"); - const headerAuth = form.watch("headerAuth"); - - const passwordForm = useForm({ - resolver: zodResolver(setPasswordSchema), - defaultValues: { password: "" } - }); - - const pincodeForm = useForm({ - resolver: zodResolver(setPincodeSchema), - defaultValues: { pincode: "" } - }); - - const headerAuthForm = useForm({ - resolver: zodResolver(setHeaderAuthSchema), - defaultValues: { user: "", password: "", extendedCompatibility: true } - }); - - if (!isOpen) { - return ( - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - - - ); - } - - return ( - <> - {/* Password Credenza */} - { - setIsSetPasswordOpen(val); - if (!val) passwordForm.reset(); - }} - > - - - - {t("resourcePasswordSetupTitle")} - - - {t("resourcePasswordSetupTitleDescription")} - - - -
- { - form.setValue("password", data); - setIsSetPasswordOpen(false); - passwordForm.reset(); - })} - className="space-y-4" - id="set-password-form" - > - ( - - - {t("password")} - - - - - - - )} - /> - - -
- - - - - - -
-
- - {/* Pincode Credenza */} - { - setIsSetPincodeOpen(val); - if (!val) pincodeForm.reset(); - }} - > - - - - {t("resourcePincodeSetupTitle")} - - - {t("resourcePincodeSetupTitleDescription")} - - - -
- { - form.setValue("pincode", data); - setIsSetPincodeOpen(false); - pincodeForm.reset(); - })} - className="space-y-4" - id="set-pincode-form" - > - ( - - - {t("resourcePincode")} - - -
- - - - - - - - - - -
-
- -
- )} - /> - - -
- - - - - - -
-
- - {/* Header Auth Credenza */} - { - setIsSetHeaderAuthOpen(val); - if (!val) headerAuthForm.reset(); - }} - > - - - - {t("resourceHeaderAuthSetupTitle")} - - - {t("resourceHeaderAuthSetupTitleDescription")} - - - -
- { - form.setValue("headerAuth", data); - setIsSetHeaderAuthOpen(false); - headerAuthForm.reset(); - } - )} - className="space-y-4" - id="set-header-auth-form" - > - ( - - {t("user")} - - - - - - )} - /> - ( - - - {t("password")} - - - - - - - )} - /> - ( - - - - - - - )} - /> - - -
- - - - - - -
-
- - - - - {t("resourceAuthMethods")} - - - {t("resourcePolicyAuthMethodsDescription")} - - - - - {/* Password row */} -
-
- - - {t("resourcePasswordProtection", { - status: password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Pincode row */} -
-
- - - {t("resourcePincodeProtection", { - status: pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header auth row */} -
-
- - - {headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - ); -} - -// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── - -type PolicyOtpEmailSectionProps = { - form: UseFormReturn; - emailEnabled: boolean; -}; - -export function PolicyOtpEmailSection({ - form, - emailEnabled -}: PolicyOtpEmailSectionProps) { - const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); - const [whitelistEnabled, setWhitelistEnabled] = useState(false); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - if (!isOpen) { - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - - - ); - } - - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - { - setWhitelistEnabled(val); - form.setValue("emailWhitelistEnabled", val); - }} - disabled={!emailEnabled} - /> - - {whitelistEnabled && emailEnabled && ( - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t("otpEmailEnter")} - tags={form.getValues().emails} - setTags={(newEmails) => { - form.setValue( - "emails", - newEmails as [Tag, ...Tag[]] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - )} - - - - ); -} - -// ─── PolicyRulesSection ─────────────────────────────────────────────────────── - -type PolicyRulesSectionProps = { - form: UseFormReturn; - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; -}; - -export function PolicyRulesSection({ - form, - isMaxmindAvailable, - isMaxmindAsnAvailable -}: PolicyRulesSectionProps) { - const t = useTranslations(); - const [isOpen, setIsOpen] = useState(false); - const [rules, setRules] = useState([]); - const [rulesEnabled, setRulesEnabled] = useState(false); - const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = - useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); - - const addRuleForm = useForm({ - resolver: zodResolver(addRuleSchema), - defaultValues: { - action: "ACCEPT" as const, - match: "PATH", - value: "" - } - }); - - const RuleAction = useMemo( - () => ({ - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - }), - [t] - ); - - const RuleMatch = useMemo( - () => ({ - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN" - }), - [t] - ); - - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); - }, - [form] - ); - - const addRule = useCallback( - function addRule(data: z.infer) { - const isDuplicate = rules.some( - (rule) => - rule.action === data.action && - rule.match === data.match && - rule.value === data.value - ); - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicate"), - description: t("rulesErrorDuplicateDescription") - }); - return; - } - if (data.match === "CIDR" && !isValidCIDR(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddressRange"), - description: t("rulesErrorInvalidIpAddressRangeDescription") - }); - return; - } - if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidUrl"), - description: t("rulesErrorInvalidUrlDescription") - }); - return; - } - if (data.match === "IP" && !isValidIP(data.value)) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), - description: t("rulesErrorInvalidIpAddressDescription") - }); - return; - } - if ( - data.match === "COUNTRY" && - !COUNTRIES.some((c) => c.code === data.value) - ) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidCountry"), - description: t("rulesErrorInvalidCountryDescription") || "" - }); - return; - } - - let priority = data.priority; - if (priority === undefined) { - priority = - rules.reduce( - (acc, rule) => - rule.priority > acc ? rule.priority : acc, - 0 - ) + 1; - } - - const updatedRules = [ - ...rules, - { - ...data, - ruleId: new Date().getTime(), - new: true, - priority, - enabled: true - } - ]; - setRules(updatedRules); - syncFormRules(updatedRules); - addRuleForm.reset(); - }, - [rules, t, addRuleForm, syncFormRules] - ); - - const removeRule = useCallback( - function removeRule(ruleId: number) { - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const getValueHelpText = useCallback( - function getValueHelpText(type: string) { - switch (type) { - case "CIDR": - return t("rulesMatchIpAddressRangeDescription"); - case "IP": - return t("rulesMatchIpAddress"); - case "PATH": - return t("rulesMatchUrl"); - case "COUNTRY": - return t("rulesMatchCountry"); - case "ASN": - return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; - } - }, - [t] - ); - - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const parsed = z.coerce - .number() - .int() - .optional() - .safeParse(e.target.value); - if (!parsed.success) { - toast({ - variant: "destructive", - title: t("rulesErrorInvalidPriority"), - description: t( - "rulesErrorInvalidPriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: parsed.data - }); - }} - /> - ) - }, - { - accessorKey: "action", - header: () => {t("rulesAction")}, - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "match", - header: () => ( - {t("rulesMatchType")} - ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "value", - header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( - - - - - - - - - - {t("noCountryFound")} - - - {COUNTRIES.map((country) => ( - - updateRule( - row.original.ruleId, - { - value: country.code - } - ) - } - > - - {country.name} ( - {country.code}) - - ))} - - - - - - ) : row.original.match === "ASN" ? ( - - - - - - - - - - No ASN found. Enter a custom ASN - below. - - - {MAJOR_ASNS.map((asn) => ( - - updateRule( - row.original.ruleId, - { value: asn.code } - ) - } - > - - {asn.name} ({asn.code}) - - ))} - - - -
- - asn.code === - row.original.value - ) - ? row.original.value - : "" - } - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = - e.currentTarget.value - .toUpperCase() - .replace(/^AS/, ""); - if (/^\d+$/.test(value)) { - updateRule( - row.original.ruleId, - { value: "AS" + value } - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - updateRule(row.original.ruleId, { - value: e.target.value - }) - } - /> - ) - }, - { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( - - updateRule(row.original.ruleId, { enabled: val }) - } - /> - ) - }, - { - id: "actions", - header: () => {t("actions")}, - cell: ({ row }) => ( -
- -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule - ] - ); - - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - - if (!isOpen) { - return ( - - - - {t("rulesResource")} - - - {t("rulesResourcePolicyDescription")} - - - - - - - ); - } - - return ( - - - - {t("rulesResource")} - - - {t("rulesResourceDescription")} - - - -
-
- { - setRulesEnabled(val); - form.setValue("applyRules", val); - }} - /> -
- -
- -
- ( - - - {t("rulesAction")} - - - - - - - )} - /> - ( - - - {t("rulesMatchType")} - - - - - - - )} - /> - ( - - - - {addRuleForm.watch("match") === - "COUNTRY" ? ( - - - - - - - - - - {t( - "noCountryFound" - )} - - - {COUNTRIES.map( - ( - country - ) => ( - { - field.onChange( - country.code - ); - setOpenAddRuleCountrySelect( - false - ); - }} - > - - { - country.name - }{" "} - ( - { - country.code - } - - ) - - ) - )} - - - - - - ) : addRuleForm.watch( - "match" - ) === "ASN" ? ( - - - - - - - - - - No ASN - found. - Use the - custom - input - below. - - - {MAJOR_ASNS.map( - ( - asn - ) => ( - { - field.onChange( - asn.code - ); - setOpenAddRuleAsnSelect( - false - ); - }} - > - - { - asn.name - }{" "} - ( - { - asn.code - } - - ) - - ) - )} - - - -
- { - if ( - e.key === - "Enter" - ) { - const value = - e.currentTarget.value - .toUpperCase() - .replace( - /^AS/, - "" - ); - if ( - /^\d+$/.test( - value - ) - ) { - field.onChange( - "AS" + - value - ); - setOpenAddRuleAsnSelect( - false - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - - )} -
- -
- )} - /> - -
-
- - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - const isActionsColumn = - header.column.id === "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column - .columnDef.header, - header.getContext() - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const isActionsColumn = - cell.column.id === "actions"; - return ( - - {flexRender( - cell.column.columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - - {t("rulesNoOne")} - - - )} - -
-
-
-
- ); -} diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts index 8579a6de5..1010d006e 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -46,13 +46,6 @@ export const createPolicySchema = z.object({ export type PolicyFormValues = z.infer; -export const addRuleSchema = z.object({ - action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.string(), - value: z.string(), - priority: z.coerce.number().int().optional() -}); - export type LocalRule = { ruleId: number; action: "ACCEPT" | "DROP" | "PASS"; @@ -63,3 +56,17 @@ export type LocalRule = { new?: boolean; updated?: boolean; }; + +export { + createPolicyRulePrioritySchema, + createPolicyRuleSchema, + createPolicyRuleValueSchema, + createPolicyRulesArraySchema, + createPolicyRulesSectionSchema, + createPolicySchemaWithI18n, + getPolicyRuleValidationMessage, + validatePolicyRulePriority, + validatePolicyRuleValue, + validatePolicyRulesForSave, + type RuleValidationToast +} from "./policy-access-rule-validation"; diff --git a/src/components/resource-policy/policy-access-rule-utils.ts b/src/components/resource-policy/policy-access-rule-utils.ts new file mode 100644 index 000000000..07302caed --- /dev/null +++ b/src/components/resource-policy/policy-access-rule-utils.ts @@ -0,0 +1,29 @@ +export type EmptyRuleDraft = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new: true; +}; + +export function createEmptyRule( + existingRules: Array<{ priority: number }> +): EmptyRuleDraft { + const priority = + existingRules.reduce( + (acc, rule) => (rule.priority > acc ? rule.priority : acc), + 0 + ) + 1; + + return { + ruleId: Date.now(), + action: "ACCEPT", + match: "PATH", + value: "", + priority, + enabled: true, + new: true + }; +} diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts new file mode 100644 index 000000000..e13fb9468 --- /dev/null +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -0,0 +1,237 @@ +import { COUNTRIES } from "@server/db/countries"; +import { isValidRegionId } from "@server/db/regions"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import z from "zod"; + +type TranslateFn = ( + key: string, + values?: Record +) => string; + +export type RuleValidationToast = { + title: string; + description: string; +}; + +export function getPolicyRuleValidationMessage( + t: TranslateFn, + issue: z.core.$ZodIssue +): string { + const ruleIndex = issue.path.find((segment) => typeof segment === "number"); + if (typeof ruleIndex === "number") { + return t("rulesErrorValidationRuleDescription", { + ruleNumber: ruleIndex + 1, + message: issue.message + }); + } + return issue.message; +} + +export function createPolicyRulePrioritySchema(t: TranslateFn) { + return z.coerce + .number({ error: t("rulesErrorInvalidPriorityDescription") }) + .int({ message: t("rulesErrorInvalidPriorityDescription") }) + .min(1, { message: t("rulesErrorInvalidPriorityDescription") }); +} + +export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { + const required = z + .string() + .min(1, { message: t("rulesErrorValueRequired") }); + + switch (match) { + case "CIDR": + return required.refine(isValidCIDR, { + message: t("rulesErrorInvalidIpAddressRangeDescription") + }); + case "IP": + return required.refine(isValidIP, { + message: t("rulesErrorInvalidIpAddressDescription") + }); + case "PATH": + return required.refine(isValidUrlGlobPattern, { + message: t("rulesErrorInvalidUrlDescription") + }); + case "REGION": + return required.refine(isValidRegionId, { + message: t("rulesErrorInvalidRegionDescription") + }); + case "COUNTRY": + return required.refine( + (value) => COUNTRIES.some((country) => country.code === value), + { message: t("rulesErrorInvalidCountryDescription") } + ); + case "ASN": + return required.refine((value) => /^AS\d+$/i.test(value.trim()), { + message: t("rulesErrorInvalidAsnDescription") + }); + default: + return required; + } +} + +export function createPolicyRuleSchema(t: TranslateFn) { + return z + .object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + .superRefine((rule, ctx) => { + const priorityResult = createPolicyRulePrioritySchema(t).safeParse( + rule.priority + ); + if (!priorityResult.success) { + ctx.addIssue({ + code: "custom", + message: + priorityResult.error.issues[0]?.message ?? + t("rulesErrorInvalidPriorityDescription"), + path: ["priority"] + }); + } + + const valueResult = createPolicyRuleValueSchema( + t, + rule.match + ).safeParse(rule.value); + if (!valueResult.success) { + ctx.addIssue({ + code: "custom", + message: + valueResult.error.issues[0]?.message ?? + t("rulesErrorValueRequired"), + path: ["value"] + }); + } + }); +} + +export function createPolicyRulesArraySchema(t: TranslateFn) { + return z.array(createPolicyRuleSchema(t)).superRefine((rules, ctx) => { + const seenPriorities = new Set(); + rules.forEach((rule, index) => { + if (seenPriorities.has(rule.priority)) { + ctx.addIssue({ + code: "custom", + message: t("rulesErrorDuplicatePriorityDescription"), + path: [index, "priority"] + }); + } + seenPriorities.add(rule.priority); + }); + }); +} + +export function createPolicyRulesSectionSchema(t: TranslateFn) { + return z.object({ + applyRules: z.boolean(), + rules: createPolicyRulesArraySchema(t) + }); +} + +export function createPolicySchemaWithI18n( + t: TranslateFn, + baseSchema: z.ZodObject +) { + return baseSchema.extend({ + rules: createPolicyRulesArraySchema(t) + }); +} + +export function validatePolicyRulePriority( + t: TranslateFn, + value: unknown +): + | { success: true; data: number } + | { success: false; toast: RuleValidationToast } { + const result = createPolicyRulePrioritySchema(t).safeParse(value); + if (result.success) { + return { success: true, data: result.data }; + } + + return { + success: false, + toast: { + title: t("rulesErrorInvalidPriority"), + description: + result.error.issues[0]?.message ?? + t("rulesErrorInvalidPriorityDescription") + } + }; +} + +export function validatePolicyRuleValue( + t: TranslateFn, + match: string, + value: string +): + | { success: true; data: string } + | { success: false; toast: RuleValidationToast } { + const result = createPolicyRuleValueSchema(t, match).safeParse(value); + if (result.success) { + return { success: true, data: result.data }; + } + + const issue = result.error.issues[0]; + const titleKey = + match === "CIDR" + ? "rulesErrorInvalidIpAddressRange" + : match === "IP" + ? "rulesErrorInvalidIpAddress" + : match === "PATH" + ? "rulesErrorInvalidUrl" + : match === "REGION" + ? "rulesErrorInvalidRegion" + : match === "COUNTRY" + ? "rulesErrorInvalidCountry" + : match === "ASN" + ? "rulesErrorInvalidAsn" + : "rulesErrorValidation"; + + return { + success: false, + toast: { + title: t(titleKey), + description: issue?.message ?? t("rulesErrorValueRequired") + } + }; +} + +export function validatePolicyRulesForSave( + t: TranslateFn, + rules: Array<{ + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + }>, + applyRules: boolean +): { success: true } | { success: false; toast: RuleValidationToast } { + if (!applyRules) { + return { success: true }; + } + + const result = createPolicyRulesArraySchema(t).safeParse(rules); + if (result.success) { + return { success: true }; + } + + const issue = result.error.issues[0]; + return { + success: false, + toast: { + title: t("rulesErrorValidation"), + description: issue + ? getPolicyRuleValidationMessage(t, issue) + : t("rulesErrorUpdateDescription") + } + }; +} diff --git a/src/components/resource-policy/policy-auth-method-id.ts b/src/components/resource-policy/policy-auth-method-id.ts new file mode 100644 index 000000000..a886735b4 --- /dev/null +++ b/src/components/resource-policy/policy-auth-method-id.ts @@ -0,0 +1,21 @@ +import z from "zod"; + +export type PolicyAuthMethodId = + | "pincode" + | "passcode" + | "email" + | "headerAuth"; + +export const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +export const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +export const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); diff --git a/src/components/resource-policy/policy-auth-summaries.ts b/src/components/resource-policy/policy-auth-summaries.ts new file mode 100644 index 000000000..21898553e --- /dev/null +++ b/src/components/resource-policy/policy-auth-summaries.ts @@ -0,0 +1,45 @@ +type SummaryParams = { + t: (key: string, values?: Record) => string; +}; + +type SsoSummaryParams = SummaryParams & { + idpName?: string; + userCount: number; + roleCount: number; +}; + +export function getSsoSummary({ + t, + idpName, + userCount, + roleCount +}: SsoSummaryParams) { + const idp = idpName ?? t("policyAuthSsoDefaultIdp"); + return t("policyAuthSsoSummary", { + idp, + users: userCount, + roles: roleCount + }); +} + +export function getPasscodeSummary({ t }: SummaryParams) { + return t("policyAuthPasscodeSummary"); +} + +export function getPincodeSummary({ t }: SummaryParams) { + return t("policyAuthPincodeSummary"); +} + +export function getEmailWhitelistSummary({ + t, + count +}: SummaryParams & { count: number }) { + return t("policyAuthEmailSummary", { count }); +} + +export function getHeaderAuthSummary({ + t, + headerName +}: SummaryParams & { headerName: string }) { + return headerName || t("policyAuthHeaderAuthSummary"); +} diff --git a/src/components/ui/data-table-empty-state.tsx b/src/components/ui/data-table-empty-state.tsx index e7da09f03..e734f7726 100644 --- a/src/components/ui/data-table-empty-state.tsx +++ b/src/components/ui/data-table-empty-state.tsx @@ -9,11 +9,13 @@ const PLACEHOLDER_ROW_COUNT = 5; type DataTableEmptyStateProps = { colSpan: number; action?: ReactNode; + message?: string; }; export function DataTableEmptyState({ colSpan, - action + action, + message }: DataTableEmptyStateProps) { const t = useTranslations(); return ( @@ -32,7 +34,7 @@ export function DataTableEmptyState({

- {t("noResults")} + {message ?? t("noResults")}

{action}
From 7b1f8d98f308ae499a250295e4a2c574057b5f84 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 6 Jun 2026 12:27:11 -0700 Subject: [PATCH 19/28] make the rule rows draggable --- messages/en-US.json | 1 + .../CreatePolicyRulesSectionForm.tsx | 587 +------------ .../PolicyAccessRulesSection.tsx | 747 +--------------- .../PolicyAccessRulesTable.tsx | 824 ++++++++++++++++++ src/components/resource-policy/index.ts | 9 + .../policy-access-rule-utils.ts | 45 +- 6 files changed, 938 insertions(+), 1275 deletions(-) create mode 100644 src/components/resource-policy/PolicyAccessRulesTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 428d0a2d4..8454467b2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -784,6 +784,7 @@ "ruleErrorUpdate": "Operation failed", "ruleErrorUpdateDescription": "An error occurred during the save operation", "rulesPriority": "Priority", + "rulesReorderDragHandle": "Drag to reorder rule priority", "rulesAction": "Action", "rulesMatchType": "Match Type", "value": "Value", diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx index c78ebd37f..042b63e63 100644 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -12,76 +12,18 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; -import { toast } from "@app/hooks/useToast"; -import { - validatePolicyRulePriority, - validatePolicyRuleValue -} from "./policy-access-rule-validation"; - import { Button } from "@app/components/ui/button"; -import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; - -import { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; +import { Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; -import { createEmptyRule } from "./policy-access-rule-utils"; - -// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── - -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; -}; +import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { + createEmptyRule, + type PolicyAccessRule +} from "./policy-access-rule-utils"; export type CreatePolicyRulesSectionFormProps = { form: UseFormReturn; @@ -95,7 +37,7 @@ export function CreatePolicyRulesSectionForm({ isMaxmindAsnAvailable }: CreatePolicyRulesSectionFormProps) { const t = useTranslations(); - const [rules, setRules] = useState([]); + const [rules, setRules] = useState([]); const rulesFormSchema = useMemo( () => createPolicyRulesSectionSchema(t), @@ -123,28 +65,8 @@ export function CreatePolicyRulesSectionForm({ name: "applyRules" }); - const RuleAction = useMemo( - () => ({ - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - }), - [t] - ); - - const RuleMatch = useMemo( - () => ({ - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN" - }), - [t] - ); - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { + (updatedRules: PolicyAccessRule[]) => { form.setValue( "rules", updatedRules.map( @@ -177,7 +99,7 @@ export function CreatePolicyRulesSectionForm({ ); const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { + function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } @@ -189,369 +111,14 @@ export function CreatePolicyRulesSectionForm({ [rules, syncFormRules] ); - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - size: 96, - maxSize: 96, - header: ({ column }) => ( -
- -
- ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const validated = validatePolicyRulePriority( - t, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast - }); - return; - } - const duplicatePriority = rules.some( - (rule) => - rule.ruleId !== row.original.ruleId && - rule.priority === validated.data - ); - if (duplicatePriority) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicatePriority"), - description: t( - "rulesErrorDuplicatePriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: validated.data - }); - }} - /> - ) - }, - { - accessorKey: "action", - size: 160, - maxSize: 160, - header: () => {t("rulesAction")}, - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "match", - size: 144, - maxSize: 144, - header: () => ( - {t("rulesMatchType")} - ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "value", - header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( - - - - - - - - - - {t("noCountryFound")} - - - {COUNTRIES.map((country) => ( - - updateRule( - row.original.ruleId, - { - value: country.code - } - ) - } - > - - {country.name} ( - {country.code}) - - ))} - - - - - - ) : row.original.match === "ASN" ? ( - - - - - - - - - - No ASN found. Enter a custom ASN - below. - - - {MAJOR_ASNS.map((asn) => ( - - updateRule( - row.original.ruleId, - { value: asn.code } - ) - } - > - - {asn.name} ({asn.code}) - - ))} - - - -
- - asn.code === - row.original.value - ) - ? row.original.value - : "" - } - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = - e.currentTarget.value - .toUpperCase() - .replace(/^AS/, ""); - if (/^\d+$/.test(value)) { - updateRule( - row.original.ruleId, - { value: "AS" + value } - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : ( - { - const validated = validatePolicyRuleValue( - t, - row.original.match, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast - }); - return; - } - updateRule(row.original.ruleId, { - value: validated.data - }); - }} - /> - ) - }, - { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( -
- - updateRule(row.original.ruleId, { - enabled: val - }) - } - /> -
- ) - }, - { - id: "actions", - header: () => null, - cell: ({ row }) => ( -
- -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule, - rules - ] + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] ); - const table = useReactTable({ - data: rules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - const addRuleButton = ( ); + const hasRules = rules.length > 0; + return ( @@ -580,117 +149,17 @@ export function CreatePolicyRulesSectionForm({ {rulesEnabled && ( <> - - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const columnId = - header.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const columnId = - cell.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - )} - -
- {table.getRowModel().rows?.length > 0 && - addRuleButton} + + {hasRules && addRuleButton} )}
diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index daa7eba32..196fd9b21 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -15,64 +15,12 @@ import { useTranslations } from "next-intl"; import { toast } from "@app/hooks/useToast"; import { createPolicyRulesSectionSchema, - validatePolicyRulePriority, - validatePolicyRuleValue, validatePolicyRulesForSave, type PolicyFormValues } from "."; import { Button } from "@app/components/ui/button"; -import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; - -import { MAJOR_ASNS } from "@server/db/asns"; -import { COUNTRIES } from "@server/db/countries"; -import { REGIONS, getRegionNameById } from "@server/db/regions"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable -} from "@tanstack/react-table"; -import { - ArrowUpDown, - Check, - ChevronsUpDown, - LockIcon, - Plus -} from "lucide-react"; +import { Plus } from "lucide-react"; import { useCallback, @@ -92,22 +40,14 @@ import type { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; -import { createEmptyRule } from "./policy-access-rule-utils"; +import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { + createEmptyRule, + type PolicyAccessRule +} from "./policy-access-rule-utils"; // ─── PolicyRulesSection ─────────────────────────────────────────────────────── -type LocalRule = { - ruleId: number; - action: "ACCEPT" | "DROP" | "PASS"; - match: string; - value: string; - priority: number; - enabled: boolean; - new?: boolean; - updated?: boolean; - fromPolicy?: boolean; -}; - type PolicyAccessRulesSectionEditProps = { mode: "edit"; isMaxmindAvailable: boolean; @@ -148,7 +88,6 @@ function PolicyAccessRulesSectionEdit({ const isResourceOverlay = resourceId !== undefined; - // ── Fetch resource-specific rules when in overlay mode ─────────────────── const { data: resourceRulesData } = useQuery({ ...resourceQueries.resourceRules({ resourceId: resourceId! }), enabled: isResourceOverlay @@ -176,17 +115,16 @@ function PolicyAccessRulesSectionEdit({ name: "applyRules" }); - const [rules, setRules] = useState( + const [rules, setRules] = useState( policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) ); - // Initialize resource-specific rules once fetched useEffect(() => { if (!isResourceOverlay || resourceRulesInitialized) return; if (!resourceRulesData) return; const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); - const resourceSpecific: LocalRule[] = resourceRulesData + const resourceSpecific: PolicyAccessRule[] = resourceRulesData .filter((r) => !policyRuleIds.has(r.ruleId)) .map((r) => ({ ruleId: r.ruleId, @@ -210,29 +148,8 @@ function PolicyAccessRulesSectionEdit({ policy.rules ]); - const RuleAction = useMemo( - () => ({ - ACCEPT: t("alwaysAllow"), - DROP: t("alwaysDeny"), - PASS: t("passToAuth") - }), - [t] - ); - - const RuleMatch = useMemo( - () => ({ - PATH: t("path"), - IP: "IP", - CIDR: t("ipAddressRange"), - COUNTRY: t("country"), - ASN: "ASN", - REGION: t("region") - }), - [t] - ); - const syncFormRules = useCallback( - (updatedRules: LocalRule[]) => { + (updatedRules: PolicyAccessRule[]) => { form.setValue( "rules", updatedRules.map( @@ -258,8 +175,7 @@ function PolicyAccessRulesSectionEdit({ const removeRule = useCallback( function removeRule(ruleId: number) { const rule = rules.find((r) => r.ruleId === ruleId); - if (!rule || rule.fromPolicy) return; // cannot remove policy rules - // Track deletion for resource overlay mode (only for existing DB rules) + if (!rule || rule.fromPolicy) return; if (isResourceOverlay && !rule.new) { deletedResourceRuleIdsRef.current.add(ruleId); } @@ -271,7 +187,7 @@ function PolicyAccessRulesSectionEdit({ ); const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { + function updateRule(ruleId: number, data: Partial) { const updatedRules = rules.map((rule) => rule.ruleId === ruleId ? { ...rule, ...data, updated: true } @@ -283,517 +199,14 @@ function PolicyAccessRulesSectionEdit({ [rules, syncFormRules] ); - const sortedRules = useMemo( - () => [...rules].sort((a, b) => a.priority - b.priority), - [rules] + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] ); - const columns: ColumnDef[] = useMemo( - () => [ - { - accessorKey: "priority", - size: 96, - maxSize: 96, - header: ({ column }) => ( -
- -
- ), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const validated = validatePolicyRulePriority( - t, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast - }); - return; - } - const duplicatePriority = rules.some( - (rule) => - rule.ruleId !== row.original.ruleId && - rule.priority === validated.data - ); - if (duplicatePriority) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicatePriority"), - description: t( - "rulesErrorDuplicatePriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: validated.data - }); - }} - /> - ) - }, - { - accessorKey: "action", - size: 160, - maxSize: 160, - header: () => {t("rulesAction")}, - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "match", - size: 144, - maxSize: 144, - header: () => ( - {t("rulesMatchType")} - ), - cell: ({ row }) => ( - - ) - }, - { - accessorKey: "value", - header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( - - - - - - - - - - {t("noCountryFound")} - - - {COUNTRIES.map((country) => ( - - updateRule( - row.original.ruleId, - { - value: country.code - } - ) - } - > - - {country.name} ( - {country.code}) - - ))} - - - - - - ) : row.original.match === "ASN" ? ( - - - - - - - - - - No ASN found. Enter a custom ASN - below. - - - {MAJOR_ASNS.map((asn) => ( - - updateRule( - row.original.ruleId, - { value: asn.code } - ) - } - > - - {asn.name} ({asn.code}) - - ))} - - - -
- - asn.code === - row.original.value - ) - ? row.original.value - : "" - } - onKeyDown={(e) => { - if (e.key === "Enter") { - const value = - e.currentTarget.value - .toUpperCase() - .replace(/^AS/, ""); - if (/^\d+$/.test(value)) { - updateRule( - row.original.ruleId, - { value: "AS" + value } - ); - } - } - }} - className="text-sm" - /> -
-
-
- ) : row.original.match === "REGION" ? ( - - - - - - - - - - {t("noRegionFound")} - - {REGIONS.map((continent) => ( - - - updateRule( - row.original.ruleId, - { - value: continent.id - } - ) - } - > - - {t(continent.name)} ( - {continent.id}) - - {continent.includes.map( - (subregion) => ( - - updateRule( - row.original - .ruleId, - { - value: subregion.id - } - ) - } - > - - {t(subregion.name)}{" "} - ({subregion.id}) - - ) - )} - - ))} - - - - - ) : ( - { - const validated = validatePolicyRuleValue( - t, - row.original.match, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast - }); - return; - } - updateRule(row.original.ruleId, { - value: validated.data - }); - }} - /> - ) - }, - { - accessorKey: "enabled", - header: () => {t("enabled")}, - cell: ({ row }) => ( -
- - updateRule(row.original.ruleId, { - enabled: val - }) - } - /> -
- ) - }, - { - id: "actions", - header: () => null, - cell: ({ row }) => ( -
- {row.original.fromPolicy ? ( - - ) : ( - - )} -
- ) - } - ], - [ - t, - RuleAction, - RuleMatch, - isMaxmindAvailable, - isMaxmindAsnAvailable, - updateRule, - removeRule, - readonly, - rules - ] - ); - - const table = useReactTable({ - data: sortedRules, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { pagination: { pageIndex: 0, pageSize: 1000 } } - }); - const [isPending, startTransition] = useTransition(); async function saveRules() { @@ -930,6 +343,8 @@ function PolicyAccessRulesSectionEdit({ ); + const hasRules = rules.length > 0; + return ( @@ -952,117 +367,19 @@ function PolicyAccessRulesSectionEdit({ {rulesEnabled && ( <> - - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const columnId = - header.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const columnId = - cell.column.id; - const isActionsColumn = - columnId === - "actions"; - const isPriorityColumn = - columnId === - "priority"; - const isActionColumn = - columnId === - "action"; - const isMatchColumn = - columnId === - "match"; - return ( - - {flexRender( - cell.column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - )} - -
- {table.getRowModel().rows?.length > 0 && - addRuleButton} + + {hasRules && addRuleButton} )}
diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx new file mode 100644 index 000000000..4f1052d4f --- /dev/null +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -0,0 +1,824 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { toast } from "@app/hooks/useToast"; +import { cn } from "@app/lib/cn"; +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { REGIONS, getRegionNameById } from "@server/db/regions"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Check, + ChevronsUpDown, + GripVertical, + LockIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + useCallback, + useMemo, + useState, + type DragEvent, + type ReactNode +} from "react"; +import { + validatePolicyRulePriority, + validatePolicyRuleValue +} from "./policy-access-rule-validation"; +import { + reorderPolicyRules, + sortPolicyRulesByPriority, + type PolicyAccessRule +} from "./policy-access-rule-utils"; + +export type PolicyAccessRulesTableProps = { + rules: PolicyAccessRule[]; + onRulesChange: (rules: PolicyAccessRule[]) => void; + updateRule: (ruleId: number, data: Partial) => void; + removeRule: (ruleId: number) => void; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + emptyStateAction: ReactNode; + readonly?: boolean; + includeRegionMatch?: boolean; + markUpdatedOnReorder?: boolean; + isRuleDraggable?: (rule: PolicyAccessRule) => boolean; + isRuleLocked?: (rule: PolicyAccessRule) => boolean; +}; + +function getColumnClassName(columnId: string) { + if (columnId === "actions") { + return "sticky right-0 z-10 w-[1%] min-w-fit bg-card text-right"; + } + if (columnId === "dragHandle") { + return "w-8 max-w-8 px-2"; + } + if (columnId === "priority") { + return "w-24 max-w-24"; + } + if (columnId === "action") { + return "w-40 max-w-40"; + } + if (columnId === "match") { + return "w-36 max-w-36"; + } + return ""; +} + +export function PolicyAccessRulesTable({ + rules, + onRulesChange, + updateRule, + removeRule, + isMaxmindAvailable, + isMaxmindAsnAvailable, + emptyStateAction, + readonly = false, + includeRegionMatch = false, + markUpdatedOnReorder = false, + isRuleDraggable: isRuleDraggableProp, + isRuleLocked: isRuleLockedProp +}: PolicyAccessRulesTableProps) { + const t = useTranslations(); + const [draggedRuleId, setDraggedRuleId] = useState(null); + const [dragOverRuleId, setDragOverRuleId] = useState(null); + + const isRuleLocked = useCallback( + (rule: PolicyAccessRule) => + isRuleLockedProp + ? isRuleLockedProp(rule) + : Boolean(rule.fromPolicy), + [isRuleLockedProp] + ); + + const isRuleDraggable = useCallback( + (rule: PolicyAccessRule) => + isRuleDraggableProp + ? isRuleDraggableProp(rule) + : !readonly && !isRuleLocked(rule), + [isRuleDraggableProp, isRuleLocked, readonly] + ); + + const sortedRules = useMemo( + () => sortPolicyRulesByPriority(rules), + [rules] + ); + + const handleReorder = useCallback( + (fromRuleId: number, toRuleId: number) => { + const fromIndex = sortedRules.findIndex( + (rule) => rule.ruleId === fromRuleId + ); + const toIndex = sortedRules.findIndex( + (rule) => rule.ruleId === toRuleId + ); + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + + const reordered = reorderPolicyRules( + sortedRules, + fromIndex, + toIndex, + { markUpdated: markUpdatedOnReorder } + ); + onRulesChange(reordered); + }, + [sortedRules, onRulesChange, markUpdatedOnReorder] + ); + + const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { + setDraggedRuleId(ruleId); + e.dataTransfer.effectAllowed = "move"; + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedRuleId(null); + setDragOverRuleId(null); + }, []); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN", + REGION: t("region") + }), + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "dragHandle", + size: 32, + maxSize: 32, + header: () => null, + cell: ({ row }) => + isRuleDraggable(row.original) ? ( + + ) : null + }, + { + accessorKey: "priority", + size: 96, + maxSize: 96, + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + const duplicatePriority = rules.some( + (rule) => + rule.ruleId !== row.original.ruleId && + rule.priority === validated.data + ); + if (duplicatePriority) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicatePriority"), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: validated.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + size: 160, + maxSize: 160, + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + size: 144, + maxSize: 144, + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : row.original.match === "REGION" ? ( + + + + + + + + + + {t("noRegionFound")} + + {REGIONS.map((continent) => ( + + + updateRule( + row.original.ruleId, + { + value: continent.id + } + ) + } + > + + {t(continent.name)} ( + {continent.id}) + + {continent.includes.map( + (subregion) => ( + + updateRule( + row.original + .ruleId, + { + value: subregion.id + } + ) + } + > + + {t(subregion.name)}{" "} + ({subregion.id}) + + ) + )} + + ))} + + + + + ) : ( + { + const validated = validatePolicyRuleValue( + t, + row.original.match, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + updateRule(row.original.ruleId, { + value: validated.data + }); + }} + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( +
+ + updateRule(row.original.ruleId, { + enabled: val + }) + } + /> +
+ ) + }, + { + id: "actions", + header: () => null, + cell: ({ row }) => ( +
+ {isRuleLocked(row.original) ? ( + + ) : ( + + )} +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + includeRegionMatch, + updateRule, + removeRule, + readonly, + rules, + isRuleDraggable, + isRuleLocked, + handleDragStart, + handleDragEnd + ] + ); + + const table = useReactTable({ + data: sortedRules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const columnId = header.column.id; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const rule = row.original; + return ( + { + e.preventDefault(); + if ( + draggedRuleId !== null && + draggedRuleId !== rule.ruleId + ) { + setDragOverRuleId(rule.ruleId); + } + }} + onDrop={(e) => { + e.preventDefault(); + if ( + draggedRuleId !== null && + draggedRuleId !== rule.ruleId + ) { + handleReorder( + draggedRuleId, + rule.ruleId + ); + } + setDraggedRuleId(null); + setDragOverRuleId(null); + }} + className={cn( + draggedRuleId === rule.ruleId && + "opacity-50", + dragOverRuleId === rule.ruleId && + "border-t-2 border-primary" + )} + > + {row.getVisibleCells().map((cell) => { + const columnId = cell.column.id; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ); + })} + + ); + }) + ) : ( + + )} + +
+ ); +} diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts index 1010d006e..a0456515b 100644 --- a/src/components/resource-policy/index.ts +++ b/src/components/resource-policy/index.ts @@ -57,6 +57,15 @@ export type LocalRule = { updated?: boolean; }; +export { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +export type { PolicyAccessRulesTableProps } from "./PolicyAccessRulesTable"; +export { + createEmptyRule, + reorderPolicyRules, + sortPolicyRulesByPriority, + type EmptyRuleDraft, + type PolicyAccessRule +} from "./policy-access-rule-utils"; export { createPolicyRulePrioritySchema, createPolicyRuleSchema, diff --git a/src/components/resource-policy/policy-access-rule-utils.ts b/src/components/resource-policy/policy-access-rule-utils.ts index 07302caed..50178a4a5 100644 --- a/src/components/resource-policy/policy-access-rule-utils.ts +++ b/src/components/resource-policy/policy-access-rule-utils.ts @@ -1,10 +1,16 @@ -export type EmptyRuleDraft = { +export type PolicyAccessRule = { ruleId: number; action: "ACCEPT" | "DROP" | "PASS"; match: string; value: string; priority: number; enabled: boolean; + new?: boolean; + updated?: boolean; + fromPolicy?: boolean; +}; + +export type EmptyRuleDraft = PolicyAccessRule & { new: true; }; @@ -27,3 +33,40 @@ export function createEmptyRule( new: true }; } + +export function sortPolicyRulesByPriority( + rules: T[] +): T[] { + return [...rules].sort((a, b) => a.priority - b.priority); +} + +export function reorderPolicyRules< + T extends { priority: number; new?: boolean; updated?: boolean } +>( + rules: T[], + fromIndex: number, + toIndex: number, + options?: { markUpdated?: boolean } +): T[] { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= rules.length || + toIndex >= rules.length + ) { + return rules; + } + + const reordered = [...rules]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + return reordered.map((rule, index) => { + const next = { ...rule, priority: index + 1 }; + if (options?.markUpdated && !rule.new) { + return { ...next, updated: true }; + } + return next; + }); +} From dc8243cb51ec692a89e6050f2205e69162d64381 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 12:27:14 -0700 Subject: [PATCH 20/28] Fix form rendering issue --- src/components/BrowserGatewayTargetForm.tsx | 53 +++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/components/BrowserGatewayTargetForm.tsx b/src/components/BrowserGatewayTargetForm.tsx index 768ac2738..09e6fd6fa 100644 --- a/src/components/BrowserGatewayTargetForm.tsx +++ b/src/components/BrowserGatewayTargetForm.tsx @@ -48,17 +48,46 @@ export type BrowserGatewayTargetFormProps = export function BrowserGatewayTargetForm( props: BrowserGatewayTargetFormProps ) { + // IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES + // Opt out of the React Compiler for this component. + // + // The parent (create page) shares a single `bgTargetForm` instance across + // multiple conditionally-rendered Form sections (SSH passthrough/push, RDP, + // VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the + // resource type changes. react-hook-form's Controller uses an external + // subscription that the React Compiler cannot statically reason about, so + // with `reactCompiler: true` (see next.config.ts) the Compiler can memoize + // the render prop and skip re-rendering the elements when their + // bound form values change. The visible symptom is that typing into the + // destination/port inputs updates form state but the input itself never + // visually updates. The escape hatch is the canonical fix here. + "use no memo"; const t = useTranslations(); const [siteOpen, setSiteOpen] = useState(false); const sitesFieldName = props.multiSite === true ? props.sitesField : props.siteField; + // Subscribe to field values via useWatch and drive the controlled + // elements from these values rather than from the `field.value` returned + // by the Controller render prop. Combined with the "use no memo" directive + // above, this makes the inputs reliably re-render when their bound form + // values change. const watchedSites = useWatch({ control: props.control, name: sitesFieldName }); + const watchedDestination = useWatch({ + control: props.control, + name: props.destinationField + }); + + const watchedDestinationPort = useWatch({ + control: props.control, + name: props.destinationPortField + }); + const showMultiSiteDisclaimer = props.multiSite === true && ((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1; @@ -141,7 +170,17 @@ export function BrowserGatewayTargetForm( {t("destination")} - + @@ -158,8 +197,16 @@ export function BrowserGatewayTargetForm( type="number" min={1} max={65535} - {...field} - value={field.value ?? ""} + name={field.name} + ref={field.ref} + onBlur={field.onBlur} + onChange={field.onChange} + value={ + (watchedDestinationPort as + | string + | number + | undefined) ?? "" + } /> From 4b770d138501843306ad16a6d38d2ab6ebe560e7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 13:34:24 -0700 Subject: [PATCH 21/28] Fix issues --- .../resource-policy/PolicyAuthStackSectionCreate.tsx | 4 ++-- src/components/resource-policy/PolicyAuthStackSectionEdit.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx index c462cd9ae..9deb74998 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx @@ -123,7 +123,7 @@ export function PolicyAuthStackSectionCreate({ } allIdps={allIdps} rolesEditor={ - control={parentForm.control} name="roles" render={({ field }) => ( @@ -146,7 +146,7 @@ export function PolicyAuthStackSectionCreate({ /> } usersEditor={ - control={parentForm.control} name="users" render={({ field }) => ( diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index f24e4360d..2140ad445 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -662,7 +662,8 @@ export function PolicyAuthStackSectionEdit({ user: headerAuth.user, password: headerAuth.password, extendedCompatibility: - headerAuth.extendedCompatibility + headerAuth.extendedCompatibility ?? + true } : undefined } From 8658198a9329e6b5aca30c91a38449f576d186a7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 6 Jun 2026 13:47:23 -0700 Subject: [PATCH 22/28] Remove unnessicary auth token --- .../settings/resources/public/[niceId]/rdp/page.tsx | 1 - .../settings/resources/public/[niceId]/ssh/page.tsx | 8 -------- .../settings/resources/public/[niceId]/vnc/page.tsx | 1 - src/app/[orgId]/settings/resources/public/create/page.tsx | 1 - 4 files changed, 11 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx index 756561bf5..fbf18ecc7 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -178,7 +178,6 @@ function RdpServerForm({ mode: "rdp", ip: destination, port: Number(destinationPort), - authToken: null, hcEnabled: false }) ) diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index c6487ad68..f0e856f69 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -54,7 +54,6 @@ import type { ResourceContextType } from "@app/contexts/resourceContext"; type ExistingTarget = { targetId: number; siteId: number; - authToken?: string | null; }; type TargetRow = { @@ -65,7 +64,6 @@ type TargetRow = { mode: string | null; ip: string; port: number; - authToken?: string | null; }; type ResourceTargetsResponse = { @@ -206,7 +204,6 @@ function SshServerForm({ : targets.map((target) => ({ targetId: target.targetId, siteId: target.siteId, - authToken: target.authToken })) ); @@ -216,7 +213,6 @@ function SshServerForm({ ? { targetId: firstTarget.targetId, siteId: firstTarget.siteId, - authToken: firstTarget.authToken } : null ); @@ -264,7 +260,6 @@ function SshServerForm({ ip: "localhost", port: 22, siteId: nativeSite.siteId, - authToken: nativeExistingTarget.authToken, hcEnabled: false } ); @@ -286,7 +281,6 @@ function SshServerForm({ setNativeExistingTarget({ targetId: res.data.data.targetId, siteId: nativeSite.siteId, - authToken: res.data.data.authToken }); } } @@ -323,7 +317,6 @@ function SshServerForm({ ip: values.destination, port: Number(values.destinationPort), siteId: t.siteId, - authToken: t.authToken, hcEnabled: false }) ) @@ -347,7 +340,6 @@ function SshServerForm({ const newTargets: ExistingTarget[] = created.map((res, i) => ({ targetId: res.data.data.targetId, siteId: toCreate[i].siteId, - authToken: res.data.data.authToken })); setExistingTargets([...toUpdate, ...newTargets]); } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx index 7b2c38399..3efe29ee4 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -178,7 +178,6 @@ function VncServerForm({ mode: "vnc", ip: destination, port: Number(destinationPort), - authToken: null, hcEnabled: false }) ) diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 322330d63..1662ee560 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -638,7 +638,6 @@ export default function Page() { mode: resourceType, ip: bgValues.destination, port: Number(bgValues.destinationPort), - authToken: null, hcEnabled: false } ); From aa47f522ef8de86e638e1efce0f96daaa2270e07 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 6 Jun 2026 15:34:34 -0700 Subject: [PATCH 23/28] move toggle on general page --- messages/en-US.json | 5 + .../public/[niceId]/general/page.tsx | 73 +++++---- .../PolicyAuthMethodCredenzas.tsx | 155 ++++++++++-------- .../PolicyAuthStackSectionCreate.tsx | 2 +- .../PolicyAuthStackSectionEdit.tsx | 4 +- 5 files changed, 136 insertions(+), 103 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 8454467b2..1bf6a30ad 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -843,6 +843,10 @@ "policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderName": "Header name", "policyAuthHeaderValue": "Expected value", + "policyAuthSetPasscode": "Set Passcode", + "policyAuthSetPincode": "Set PIN Code", + "policyAuthSetEmailWhitelist": "Set Email Whitelist", + "policyAuthSetHeaderAuth": "Set Basic Header Auth", "policyAccessRulesTitle": "Access Rules", "policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.", "policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.", @@ -3131,6 +3135,7 @@ "maintenanceModeType": "Maintenance Mode Type", "showMaintenancePage": "Show a maintenance page to visitors", "enableMaintenanceMode": "Enable Maintenance Mode", + "enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.", "automatic": "Automatic", "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", "forced": "Forced", diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index f53afd056..ef909bd9f 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -220,6 +220,11 @@ function MaintenanceSectionForm({
+ + {t( + "enableMaintenanceModeDescription" + )} + ); @@ -586,6 +591,40 @@ export default function GeneralForm() { className="space-y-4" id="general-settings-form" > + ( + + + + form.setValue( + "enabled", + val + ) + } + /> + + + {t( + "disabledResourceDescription" + )} + + + + )} + /> +
)} - - ( - - - - form.setValue( - "enabled", - val - ) - } - /> - - - {t( - "disabledResourceDescription" - )} - - - - )} - /> diff --git a/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx b/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx index 2daebb9f5..caf9092ab 100644 --- a/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx +++ b/src/components/resource-policy/PolicyAuthMethodCredenzas.tsx @@ -117,7 +117,7 @@ export function PasscodeCredenza({ title={t("resourcePasswordSetupTitle")} description={t("resourcePasswordSetupTitleDescription")} formId="policy-passcode-form" - submitLabel={t("resourcePasswordSubmit")} + submitLabel={t("policyAuthSetPasscode")} >
void; + onSave: (emails: Tag[]) => void; }; export function EmailCredenza({ @@ -377,12 +377,19 @@ export function EmailCredenza({ emailEnabled, disabled, emails, - onEmailsChange + onSave }: EmailCredenzaProps) { const t = useTranslations(); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); + const [draftEmails, setDraftEmails] = useState(emails); + + useEffect(() => { + if (open) { + setDraftEmails(emails); + } + }, [open, emails]); return ( @@ -394,72 +401,90 @@ export function EmailCredenza({ -
- {!emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - {emailEnabled && ( -

- {t("otpEmailWhitelistListDescription")} -

- )} - {emailEnabled && ( - - - {t("otpEmailWhitelistList")} - - - { - if (!disabled) { - onEmailsChange( - newEmails as Tag[] - ); + { + event.preventDefault(); + onSave(draftEmails); + onOpenChange(false); + }} + > +
+ {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + {emailEnabled && ( +

+ {t("otpEmailWhitelistListDescription")} +

+ )} + {emailEnabled && ( + + + {t("otpEmailWhitelistList")} + + + - z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/ - ) - ) - .safeParse(tag).success - } - allowDuplicates={false} - sortTags - size="sm" - disabled={disabled} - /> - - - {t("otpEmailEnterDescription")} - - - )} -
+ placeholder={t("otpEmailEnter")} + tags={draftEmails} + setTags={(newEmails) => { + if (!disabled) { + setDraftEmails( + newEmails as Tag[] + ); + } + }} + validateTag={(tag) => + z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/ + ) + ) + .safeParse(tag).success + } + allowDuplicates={false} + sortTags + size="sm" + disabled={disabled} + /> +
+ + {t("otpEmailEnterDescription")} + +
+ )} +
+
+ {emailEnabled && ( + + )}
diff --git a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx index c462cd9ae..3f9631b02 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionCreate.tsx @@ -279,7 +279,7 @@ export function PolicyAuthStackSectionCreate({ onOpenChange={(open) => !open && closeCredenza()} emailEnabled={emailEnabled} emails={emails} - onEmailsChange={(value) => + onSave={(value) => parentForm.setValue( "emails", value as PolicyFormValues["emails"] diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx index f24e4360d..7ecd7a8a8 100644 --- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx +++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx @@ -648,9 +648,7 @@ export function PolicyAuthStackSectionEdit({ emailEnabled={emailEnabled} disabled={authReadonly} emails={emails} - onEmailsChange={(value) => - form.setValue("emails", value) - } + onSave={(value) => form.setValue("emails", value)} /> Date: Sat, 6 Jun 2026 16:14:20 -0700 Subject: [PATCH 24/28] Update traefik config --- .../private/lib/traefik/getTraefikConfig.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 901c88f49..e81715d3b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -171,8 +171,15 @@ export async function getTraefikConfig( ), inArray(sites.type, siteTypes), allowRawResources - ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three - : eq(resources.mode, "http") + ? inArray(resources.mode, [ + "http", + "udp", + "tcp", + "vnc", + "ssh", + "rdp" + ]) // allow all three + : inArray(resources.mode, ["http", "vnc", "ssh", "rdp"]) ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering @@ -180,9 +187,9 @@ export async function getTraefikConfig( // Group by resource and include targets with their unique site data const resourcesMap = new Map(); - resourcesWithTargetsAndSites.forEach((row) => { + for (const row of resourcesWithTargetsAndSites) { if (!["http", "tcp", "udp"].includes(row.mode)) { - return; + continue; } const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; @@ -193,7 +200,7 @@ export async function getTraefikConfig( const priority = row.priority ?? 100; if (filterOutNamespaceDomains && row.domainNamespaceId) { - return; + continue; } // Create a unique key combining resourceId, path config, and rewrite config @@ -220,7 +227,7 @@ export async function getTraefikConfig( logger.debug( `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` ); - return; + continue; } resourcesMap.set(mapKey, { @@ -277,7 +284,7 @@ export async function getTraefikConfig( online: row.siteOnline } }); - }); + } // Group browser gateway targets by resource type BrowserGatewayResourceEntry = { @@ -313,7 +320,7 @@ export async function getTraefikConfig( if (allowBrowserGatewayResources) { for (const row of resourcesWithTargetsAndSites) { if (!["ssh", "vnc", "rdp"].includes(row.mode)) { - return; + continue; } if (filterOutNamespaceDomains && row.domainNamespaceId) { continue; From c39449047357b21783b64747fdf1d991ea598047 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 7 Jun 2026 10:43:16 -0700 Subject: [PATCH 25/28] Update browser targets --- server/routers/target/createTarget.ts | 23 ++++++++++++++++------- server/routers/target/deleteTarget.ts | 25 +++++++++++++++++-------- server/routers/target/updateTarget.ts | 23 ++++++++++++++++------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index bb880b045..48ed1f5d9 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -27,6 +27,7 @@ import { import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; +import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() @@ -342,13 +343,21 @@ export async function createTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await addTargets( - newt.newtId, - newTarget, - healthCheck, - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(newTarget[0].mode)) { + await addTargets( + newt.newtId, + newTarget, + healthCheck, + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { + await sendBrowserGatewayTargets( + newt.newtId, + newTarget, + newt.version + ); + } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 61d748f8c..77614b1cd 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { OpenAPITags, registry } from "@server/openApi"; import { targetHealthCheck } from "@server/db"; +import { removeBrowserGatewayTarget } from "@server/routers/newt/targets"; const deleteTargetSchema = z.strictObject({ targetId: z.coerce.number().int().positive() @@ -136,14 +137,22 @@ export async function deleteTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await removeTargets( - newt.newtId, - // [deletedTarget], - [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - [deletedHealthCheck], - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { + await removeTargets( + newt.newtId, + // [deletedTarget], + [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this + [deletedHealthCheck], + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { + await removeBrowserGatewayTarget( + newt.newtId, + deletedTarget.targetId, + newt.version + ); + } } } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index a5bb5fef3..c40ffa18b 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -18,6 +18,7 @@ import { import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; const updateTargetParamsSchema = z.strictObject({ targetId: z.coerce.number().int().positive() @@ -350,13 +351,21 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await addTargets( - newt.newtId, - [updatedTarget], - [updatedHc], - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { + await addTargets( + newt.newtId, + [updatedTarget], + [updatedHc], + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { + await sendBrowserGatewayTargets( + newt.newtId, + [updatedTarget], + newt.version + ); + } } } From 8daf7c287222c665e7a5b9cca17cec37594e29c8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 7 Jun 2026 12:07:08 -0700 Subject: [PATCH 26/28] Rename and add browser target update --- server/lib/blueprints/applyBlueprint.ts | 48 +++++-- ...clientResources.ts => privateResources.ts} | 2 +- .../{proxyResources.ts => publicResources.ts} | 8 +- src/app/rdp/RdpClient.tsx | 56 ++++---- src/app/ssh/SshClient.tsx | 53 +++++--- src/app/vnc/VncClient.tsx | 44 ++++--- src/lib/secureLocalStorage.ts | 124 ++++++++++++++++++ 7 files changed, 259 insertions(+), 76 deletions(-) rename server/lib/blueprints/{clientResources.ts => privateResources.ts} (99%) rename server/lib/blueprints/{proxyResources.ts => publicResources.ts} (99%) create mode 100644 src/lib/secureLocalStorage.ts diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 5296bb4d2..f2bb9b0c8 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -10,16 +10,22 @@ import { clientSiteResources } from "@server/db"; import { Config, ConfigSchema } from "./types"; -import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; +import { + PublicResourcesResults, + updatePublicResources +} from "./publicResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { sites } from "@server/db"; import { eq, and, isNotNull } from "drizzle-orm"; -import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; +import { + addTargets as addProxyTargets, + sendBrowserGatewayTargets +} from "@server/routers/newt/targets"; import { ClientResourcesResults, - updateClientResources -} from "./clientResources"; + updatePrivateResources +} from "./privateResources"; import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; @@ -54,18 +60,18 @@ export async function applyBlueprint({ let error: any | null = null; try { - let proxyResourcesResults: ProxyResourcesResults = []; + let proxyResourcesResults: PublicResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); - proxyResourcesResults = await updateProxyResources( + proxyResourcesResults = await updatePublicResources( orgId, config, trx, siteId ); - clientResourcesResults = await updateClientResources( + clientResourcesResults = await updatePrivateResources( orgId, config, trx, @@ -104,13 +110,27 @@ export async function applyBlueprint({ (hc) => hc.targetId === target.targetId ); - await addProxyTargets( - site.newt.newtId, - [target], - matchingHealthcheck ? [matchingHealthcheck] : [], - result.proxyResource.mode === "udp" ? "udp" : "tcp", - site.newt.version - ); + if (["http", "tcp", "udp"].includes(target.mode)) { + await addProxyTargets( + site.newt.newtId, + [target], + matchingHealthcheck + ? [matchingHealthcheck] + : [], + result.proxyResource.mode === "udp" + ? "udp" + : "tcp", + site.newt.version + ); + } else if ( + ["ssh", "rdp", "vnc"].includes(target.mode) + ) { + await sendBrowserGatewayTargets( + site.newt.newtId, + [target], + site.newt.version + ); + } } } } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/privateResources.ts similarity index 99% rename from server/lib/blueprints/clientResources.ts rename to server/lib/blueprints/privateResources.ts index 34e668984..3e6a784e0 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -105,7 +105,7 @@ export type ClientResourcesResults = { oldSites: { siteId: number }[]; }[]; -export async function updateClientResources( +export async function updatePrivateResources( orgId: string, config: Config, trx: Transaction, diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/publicResources.ts similarity index 99% rename from server/lib/blueprints/proxyResources.ts rename to server/lib/blueprints/publicResources.ts index b17878974..2bc1a6d7f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -52,19 +52,19 @@ import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import serverConfig from "@server/lib/config"; -export type ProxyResourcesResults = { +export type PublicResourcesResults = { proxyResource: Resource; targetsToUpdate: Target[]; healthchecksToUpdate: TargetHealthCheck[]; }[]; -export async function updateProxyResources( +export async function updatePublicResources( orgId: string, config: Config, trx: Transaction, siteId?: number -): Promise { - const results: ProxyResourcesResults = []; +): Promise { + const results: PublicResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index f5ad0dc1d..4dd90242f 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { useTranslations } from "next-intl"; +import { + loadEncryptedLocalStorage, + saveEncryptedLocalStorage +} from "@app/lib/secureLocalStorage"; declare module "react" { namespace JSX { @@ -63,22 +67,14 @@ type RdpCredentialsForm = { enableClipboard: boolean; }; -function loadStoredCredentials(key: string): RdpCredentialsForm { - try { - const saved = localStorage.getItem(key); - if (saved) return JSON.parse(saved) as RdpCredentialsForm; - } catch { - // ignore - } - return { - username: "", - password: "", - domain: "", - kdcProxyUrl: "", - pcb: "", - enableClipboard: true - }; -} +const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = { + username: "", + password: "", + domain: "", + kdcProxyUrl: "", + pcb: "", + enableClipboard: true +}; const isIronError = (error: unknown): error is IronError => { return ( @@ -113,9 +109,25 @@ export default function RdpClient({ const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_RDP_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); @@ -293,11 +305,11 @@ export default function RdpClient({ try { const sessionInfo = await userInteraction.connect(builder.build()); - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + values, + target.authToken + ); setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 8d97b970b..932b6336b 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -32,6 +32,10 @@ import { useTranslations } from "next-intl"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; +import { + loadEncryptedLocalStorage, + saveEncryptedLocalStorage +} from "@app/lib/secureLocalStorage"; type AuthTab = "password" | "privateKey"; @@ -48,15 +52,11 @@ type ConnectCredentials = { certificate?: string; }; -function loadStoredCredentials(key: string): SshCredentialsForm { - try { - const saved = localStorage.getItem(key); - if (saved) return JSON.parse(saved) as SshCredentialsForm; - } catch { - // ignore - } - return { username: "", password: "", privateKey: "" }; -} +const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = { + username: "", + password: "", + privateKey: "" +}; export default function SshClient({ target, @@ -86,9 +86,25 @@ export default function SshClient({ }); const form = useForm({ - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_SSH_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -252,14 +268,11 @@ export default function SshClient({ }) ); if (!override) { - try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify(form.getValues()) - ); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + form.getValues(), + target.authToken + ); } }; @@ -625,7 +638,7 @@ export default function SshClient({ {connected && (
-
+ {/*
-
+
*/}
({ resolver: zodResolver(formSchema), - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_VNC_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + const [connected, setConnected] = useState(false); const [connectError, setConnectError] = useState(null); const rfbRef = useRef(null); @@ -132,11 +146,11 @@ export default function VncClient({ rfb.resizeSession = true; rfb.addEventListener("connect", () => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + values, + target.authToken + ); setConnected(true); }); diff --git a/src/lib/secureLocalStorage.ts b/src/lib/secureLocalStorage.ts new file mode 100644 index 000000000..c8ceb601a --- /dev/null +++ b/src/lib/secureLocalStorage.ts @@ -0,0 +1,124 @@ +type EncryptedStorageEnvelope = { + v: 1; + s: string; + i: string; + d: string; +}; + +const PBKDF2_ITERATIONS = 120000; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function deriveKey(authToken: string, salt: ArrayBuffer) { + const subtle = window.crypto?.subtle; + if (!subtle) { + throw new Error("Web Crypto is unavailable"); + } + + const tokenKey = await subtle.importKey( + "raw", + toArrayBuffer(new TextEncoder().encode(authToken)), + "PBKDF2", + false, + ["deriveKey"] + ); + + return subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256" + }, + tokenKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function saveEncryptedLocalStorage( + storageKey: string, + value: T, + authToken: string | null | undefined +) { + if (typeof window === "undefined") return; + if (!authToken) { + window.localStorage.removeItem(storageKey); + return; + } + + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(authToken, toArrayBuffer(salt)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const encrypted = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(plaintext) + ); + + const payload: EncryptedStorageEnvelope = { + v: 1, + s: bytesToBase64(salt), + i: bytesToBase64(iv), + d: bytesToBase64(new Uint8Array(encrypted)) + }; + + window.localStorage.setItem(storageKey, JSON.stringify(payload)); +} + +export async function loadEncryptedLocalStorage( + storageKey: string, + authToken: string | null | undefined +): Promise { + if (typeof window === "undefined") return null; + if (!authToken) return null; + + const raw = window.localStorage.getItem(storageKey); + if (!raw) return null; + + try { + const payload = JSON.parse(raw) as EncryptedStorageEnvelope; + if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) { + throw new Error("Invalid encrypted payload"); + } + + const salt = base64ToBytes(payload.s); + const iv = base64ToBytes(payload.i); + const data = base64ToBytes(payload.d); + const key = await deriveKey(authToken, toArrayBuffer(salt)); + const decrypted = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(data) + ); + const json = new TextDecoder().decode(decrypted); + return JSON.parse(json) as T; + } catch { + window.localStorage.removeItem(storageKey); + return null; + } +} From 3b675f7de13f85b98fb7a2c8b82b3ce6e9607b42 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 7 Jun 2026 12:19:33 -0700 Subject: [PATCH 27/28] policies and policy on resource structure in a good place --- messages/en-US.json | 11 +- server/lib/validators.ts | 41 ++ .../routers/policy/createResourcePolicy.ts | 34 +- server/routers/external.ts | 7 + .../routers/policy/setResourcePolicyRules.ts | 34 +- .../public/[niceId]/authentication/page.tsx | 7 + .../public/[niceId]/general/page.tsx | 7 + .../resources/public/[niceId]/layout.tsx | 85 ++++ .../resources/public/[niceId]/page.tsx | 58 +-- .../resources/public/[niceId]/rules/page.tsx | 7 + .../policies/resources/public/page.tsx | 3 + .../public/ProxyResourceTargetsForm.tsx | 237 ++++------ .../public/[niceId]/authentication/page.tsx | 317 +------------ .../public/[niceId]/general/page.tsx | 54 ++- .../resources/public/[niceId]/layout.tsx | 14 +- .../resources/public/[niceId]/rules/page.tsx | 7 + src/app/globals.css | 4 +- src/components/ResourcePoliciesBanner.tsx | 21 + src/components/ResourcePoliciesTable.tsx | 1 + .../resource-policy/CreatePolicyForm.tsx | 11 +- .../CreatePolicyRulesSectionForm.tsx | 169 ------- .../resource-policy/EditPolicyForm.tsx | 36 +- .../EditPolicyNameSectionForm.tsx | 2 +- .../PolicyAccessRulesSection.tsx | 363 +++++++++----- .../PolicyAccessRulesTable.tsx | 235 ++++++--- .../resource-policy/PolicyAuthMethodRow.tsx | 17 - .../PolicyAuthStackSectionEdit.tsx | 445 ++++++++++-------- .../ResourcePolicyEditForm.tsx | 47 ++ .../SharedPolicyResourceNotice.tsx | 44 ++ src/components/resource-policy/index.ts | 6 +- .../policy-access-rule-utils.ts | 121 +++++ .../policy-access-rule-validation.ts | 19 +- src/components/shared-policy-selector.tsx | 218 +++++++++ src/components/ui/alert.tsx | 2 +- src/components/ui/select.tsx | 21 +- src/lib/queries.ts | 21 +- 36 files changed, 1579 insertions(+), 1147 deletions(-) create mode 100644 src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx create mode 100644 src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx create mode 100644 src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx create mode 100644 src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx create mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx create mode 100644 src/components/ResourcePoliciesBanner.tsx delete mode 100644 src/components/resource-policy/CreatePolicyRulesSectionForm.tsx create mode 100644 src/components/resource-policy/ResourcePolicyEditForm.tsx create mode 100644 src/components/resource-policy/SharedPolicyResourceNotice.tsx create mode 100644 src/components/shared-policy-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 1bf6a30ad..d92871562 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -211,6 +211,8 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", + "resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules", + "resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.", "resourcePoliciesTitle": "Manage Public Resource Policies", "resourcePoliciesAttachedResourcesColumnTitle": "Resources", "resourcePoliciesAttachedResources": "{count} resource(s)", @@ -774,6 +776,7 @@ "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorValidation": "Invalid rules", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", + "rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).", "rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountryDescription": "Select a valid country.", @@ -968,10 +971,16 @@ "resourceRoleDescription": "Admins can always access this resource.", "resourcePolicySelectTitle": "Resource Access Policy", "resourcePolicySelectDescription": "Select the resource policy type for authentication", + "resourcePolicyTypeLabel": "Policy type", + "resourcePolicyLabel": "Resource policy", "resourcePolicyInline": "Inline Resource Policy", "resourcePolicyInlineDescription": "Access Policy scoped to only this resource", "resourcePolicyShared": "Shared Resource Policy", - "resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.", + "resourcePolicySharedDescription": "This resource uses a shared policy.", + "sharedPolicy": "Shared Policy", + "sharedPolicyNoneDescription": "This resource has its own policy.", + "resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to {policyName}.", + "resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit {policyName}.", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", diff --git a/server/lib/validators.ts b/server/lib/validators.ts index b1efe8b38..c179d3c91 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -1,5 +1,7 @@ import z from "zod"; import ipaddr from "ipaddr.js"; +import { COUNTRIES } from "@server/db/countries"; +import { isValidRegionId } from "@server/db/regions"; export function isValidCIDR(cidr: string): boolean { return ( @@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean { return true; } +export const RESOURCE_RULE_MATCH_TYPES = [ + "CIDR", + "IP", + "PATH", + "COUNTRY", + "ASN", + "REGION" +] as const; + +export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number]; + +export function getResourceRuleValueValidationError( + match: ResourceRuleMatchType, + value: string +): string | null { + switch (match) { + case "CIDR": + return isValidCIDR(value) ? null : "Invalid CIDR provided"; + case "IP": + return isValidIP(value) ? null : "Invalid IP provided"; + case "PATH": + return isValidUrlGlobPattern(value) + ? null + : "Invalid URL glob pattern provided"; + case "REGION": + return isValidRegionId(value) ? null : "Invalid region ID provided"; + case "COUNTRY": + return COUNTRIES.some((country) => country.code === value) + ? null + : "Invalid country code provided"; + case "ASN": + return /^AS\d+$/i.test(value.trim()) + ? null + : "Invalid ASN provided"; + default: + return "Invalid rule match type provided"; + } +} + export function isUrlValid(url: string | undefined) { if (!url) return true; // the link is optional in the schema so if it's empty it's valid var pattern = new RegExp( diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 2b4678331..9f02b912c 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -33,9 +33,8 @@ import { import { getUniqueResourcePolicyName } from "@server/db/names"; import response from "@server/lib/response"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + getResourceRuleValueValidationError, + RESOURCE_RULE_MATCH_TYPES } from "@server/lib/validators"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({ enum: ["ACCEPT", "DROP", "PASS"], description: "rule action" }), - match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({ type: "string", - enum: ["CIDR", "IP", "PATH"], + enum: [...RESOURCE_RULE_MATCH_TYPES], description: "rule match" }), value: z.string().min(1), @@ -261,26 +260,13 @@ export async function createResourcePolicy( const niceId = await getUniqueResourcePolicyName(orgId); for (const rule of rules) { - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + const validationError = getResourceRuleValueValidationError( + rule.match, + rule.value + ); + if (validationError) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } else if (rule.match === "IP" && !isValidIP(rule.value)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") - ); - } else if ( - rule.match === "PATH" && - !isValidUrlGlobPattern(rule.value) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) + createHttpError(HttpCode.BAD_REQUEST, validationError) ); } } diff --git a/server/routers/external.ts b/server/routers/external.ts index db0db594a..960c00249 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -666,6 +666,13 @@ authenticated.get( resource.getResourcePolicies ); +authenticated.get( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + authenticated.put( "/resource-policy/:resourcePolicyId", verifyResourcePolicyAccess, diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 533e01c0e..f15c1e51a 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -8,9 +8,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + getResourceRuleValueValidationError, + RESOURCE_RULE_MATCH_TYPES } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({ enum: ["ACCEPT", "DROP", "PASS"], description: "rule action" }), - match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({ type: "string", - enum: ["CIDR", "IP", "PATH"], + enum: [...RESOURCE_RULE_MATCH_TYPES], description: "rule match" }), value: z.string().min(1), @@ -105,26 +104,13 @@ export async function setResourcePolicyRules( } for (const rule of rules) { - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + const validationError = getResourceRuleValueValidationError( + rule.match, + rule.value + ); + if (validationError) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid CIDR provided" - ) - ); - } else if (rule.match === "IP" && !isValidIP(rule.value)) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") - ); - } else if ( - rule.match === "PATH" && - !isValidUrlGlobPattern(rule.value) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid URL glob pattern provided" - ) + createHttpError(HttpCode.BAD_REQUEST, validationError) ); } } diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx new file mode 100644 index 000000000..ff9ebd4cf --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyAuthenticationPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx new file mode 100644 index 000000000..a0e80b9f7 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyGeneralPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx new file mode 100644 index 000000000..7c8d3d9bc --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx @@ -0,0 +1,85 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Resource Policy" +}; + +export const dynamic = "force-dynamic"; + +type EditPolicyLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: string; orgId: string }>; +}; + +export default async function EditPolicyLayout(props: EditPolicyLayoutProps) { + const params = await props.params; + const t = await getTranslations(); + + let policyResponse: GetResourcePolicyResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policy/${params.niceId}`, + await authCookieHeader() + ); + policyResponse = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/policies/resources/public`); + } + + if (!policyResponse) { + redirect(`/${params.orgId}/settings/policies/resources/public`); + } + + const navItems = [ + { + title: t("general"), + href: "/{orgId}/settings/policies/resources/public/{niceId}/general" + }, + { + title: t("authentication"), + href: "/{orgId}/settings/policies/resources/public/{niceId}/authentication" + }, + { + title: t("policyAccessRulesTitle"), + href: "/{orgId}/settings/policies/resources/public/{niceId}/rules" + } + ]; + + return ( + <> +
+ + + +
+ + + {props.children} + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx index 1113016b8..9cc180715 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx @@ -1,62 +1,12 @@ -import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { Button } from "@app/components/ui/button"; -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; -import type { GetResourcePolicyResponse } from "@server/routers/policy"; -import type { AxiosResponse } from "axios"; -import { getTranslations } from "next-intl/server"; -import Link from "next/link"; import { redirect } from "next/navigation"; -export interface EditPolicyPageProps { +type EditPolicyPageProps = { params: Promise<{ niceId: string; orgId: string }>; -} +}; export default async function EditPolicyPage(props: EditPolicyPageProps) { const params = await props.params; - const t = await getTranslations(); - - let policyResponse: GetResourcePolicyResponse | null = null; - try { - const res = await internal.get< - AxiosResponse - >( - `/org/${params.orgId}/resource-policy/${params.niceId}`, - await authCookieHeader() - ); - policyResponse = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/policies/resources/public`); - } - - if (!policyResponse) { - redirect(`/${params.orgId}/settings/policies/resources/public`); - } - - return ( - <> -
- - - -
- - - - - + redirect( + `/${params.orgId}/settings/policies/resources/public/${params.niceId}/general` ); } diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx new file mode 100644 index 000000000..a33fce94e --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyRulesPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx index a51bbef3a..8b12b75b2 100644 --- a/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx @@ -1,3 +1,4 @@ +import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner"; import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; @@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage( description={t("resourcePoliciesDescription")} /> + + + + {t("addTarget")} + + ); + + const hasTargets = targets.length > 0; + async function saveTargets() { if (!resource) return; @@ -823,143 +833,104 @@ export function ProxyResourceTargetsForm({ - {targets.length > 0 ? ( - <> -
- - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const isActionsColumn = - header.column - .id === - "actions"; - const isSiteColumn = - header.column - .id === - "site"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const isActionsColumn = - cell.column - .id === - "actions"; - const isSiteColumn = - cell.column - .id === - "site"; - return ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + const isSiteColumn = + header.column.id === "site"; + return ( + - {t("targetNoOne")} - - - )} - -
-
-
-
-
+ {hasTargets && ( +
+
+ {addTargetButton} +
+ +
- - ) : ( -
-

- {t("targetNoOne")} -

-
)} {build === "saas" && diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx index 81a7db9ca..29c2f4825 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -1,320 +1,7 @@ "use client"; -import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -import { - StrategySelect, - type StrategyOption -} from "@app/components/StrategySelect"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import { build } from "@server/build"; -import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm"; -import { Binary, Bot, InfoIcon, Key } from "lucide-react"; -import { CheckIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useRouter } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; -import { useForm, useWatch } from "react-hook-form"; -import { z } from "zod"; - -const resourceTypeSchema = z - .object({ - type: z.literal("inline") - }) - .or( - z.object({ - type: z.literal("shared"), - resourcePolicyId: z.number() - }) - ); - -type ResourcePolicyType = StrategyOption<"inline" | "shared">; +import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm"; export default function ResourceAuthenticationPage() { - const { org } = useOrgContext(); - const { resource, updateResource } = useResourceContext(); - const queryClient = useQueryClient(); - - const { env } = useEnvContext(); - const { isPaidUser } = usePaidStatus(); - - const api = createApiClient({ env }); - const router = useRouter(); - const t = useTranslations(); - - const { data: policies, isLoading: isLoadingPolicies } = useQuery( - resourceQueries.policies({ - resourceId: resource.resourceId - }) - ); - - const form = useForm({ - resolver: zodResolver(resourceTypeSchema), - defaultValues: { - type: - build !== "oss" && resource.resourcePolicyId - ? "shared" - : "inline" - } - }); - - const selectedResourceType = useWatch({ - control: form.control, - name: "type" - }); - - const [resourcePolicysearchQuery, setResourcePolicySearchQuery] = - useState(""); - - const { data: policiesList = [] } = useQuery({ - ...orgQueries.policies({ - orgId: org.org.orgId, - name: resourcePolicysearchQuery - }), - enabled: selectedResourceType === "shared" - }); - - const [selectedPolicy, setSelectedPolicy] = useState<{ - name: string; - id: number; - } | null>(null); - - const resourcePolicyTypes: Array = [ - { - id: "inline", - title: t("resourcePolicyInline"), - description: t("resourcePolicyInlineDescription") - }, - { - id: "shared", - title: t("resourcePolicyShared"), - description: t("resourcePolicySharedDescription") - } - ]; - - useEffect(() => { - if (!isLoadingPolicies && policies?.sharedPolicy) { - setSelectedPolicy({ - id: policies?.sharedPolicy.resourcePolicyId, - name: policies?.sharedPolicy.name - }); - } - }, [isLoadingPolicies, policies?.sharedPolicy]); - - const [isUpdatingResource, startTransition] = useTransition(); - - async function handleSaveResourcePolicyType() { - try { - if (selectedResourceType === "inline") { - await api.post(`/resource/${resource.resourceId}`, { - resourcePolicyId: null - }); - } else { - if (!selectedPolicy) { - toast({ - title: t("error"), - description: t("resourcePolicySelectError"), - variant: "destructive" - }); - return; - } - await api.post(`/resource/${resource.resourceId}`, { - resourcePolicyId: selectedPolicy.id - }); - } - router.refresh(); - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - } catch (e) { - toast({ - title: t("error"), - description: formatAxiosError(e), - variant: "destructive" - }); - } finally { - await queryClient.invalidateQueries( - resourceQueries.policies({ - resourceId: resource.resourceId - }) - ); - } - } - - const pageLoading = isLoadingPolicies || !policies; - - if (pageLoading) { - return <>; - } - - return ( - <> - - {build !== "oss" && - isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && ( - - - - {t("resourcePolicySelectTitle")} - - - {t("resourcePolicySelectDescription")} - - - - { - form.setValue("type", value); - }} - cols={2} - /> - {selectedResourceType === "shared" && ( - - - - - - - - - - {t( - "resourcePolicyNotFound" - )} - - - {policiesList.map( - (policy) => ( - - setSelectedPolicy( - { - id: policy.resourcePolicyId, - name: policy.name - } - ) - } - > - - { - policy.name - } - - ) - )} - - - - - - )} - - - - - - )} - - {selectedResourceType === "inline" ? ( - - - - ) : ( - policies.sharedPolicy && ( - - - - ) - )} - - - ); + return ; } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index ef909bd9f..ed0061269 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -36,10 +36,14 @@ import { AlertCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; -import { useActionState, useMemo, useState } from "react"; +import { useActionState, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import z from "zod"; +import { SharedPolicySelect } from "@app/components/shared-policy-selector"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { build } from "@server/build"; +import { TierFeature } from "@server/lib/billing/tierMatrix"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { @@ -434,16 +438,30 @@ function MaintenanceSectionForm({ export default function GeneralForm() { const params = useParams(); + const { org } = useOrgContext(); const { resource, updateResource } = useResourceContext(); const router = useRouter(); const t = useTranslations(); const { env } = useEnvContext(); + const { isPaidUser } = usePaidStatus(); const orgId = params.orgId; const api = createApiClient({ env }); + const showResourcePolicy = + build !== "oss" && + isPaidUser(tierMatrix[TierFeature.ResourcePolicies]); + + const [selectedSharedPolicyId, setSelectedSharedPolicyId] = useState< + number | null + >(resource.resourcePolicyId ?? null); + + useEffect(() => { + setSelectedSharedPolicyId(resource.resourcePolicyId ?? null); + }, [resource.resourcePolicyId]); + const [resourceFullDomain, setResourceFullDomain] = useState( `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); @@ -506,6 +524,12 @@ export default function GeneralForm() { const data = form.getValues(); + let resourcePolicyId: number | null | undefined; + + if (showResourcePolicy) { + resourcePolicyId = selectedSharedPolicyId; + } + const res = await api .post>( `resource/${resource?.resourceId}`, @@ -519,7 +543,8 @@ export default function GeneralForm() { ) : undefined, domainId: data.domainId, - proxyPort: data.proxyPort + proxyPort: data.proxyPort, + ...(resourcePolicyId !== undefined && { resourcePolicyId }) } ) .catch((e) => { @@ -543,7 +568,10 @@ export default function GeneralForm() { subdomain: data.subdomain, fullDomain: updated.fullDomain, proxyPort: data.proxyPort, - domainId: data.domainId + domainId: data.domainId, + ...(resourcePolicyId !== undefined && { + resourcePolicyId + }) }); toast({ @@ -584,7 +612,7 @@ export default function GeneralForm() { - +
)} + {showResourcePolicy && ( +
+ + {t("sharedPolicy")} + + +
+ )} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index 731991b73..1b20dbc8e 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -92,10 +92,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { ]; if (["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { - navItems.push({ - title: t("authentication"), - href: `/{orgId}/settings/resources/public/{niceId}/authentication` - }); + navItems.push( + { + title: t("authentication"), + href: `/{orgId}/settings/resources/public/{niceId}/authentication` + }, + { + title: t("policyAccessRulesTitle"), + href: `/{orgId}/settings/resources/public/{niceId}/rules` + } + ); } return ( diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx new file mode 100644 index 000000000..08aac6fa2 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rules/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm"; + +export default function ResourcePolicyRulesPage() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index b5a231e11..07355700a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,7 +22,7 @@ --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.91 0.004 286.32); + --border: oklch(0.88 0.004 286.32); --input: oklch(0.88 0.004 286.32); --ring: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116); @@ -57,7 +57,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.5382 0.1949 22.216); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 8%); + --border: oklch(1 0 0 / 18%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.646 0.222 41.116); --chart-1: oklch(0.488 0.243 264.376); diff --git a/src/components/ResourcePoliciesBanner.tsx b/src/components/ResourcePoliciesBanner.tsx new file mode 100644 index 000000000..4a8d88d11 --- /dev/null +++ b/src/components/ResourcePoliciesBanner.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Shield } from "lucide-react"; +import { useTranslations } from "next-intl"; +import DismissableBanner from "./DismissableBanner"; + +export const ResourcePoliciesBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("resourcePoliciesBannerDescription")} + /> + ); +}; + +export default ResourcePoliciesBanner; diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx index 4a0a382e5..a21168db6 100644 --- a/src/components/ResourcePoliciesTable.tsx +++ b/src/components/ResourcePoliciesTable.tsx @@ -283,6 +283,7 @@ export function ResourcePoliciesTable({ searchPlaceholder={t("resourcePoliciesSearch")} pagination={pagination} rowCount={rowCount} + searchQuery={searchParams.get("query")?.toString()} onSearch={handleSearchChange} onPaginationChange={handlePaginationChange} onAdd={() => diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx index 17bc8b634..b4cd146b5 100644 --- a/src/components/resource-policy/CreatePolicyForm.tsx +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -147,7 +147,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { if (res && res.status === 201) { const niceId = res.data.data.niceId; router.push( - `/${org.org.orgId}/settings/policies/resources/public/${niceId}` + `/${org.org.orgId}/settings/policies/resources/public/${niceId}/general` ); toast({ title: t("success"), @@ -227,7 +227,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) { - + - + diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx deleted file mode 100644 index 042b63e63..000000000 --- a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; - -import { createPolicyRulesSectionSchema, type PolicyFormValues } from "."; -import { Button } from "@app/components/ui/button"; -import { Plus } from "lucide-react"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; - -import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; -import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; -import { - createEmptyRule, - type PolicyAccessRule -} from "./policy-access-rule-utils"; - -export type CreatePolicyRulesSectionFormProps = { - form: UseFormReturn; - isMaxmindAvailable: boolean; - isMaxmindAsnAvailable: boolean; -}; - -export function CreatePolicyRulesSectionForm({ - form: parentForm, - isMaxmindAvailable, - isMaxmindAsnAvailable -}: CreatePolicyRulesSectionFormProps) { - const t = useTranslations(); - const [rules, setRules] = useState([]); - - const rulesFormSchema = useMemo( - () => createPolicyRulesSectionSchema(t), - [t] - ); - - const form = useForm({ - resolver: zodResolver(rulesFormSchema), - defaultValues: { - applyRules: false, - rules: [] - } - }); - - useEffect(() => { - const subscription = form.watch((values) => { - parentForm.setValue("applyRules", values.applyRules as boolean); - parentForm.setValue("rules", values.rules as any); - }); - return () => subscription.unsubscribe(); - }, [form, parentForm]); - - const rulesEnabled = useWatch({ - control: form.control, - name: "applyRules" - }); - - const syncFormRules = useCallback( - (updatedRules: PolicyAccessRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); - }, - [form] - ); - - const addEmptyRule = useCallback(() => { - const updatedRules = [...rules, createEmptyRule(rules)]; - setRules(updatedRules); - syncFormRules(updatedRules); - }, [rules, syncFormRules]); - - const removeRule = useCallback( - function removeRule(ruleId: number) { - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const updateRule = useCallback( - function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule - ); - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [rules, syncFormRules] - ); - - const handleRulesChange = useCallback( - (updatedRules: PolicyAccessRule[]) => { - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [syncFormRules] - ); - - const addRuleButton = ( - - ); - - const hasRules = rules.length > 0; - - return ( - - - - {t("policyAccessRulesTitle")} - - - {t("rulesResourceDescription")} - - - -
- { - form.setValue("applyRules", val); - }} - /> - - {rulesEnabled && ( - <> - - {hasRules && addRuleButton} - - )} -
-
-
- ); -} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index 5acc6de72..57dd12fdc 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -10,26 +10,27 @@ import { orgQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; import { useMemo } from "react"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; +export type EditPolicyFormSection = "general" | "authentication" | "rules"; + export type EditPolicyFormProps = { hidePolicyNameForm?: boolean; readonly?: boolean; resourceId?: number; + section?: EditPolicyFormSection; }; export function EditPolicyForm({ hidePolicyNameForm, readonly, - resourceId + resourceId, + section }: EditPolicyFormProps) { - const t = useTranslations(); const { org } = useOrgContext(); const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); @@ -37,7 +38,6 @@ export function EditPolicyForm({ // In overlay mode (resourceId provided), policy-level sections are locked. // Rules and users/roles sections handle their own hybrid logic via resourceId. const isOverlay = resourceId !== undefined; - const showTabs = !hidePolicyNameForm && !isOverlay; const isMaxmindAvailable = !!( env.server.maxmind_db_path && env.server.maxmind_db_path.length > 0 @@ -92,22 +92,16 @@ export function EditPolicyForm({ /> ); - if (showTabs) { - return ( - - - {authSection} - {rulesSection} - - ); + if (section === "general") { + return ; + } + + if (section === "authentication") { + return authSection; + } + + if (section === "rules") { + return rulesSection; } return ( diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx index 5acb6fd7b..172c8f691 100644 --- a/src/components/resource-policy/EditPolicyNameSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -109,7 +109,7 @@ export function EditPolicyNameSectionForm({ if (payload.niceId && payload.niceId !== policy.niceId) { router.replace( - `/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}` + `/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}/general` ); } diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index 196fd9b21..796ebbad0 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -28,7 +28,8 @@ import { useMemo, useRef, useState, - useTransition + useTransition, + type ReactNode } from "react"; import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; @@ -38,11 +39,12 @@ import { resourceQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; -import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; import { PolicyAccessRulesIntro } from "./PolicyAccessRulesIntro"; import { PolicyAccessRulesTable } from "./PolicyAccessRulesTable"; +import { SharedPolicyResourceNotice } from "./SharedPolicyResourceNotice"; import { createEmptyRule, + prependEmptyRule, type PolicyAccessRule } from "./policy-access-rule-utils"; @@ -74,6 +76,143 @@ export function PolicyAccessRulesSection(props: PolicyAccessRulesSectionProps) { return ; } +type PolicyAccessRulesSectionLayoutProps = { + rulesEnabled: boolean; + onRulesEnabledChange: (enabled: boolean) => void; + disableToggle?: boolean; + rules: PolicyAccessRule[]; + onRulesChange: (rules: PolicyAccessRule[]) => void; + updateRule: (ruleId: number, data: Partial) => void; + removeRule: (ruleId: number) => void; + readonly?: boolean; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + resourceOverlayMode?: boolean; + footer?: ReactNode; +}; + +function PolicyAccessRulesSectionLayout({ + rulesEnabled, + onRulesEnabledChange, + disableToggle, + rules, + onRulesChange, + updateRule, + removeRule, + readonly, + isMaxmindAvailable, + isMaxmindAsnAvailable, + resourceOverlayMode, + footer +}: PolicyAccessRulesSectionLayoutProps) { + const t = useTranslations(); + + const addEmptyRule = useCallback(() => { + if (resourceOverlayMode) { + onRulesChange(prependEmptyRule(rules)); + return; + } + onRulesChange([...rules, createEmptyRule(rules)]); + }, [rules, onRulesChange, resourceOverlayMode]); + + const addRuleButton = ( + + ); + + const hasRules = rules.length > 0; + + return ( + + + + {t("policyAccessRulesTitle")} + + + {t("rulesResourceDescription")} + + + +
+ {resourceOverlayMode && ( + + )} + + + {rulesEnabled && ( + <> + + {hasRules && addRuleButton} + + )} +
+
+ {footer} +
+ ); +} + +function usePolicyAccessRulesFormSync( + form: UseFormReturn<{ + applyRules: boolean; + rules: PolicyFormValues["rules"]; + }> +) { + const syncFormRules = useCallback( + (updatedRules: PolicyAccessRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const updateRulesState = useCallback( + ( + setRules: React.Dispatch>, + updatedRules: PolicyAccessRule[] + ) => { + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [syncFormRules] + ); + + return { syncFormRules, updateRulesState }; +} + function PolicyAccessRulesSectionEdit({ isMaxmindAvailable, isMaxmindAsnAvailable, @@ -119,6 +258,8 @@ function PolicyAccessRulesSectionEdit({ policy.rules.map((r) => ({ ...r, fromPolicy: isResourceOverlay })) ); + const { updateRulesState } = usePolicyAccessRulesFormSync(form); + useEffect(() => { if (!isResourceOverlay || resourceRulesInitialized) return; if (!resourceRulesData) return; @@ -148,30 +289,13 @@ function PolicyAccessRulesSectionEdit({ policy.rules ]); - const syncFormRules = useCallback( + const handleRulesChange = useCallback( (updatedRules: PolicyAccessRule[]) => { - form.setValue( - "rules", - updatedRules.map( - ({ action, match, value, priority, enabled }) => ({ - action, - match, - value, - priority, - enabled - }) - ) - ); + updateRulesState(setRules, updatedRules); }, - [form] + [updateRulesState] ); - const addEmptyRule = useCallback(() => { - const updatedRules = [...rules, createEmptyRule(rules)]; - setRules(updatedRules); - syncFormRules(updatedRules); - }, [rules, syncFormRules]); - const removeRule = useCallback( function removeRule(ruleId: number) { const rule = rules.find((r) => r.ruleId === ruleId); @@ -179,32 +303,22 @@ function PolicyAccessRulesSectionEdit({ if (isResourceOverlay && !rule.new) { deletedResourceRuleIdsRef.current.add(ruleId); } - const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); - setRules(updatedRules); - syncFormRules(updatedRules); + handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId)); }, - [rules, syncFormRules, isResourceOverlay] + [rules, handleRulesChange, isResourceOverlay] ); const updateRule = useCallback( function updateRule(ruleId: number, data: Partial) { - const updatedRules = rules.map((rule) => - rule.ruleId === ruleId - ? { ...rule, ...data, updated: true } - : rule + handleRulesChange( + rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ) ); - setRules(updatedRules); - syncFormRules(updatedRules); }, - [rules, syncFormRules] - ); - - const handleRulesChange = useCallback( - (updatedRules: PolicyAccessRule[]) => { - setRules(updatedRules); - syncFormRules(updatedRules); - }, - [syncFormRules] + [rules, handleRulesChange] ); const [isPending, startTransition] = useTransition(); @@ -213,7 +327,10 @@ function PolicyAccessRulesSectionEdit({ if (readonly) return; const applyRules = form.getValues("applyRules") ?? false; - const rulesPayload = rules.map( + const rulesToValidate = isResourceOverlay + ? rules.filter((rule) => !rule.fromPolicy) + : rules; + const rulesPayload = rulesToValidate.map( ({ action, match, value, priority, enabled }) => ({ action, match, @@ -331,80 +448,112 @@ function PolicyAccessRulesSectionEdit({ } } - const addRuleButton = ( - - ); - - const hasRules = rules.length > 0; - return ( - - - - {t("policyAccessRulesTitle")} - - - {t("rulesResourceDescription")} - - - -
- { - form.setValue("applyRules", val); - }} - disableToggle={readonly || isResourceOverlay} - /> - - {rulesEnabled && ( - <> - - {hasRules && addRuleButton} - - )} -
-
- - - -
+ { + form.setValue("applyRules", val); + }} + disableToggle={readonly || isResourceOverlay} + rules={rules} + onRulesChange={handleRulesChange} + updateRule={updateRule} + removeRule={removeRule} + readonly={readonly} + isMaxmindAvailable={isMaxmindAvailable} + isMaxmindAsnAvailable={isMaxmindAsnAvailable} + resourceOverlayMode={isResourceOverlay} + footer={ + + + + } + /> ); } function PolicyAccessRulesSectionCreate({ - form, + form: parentForm, isMaxmindAvailable, isMaxmindAsnAvailable }: PolicyAccessRulesSectionCreateProps) { + const t = useTranslations(); + const [rules, setRules] = useState([]); + + const rulesFormSchema = useMemo( + () => createPolicyRulesSectionSchema(t), + [t] + ); + + const form = useForm({ + resolver: zodResolver(rulesFormSchema), + defaultValues: { + applyRules: false, + rules: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("applyRules", values.applyRules as boolean); + parentForm.setValue( + "rules", + values.rules as PolicyFormValues["rules"] + ); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const { updateRulesState } = usePolicyAccessRulesFormSync(form); + + const handleRulesChange = useCallback( + (updatedRules: PolicyAccessRule[]) => { + updateRulesState(setRules, updatedRules); + }, + [updateRulesState] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + handleRulesChange(rules.filter((rule) => rule.ruleId !== ruleId)); + }, + [rules, handleRulesChange] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + handleRulesChange( + rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ) + ); + }, + [rules, handleRulesChange] + ); + return ( - { + form.setValue("applyRules", val); + }} + rules={rules} + onRulesChange={handleRulesChange} + updateRule={updateRule} + removeRule={removeRule} isMaxmindAvailable={isMaxmindAvailable} isMaxmindAsnAvailable={isMaxmindAsnAvailable} /> diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index 4f1052d4f..a701b92ff 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -66,8 +66,12 @@ import { validatePolicyRuleValue } from "./policy-access-rule-validation"; import { + buildDisplayPrioritiesForResourceOverlay, reorderPolicyRules, + reorderResourceOverlayRules, + setResourceRuleDisplayPriority, sortPolicyRulesByPriority, + sortPolicyRulesForResourceOverlay, type PolicyAccessRule } from "./policy-access-rule-utils"; @@ -82,6 +86,7 @@ export type PolicyAccessRulesTableProps = { readonly?: boolean; includeRegionMatch?: boolean; markUpdatedOnReorder?: boolean; + resourceOverlayMode?: boolean; isRuleDraggable?: (rule: PolicyAccessRule) => boolean; isRuleLocked?: (rule: PolicyAccessRule) => boolean; }; @@ -97,7 +102,7 @@ function getColumnClassName(columnId: string) { return "w-24 max-w-24"; } if (columnId === "action") { - return "w-40 max-w-40"; + return "w-42 max-w-42"; } if (columnId === "match") { return "w-36 max-w-36"; @@ -116,6 +121,7 @@ export function PolicyAccessRulesTable({ readonly = false, includeRegionMatch = false, markUpdatedOnReorder = false, + resourceOverlayMode = false, isRuleDraggable: isRuleDraggableProp, isRuleLocked: isRuleLockedProp }: PolicyAccessRulesTableProps) { @@ -140,12 +146,37 @@ export function PolicyAccessRulesTable({ ); const sortedRules = useMemo( - () => sortPolicyRulesByPriority(rules), + () => + resourceOverlayMode + ? sortPolicyRulesForResourceOverlay(rules) + : sortPolicyRulesByPriority(rules), + [rules, resourceOverlayMode] + ); + + const displayPriorities = useMemo( + () => + resourceOverlayMode + ? buildDisplayPrioritiesForResourceOverlay(rules) + : null, + [rules, resourceOverlayMode] + ); + + const resourceRuleCount = useMemo( + () => rules.filter((rule) => !rule.fromPolicy).length, [rules] ); const handleReorder = useCallback( (fromRuleId: number, toRuleId: number) => { + if (resourceOverlayMode) { + onRulesChange( + reorderResourceOverlayRules(rules, fromRuleId, toRuleId, { + markUpdated: markUpdatedOnReorder + }) + ); + return; + } + const fromIndex = sortedRules.findIndex( (rule) => rule.ruleId === fromRuleId ); @@ -164,7 +195,13 @@ export function PolicyAccessRulesTable({ ); onRulesChange(reordered); }, - [sortedRules, onRulesChange, markUpdatedOnReorder] + [ + rules, + sortedRules, + onRulesChange, + markUpdatedOnReorder, + resourceOverlayMode + ] ); const handleDragStart = useCallback((ruleId: number, e: DragEvent) => { @@ -228,60 +265,132 @@ export function PolicyAccessRulesTable({ maxSize: 96, header: ({ column }) => (
- + {resourceOverlayMode ? ( + + {t("rulesPriority")} + + ) : ( + + )}
), - cell: ({ row }) => ( - e.currentTarget.focus()} - onBlur={(e) => { - const validated = validatePolicyRulePriority( - t, - e.target.value - ); - if (!validated.success) { - toast({ - variant: "destructive", - ...validated.toast + cell: ({ row }) => { + const displayPriority = resourceOverlayMode + ? (displayPriorities?.get(row.original.ruleId) ?? + row.original.priority) + : row.original.priority; + + return ( + e.currentTarget.focus()} + onBlur={(e) => { + const validated = validatePolicyRulePriority( + t, + e.target.value + ); + if (!validated.success) { + toast({ + variant: "destructive", + ...validated.toast + }); + return; + } + + if (resourceOverlayMode) { + if ( + validated.data > resourceRuleCount || + validated.data < 1 + ) { + toast({ + variant: "destructive", + title: t( + "rulesErrorInvalidPriority" + ), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + + const duplicateDisplayPriority = rules.some( + (rule) => + !rule.fromPolicy && + rule.ruleId !== + row.original.ruleId && + displayPriorities?.get( + rule.ruleId + ) === validated.data + ); + if (duplicateDisplayPriority) { + toast({ + variant: "destructive", + title: t( + "rulesErrorDuplicatePriority" + ), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + + if (validated.data === displayPriority) { + return; + } + + onRulesChange( + setResourceRuleDisplayPriority( + rules, + row.original.ruleId, + validated.data, + { + markUpdated: + markUpdatedOnReorder + } + ) + ); + return; + } + + const duplicatePriority = rules.some( + (rule) => + rule.ruleId !== row.original.ruleId && + rule.priority === validated.data + ); + if (duplicatePriority) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicatePriority"), + description: t( + "rulesErrorDuplicatePriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: validated.data }); - return; - } - const duplicatePriority = rules.some( - (rule) => - rule.ruleId !== row.original.ruleId && - rule.priority === validated.data - ); - if (duplicatePriority) { - toast({ - variant: "destructive", - title: t("rulesErrorDuplicatePriority"), - description: t( - "rulesErrorDuplicatePriorityDescription" - ) - }); - return; - } - updateRule(row.original.ruleId, { - priority: validated.data - }); - }} - /> - ) + }} + /> + ); + } }, { accessorKey: "action", @@ -683,13 +792,7 @@ export function PolicyAccessRulesTable({ cell: ({ row }) => (
{isRuleLocked(row.original) ? ( - + ) : ( + + + { + onChange(policy?.resourcePolicyId ?? null); + setSelectedLabel( + policy + ? { + resourcePolicyId: policy.resourcePolicyId, + name: policy.name + } + : null + ); + setOpen(false); + }} + /> + + + ); +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index dce25949a..90a87bee6 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; const alertVariants = cva( - "relative w-full rounded-lg p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + "relative w-full rounded-lg p-4 has-[>svg]:grid has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 gap-y-1 [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:row-span-full [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:self-center [&>svg]:text-foreground [&>svg~*]:col-start-2", { variants: { variant: { diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 6d065e7aa..c91799712 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -105,13 +105,17 @@ function SelectLabel({ function SelectItem({ className, children, + description, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + description?: React.ReactNode; +}) { return ( - {children} + {description ? ( +
+ + {children} + + + {description} + +
+ ) : ( + {children} + )}
); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 17377af8f..7d224c7b1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -45,6 +45,7 @@ import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; export type ProductUpdate = { link: string | null; @@ -581,16 +582,16 @@ export const orgQueries = { } }), - policies: ({ orgId, name }: { orgId: string; name?: string }) => + policies: ({ orgId, query }: { orgId: string; query?: string }) => queryOptions({ - queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const, + queryKey: ["ORG", orgId, "RESOURCES_POLICIES", query] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ pageSize: "10" }); - if (name) { - sp.set("query", name); + if (query) { + sp.set("query", query); } const res = await meta!.api.get< @@ -601,6 +602,18 @@ export const orgQueries = { return res.data.data.policies; } + }), + + resourcePolicy: ({ resourcePolicyId }: { resourcePolicyId: number }) => + queryOptions({ + queryKey: ["RESOURCE_POLICY", resourcePolicyId] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource-policy/${resourcePolicyId}`, { signal }); + + return res.data.data; + } }) }; From aea7df7dc24c46e3262849d977bab25040293640 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 8 Jun 2026 10:37:46 -0700 Subject: [PATCH 28/28] rename share links --- messages/en-US.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d92871562..f377c653d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -150,16 +150,16 @@ "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "siteInfo": "Site Information", "status": "Status", - "shareTitle": "Manage Share Links", + "shareTitle": "Manage Shareable Links", "shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources", - "shareSearch": "Search share links...", - "shareCreate": "Create Share Link", + "shareSearch": "Search shareable links...", + "shareCreate": "Create Shareable Link", "shareErrorDelete": "Failed to delete link", "shareErrorDeleteMessage": "An error occurred deleting link", "shareDeleted": "Link deleted", "shareDeletedDescription": "The link has been deleted", - "shareDelete": "Delete Share Link", - "shareDeleteConfirm": "Confirm Delete Share Link", + "shareDelete": "Delete Shareable Link", + "shareDeleteConfirm": "Confirm Delete Shareable Link", "shareQuestionRemove": "Are you sure you want to delete this share link?", "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", @@ -879,9 +879,9 @@ "resourcesErrorUpdateDescription": "An error occurred while updating the resource", "access": "Access", "accessControl": "Access Control", - "shareLink": "{resource} Share Link", + "shareLink": "{resource} Shareable Link", "resourceSelect": "Select resource", - "shareLinks": "Share Links", + "shareLinks": "Shareable Links", "share": "Shareable Links", "shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.", "shareEasyCreate": "Easy to create and share", @@ -1524,7 +1524,7 @@ "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", - "sidebarPolicies": "Policies", + "sidebarPolicies": "Shared Policies", "sidebarResourcePolicies": "Public Resources", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", @@ -1533,7 +1533,7 @@ "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", - "sidebarShareableLinks": "Share Links", + "sidebarShareableLinks": "Shareable Links", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings",