From fba37b7ad06ac4c9acebcf4972a1d57ae2d5bc1d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 15:31:21 -0700 Subject: [PATCH 1/9] Add command to make other user server admin --- cli/commands/setServerAdmin.ts | 51 ++++++++++++++++++++++++++++++++++ cli/index.ts | 2 ++ 2 files changed, 53 insertions(+) create mode 100644 cli/commands/setServerAdmin.ts diff --git a/cli/commands/setServerAdmin.ts b/cli/commands/setServerAdmin.ts new file mode 100644 index 000000000..341b70bc1 --- /dev/null +++ b/cli/commands/setServerAdmin.ts @@ -0,0 +1,51 @@ +import { CommandModule } from "yargs"; +import { db, users } from "@server/db"; +import { eq } from "drizzle-orm"; + +type SetServerAdminArgs = { + email: string; +}; + +export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = { + command: "set-server-admin", + describe: "Mark any user as a server admin by email address", + builder: (yargs) => { + return yargs.option("email", { + type: "string", + demandOption: true, + describe: "User email address" + }); + }, + handler: async (argv: { email: string }) => { + try { + const email = argv.email.trim().toLowerCase(); + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user) { + console.error(`User with email '${email}' not found`); + process.exit(1); + } + + if (user.serverAdmin) { + console.log(`User '${email}' is already a server admin`); + process.exit(0); + } + + await db + .update(users) + .set({ serverAdmin: true }) + .where(eq(users.userId, user.userId)); + + console.log(`User '${email}' has been marked as a server admin`); + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; diff --git a/cli/index.ts b/cli/index.ts index 19585bc6f..cfa65625c 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -11,6 +11,7 @@ import { deleteClient } from "./commands/deleteClient"; import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; import { clearCertificates } from "./commands/clearCertificates"; import { disableUser2fa } from "./commands/disableUser2fa"; +import { setServerAdmin } from "./commands/setServerAdmin"; yargs(hideBin(process.argv)) .scriptName("pangctl") @@ -23,5 +24,6 @@ yargs(hideBin(process.argv)) .command(generateOrgCaKeys) .command(clearCertificates) .command(disableUser2fa) + .command(setServerAdmin) .demandCommand() .help().argv; From 88ea4391e0cf1c2d302d38994c8f84ac034cadbb Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 15:31:33 -0700 Subject: [PATCH 2/9] Show new types of resources right --- server/routers/resource/getUserResources.ts | 2 + src/components/MemberResourcesPortal.tsx | 149 ++++++++++++-------- 2 files changed, 94 insertions(+), 57 deletions(-) diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 4c7d3e9ad..114432a73 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -438,6 +438,7 @@ export async function getUserResources( return { siteResourceId: siteResource.siteResourceId, name: siteResource.name, + niceId: siteResource.niceId, destination: siteResource.destination, mode: siteResource.mode, ssl: siteResource.ssl, @@ -492,6 +493,7 @@ export type GetUserResourcesResponse = { siteResources: Array<{ siteResourceId: number; name: string; + niceId: string; destination: string; mode: string; tcpPortRangeString: string | null; diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index fded7df52..21b7a4cd7 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -41,6 +41,7 @@ import { TooltipTrigger } from "@/components/ui/tooltip"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import { Badge } from "@/components/ui/badge"; // Update Resource type to include site information type Resource = { @@ -49,7 +50,7 @@ type Resource = { domain: string; enabled: boolean; protected: boolean; - // mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh" + mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh" // Auth method fields sso?: boolean; password?: boolean; @@ -62,6 +63,7 @@ type Resource = { type SiteResource = { siteResourceId: number; name: string; + niceId: string; destination: string; mode: string; ssl: boolean; @@ -754,7 +756,13 @@ export default function MemberResourcesPortal({ -
+
+ + {resource.mode.toUpperCase()} + @@ -860,7 +868,13 @@ export default function MemberResourcesPortal({
-
+
+ + {siteResource.mode.toUpperCase()} +
@@ -876,24 +890,24 @@ export default function MemberResourcesPortal({ : - { - siteResource.mode - } - -
-
- - {t( - "memberPortalDestination" - )} - : - - - { - siteResource.destination - } + {siteResource.mode.toUpperCase()}
+ {siteResource.destination && ( +
+ + {t( + "memberPortalDestination" + )} + : + + + { + siteResource.destination + } + +
+ )} {siteResource.alias && (
@@ -942,45 +956,35 @@ export default function MemberResourcesPortal({ isLink={true} /> ) : siteResource.alias ? ( - <> - {/* Alias as primary */} -
-
- { - siteResource.alias - } -
- -
- {/* Destination as secondary */} -
- { - siteResource.destination - } -
- - ) : ( + duration: 2000 + }); + }} + > + + +
+ ) : siteResource.destination ? ( /* Destination as primary when no alias */
@@ -1011,6 +1015,37 @@ export default function MemberResourcesPortal({
+ ) : ( + /* niceId fallback when no alias and no destination */ +
+
+ { + siteResource.niceId + } +
+ +
)}
From 19feaf4bf28e256a3df0a47b9ab3d17b12bf8671 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 15:47:55 -0700 Subject: [PATCH 3/9] Add the policy information into missing places --- server/db/queries/verifySessionQueries.ts | 57 ++++++++++++++++---- server/db/sqlite/schema/schema.ts | 9 ++++ server/private/routers/hybrid.ts | 63 +++++++++++++++++++---- server/private/routers/ssh/signSshKey.ts | 35 +------------ 4 files changed, 113 insertions(+), 51 deletions(-) diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 17844e13c..d1f933979 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -26,15 +26,22 @@ import { userPolicies, users, ResourceHeaderAuthExtendedCompatibility, - resourceHeaderAuthExtendedCompatibility + resourceHeaderAuthExtendedCompatibility, + resourcePolicies, + resourcePolicyPincode, + ResourcePolicyPincode, + resourcePolicyPassword, + ResourcePolicyPassword, + resourcePolicyHeaderAuth, + ResourcePolicyHeaderAuth } from "@server/db"; import { and, eq, inArray, or, sql } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; - pincode: ResourcePincode | null; - password: ResourcePassword | null; - headerAuth: ResourceHeaderAuth | null; + pincode: ResourcePincode | ResourcePolicyPincode | null; + password: ResourcePassword | ResourcePolicyPassword | null; + headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; }; @@ -82,6 +89,31 @@ export async function getResourceByDomain( resources.resourceId ) ) + .leftJoin( + resourcePolicies, + eq(resourcePolicies.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPassword, + eq( + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .where( or( @@ -113,11 +145,18 @@ export async function getResourceByDomain( return { resource: result.resources, - pincode: result.resourcePincode, - password: result.resourcePassword, - headerAuth: result.resourceHeaderAuth, - headerAuthExtendedCompatibility: - result.resourceHeaderAuthExtendedCompatibility, + pincode: result.resourcePolicyPincode ?? result.resourcePincode, + password: result.resourcePolicyPassword ?? result.resourcePassword, + headerAuth: + result.resourcePolicyHeaderAuth ?? result.resourceHeaderAuth, + headerAuthExtendedCompatibility: result.resourcePolicyHeaderAuth + ? ({ + headerAuthExtendedCompatibilityId: 0, + resourceId: result.resources.resourceId, + extendedCompatibilityIsActivated: + result.resourcePolicyHeaderAuth.extendedCompatibility + } as ResourceHeaderAuthExtendedCompatibility) + : result.resourceHeaderAuthExtendedCompatibility, org: result.orgs }; } diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index aff55b74e..4291df6b0 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1545,5 +1545,14 @@ export type RoundTripMessageTracker = InferSelectModel< export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; export type ResourcePolicy = InferSelectModel; +export type ResourcePolicyPincode = InferSelectModel< + typeof resourcePolicyPincode +>; +export type ResourcePolicyPassword = InferSelectModel< + typeof resourcePolicyPassword +>; +export type ResourcePolicyHeaderAuth = InferSelectModel< + typeof resourcePolicyHeaderAuth +>; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 11f46e68d..27100c3eb 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -35,7 +35,14 @@ import { ResourceHeaderAuthExtendedCompatibility, orgs, requestAuditLog, - Org + Org, + resourcePolicies, + resourcePolicyPincode, + ResourcePolicyPincode, + resourcePolicyPassword, + ResourcePolicyPassword, + resourcePolicyHeaderAuth, + ResourcePolicyHeaderAuth } from "@server/db"; import { resources, @@ -204,9 +211,9 @@ export type ValidateResourceSessionTokenBody = z.infer< // Type definitions for API responses export type ResourceWithAuth = { resource: Resource | null; - pincode: ResourcePincode | null; - password: ResourcePassword | null; - headerAuth: ResourceHeaderAuth | null; + pincode: ResourcePincode | ResourcePolicyPincode | null; + password: ResourcePassword | ResourcePolicyPassword | null; + headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; }; @@ -529,6 +536,34 @@ hybridRouter.get( resources.resourceId ) ) + .leftJoin( + resourcePolicies, + eq( + resourcePolicies.resourcePolicyId, + resources.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPassword, + eq( + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .where( or( @@ -581,11 +616,21 @@ hybridRouter.get( const resourceWithAuth: ResourceWithAuth = { resource: result.resources, - pincode: result.resourcePincode, - password: result.resourcePassword, - headerAuth: result.resourceHeaderAuth, - headerAuthExtendedCompatibility: - result.resourceHeaderAuthExtendedCompatibility, + pincode: result.resourcePolicyPincode ?? result.resourcePincode, + password: + result.resourcePolicyPassword ?? result.resourcePassword, + headerAuth: + result.resourcePolicyHeaderAuth ?? + result.resourceHeaderAuth, + headerAuthExtendedCompatibility: result.resourcePolicyHeaderAuth + ? ({ + headerAuthExtendedCompatibilityId: 0, + resourceId: result.resources.resourceId, + extendedCompatibilityIsActivated: + result.resourcePolicyHeaderAuth + .extendedCompatibility + } as ResourceHeaderAuthExtendedCompatibility) + : result.resourceHeaderAuthExtendedCompatibility, org: result.orgs }; diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 3919306cf..efddfc0d9 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -78,41 +78,9 @@ export type SignSshKeyResponse = { validAfter?: string; validBefore?: string; expiresIn?: number; + authDaemonMode: "site" | "remote" | "native" | null; }; -// registry.registerPath({ -// method: "post", -// path: "/org/{orgId}/ssh/sign-key", -// description: "Sign an SSH public key for access to a resource.", -// tags: [OpenAPITags.Org, OpenAPITags.Ssh], -// request: { -// params: paramsSchema, -// body: { -// content: { -// "application/json": { -// schema: bodySchema -// } -// } -// } -// }, -// responses: { -// 200: { -// description: "Successful response", -// content: { -// "application/json": { -// schema: z.object({ -// data: z.unknown().nullable(), -// success: z.boolean(), -// error: z.boolean(), -// message: z.string(), -// status: z.number() -// }) -// } -// } -// } -// } -// }); - export async function signSshKey( req: Request, res: Response, @@ -654,6 +622,7 @@ export async function signSshKey( siteIds: siteIds, siteId: siteIds[0], // just pick the first one for backward compatibility with older olms keyId: cert?.keyId, + authDaemonMode: resource.authDaemonMode, validPrincipals: cert?.validPrincipals, validAfter: cert?.validAfter.toISOString(), validBefore: cert?.validBefore.toISOString(), From 8bcc130947f9192a9b05d1b44933fd7ccfe75cd8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 16:33:05 -0700 Subject: [PATCH 4/9] Make sure the right type of select shows --- .../siteResource/createSiteResource.ts | 2 - .../siteResource/updateSiteResource.ts | 21 ++++++++-- .../resources/public/[niceId]/ssh/page.tsx | 3 +- .../settings/resources/public/create/page.tsx | 42 +++++++++---------- .../CreatePrivateResourceDialog.tsx | 8 ++-- src/components/EditPrivateResourceDialog.tsx | 8 ++-- src/components/PrivateResourceForm.tsx | 29 +++++++++++-- 7 files changed, 75 insertions(+), 38 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index e189d73e7..a15b10555 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -49,7 +49,6 @@ const createSiteResourceSchema = z scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()).optional(), siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided - // proxyPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(), destination: z.string().min(1).optional(), enabled: z.boolean().default(true), @@ -248,7 +247,6 @@ export async function createSiteResource( siteId, mode, scheme, - // proxyPort, destinationPort, destination, enabled, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 50b942cb6..a52d15d6a 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -59,7 +59,6 @@ const updateSiteResourceSchema = z mode: z.enum(["host", "cidr", "http", "ssh"]).optional(), ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), - // proxyPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), enabled: z.boolean().optional(), @@ -632,6 +631,15 @@ export async function updateSiteResource( }) } : {}; + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } + [updatedSiteResource] = await trx .update(siteResources) .set({ @@ -644,9 +652,14 @@ export async function updateSiteResource( destinationPort: destinationPort, enabled: enabled, alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeString, - udpPortRangeString: udpPortRangeString, - disableIcmp: disableIcmp, + tcpPortRangeString: tcpPortRangeStringAdjusted, + udpPortRangeString: + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, + disableIcmp: + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), domainId, subdomain: finalSubdomain, fullDomain, 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 cc733d6ab..de207b872 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -480,7 +480,8 @@ function SshServerForm({ /> - ) : standardDaemonLocation !== "site" ? ( + ) : standardDaemonLocation !== "site" || + pamMode === "passthrough" ? (
- {/* Auth Method (standard only) */} - {!isNative && ( -
-

- {t( - "sshAuthenticationMethod" - )} -

- - value={pamMode} - options={ - authMethodOptions - } - onChange={setPamMode} - cols={2} - /> -
- )} +
+

+ {t( + "sshAuthenticationMethod" + )} +

+ + value={pamMode} + options={ + authMethodOptions + } + onChange={setPamMode} + cols={2} + /> +
{/* Daemon Location (standard + push) */} {showDaemonLocation && ( @@ -1046,7 +1042,9 @@ export default function Page() { ) : standardDaemonLocation !== - "site" ? ( + "site" || + pamMode === + "passthrough" ? ( parseInt(r.id)) : [], diff --git a/src/components/EditPrivateResourceDialog.tsx b/src/components/EditPrivateResourceDialog.tsx index 2c390dedb..077c85ee0 100644 --- a/src/components/EditPrivateResourceDialog.tsx +++ b/src/components/EditPrivateResourceDialog.tsx @@ -104,6 +104,7 @@ export default function EditPrivateResourceDialog({ data.alias.trim() ? data.alias : null, + destinationPort: data.destinationPort ?? null, pamMode: data.pamMode ?? undefined, ...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode @@ -112,13 +113,14 @@ export default function EditPrivateResourceDialog({ authDaemonPort: data.authDaemonPort || null }) }), - ...((data.mode === "host" || - data.mode === "ssh" || - data.mode === "cidr") && { + ...((data.mode === "host" || data.mode === "cidr") && { tcpPortRangeString: data.tcpPortRangeString, udpPortRangeString: data.udpPortRangeString, disableIcmp: data.disableIcmp ?? false }), + ...(data.mode === "ssh" && { + disableIcmp: data.disableIcmp ?? false + }), roleIds: (data.roles || []).map((r) => parseInt(r.id)), userIds: (data.users || []).map((u) => u.id), clientIds: (data.clients || []).map((c) => parseInt(c.id)) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 1557c89f5..adbbc5f22 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -365,6 +365,19 @@ export function PrivateResourceForm({ path: ["destination"] }); } + if (data.mode === "ssh" && !isNativeSsh) { + if ( + data.destinationPort == null || + !Number.isFinite(data.destinationPort) || + data.destinationPort < 1 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("internalResourceHttpPortRequired"), + path: ["destinationPort"] + }); + } + } if (data.mode !== "http") return; if (!data.scheme) { ctx.addIssue({ @@ -548,7 +561,7 @@ export function PrivateResourceForm({ mode: "host", destination: "", alias: null, - destinationPort: null, + destinationPort: 22, scheme: "http", ssl: true, httpConfigSubdomain: null, @@ -735,6 +748,7 @@ export function PrivateResourceForm({ onSubmit={form.handleSubmit((values) => { const siteIds = values.siteIds; const trimmedDestination = values.destination?.trim(); + const isSshMode = values.mode === "ssh"; onSubmit({ ...values, siteIds, @@ -742,6 +756,12 @@ export function PrivateResourceForm({ trimmedDestination && trimmedDestination.length > 0 ? trimmedDestination : null, + tcpPortRangeString: isSshMode + ? undefined + : values.tcpPortRangeString, + udpPortRangeString: isSshMode + ? undefined + : values.udpPortRangeString, clients: (values.clients ?? []).map((c) => ({ id: c.clientId.toString(), text: c.name @@ -826,8 +846,11 @@ export function PrivateResourceForm({ {t("sites")} {mode === "ssh" && - sshServerMode === - "native" ? ( + (sshServerMode === + "native" || + (pamMode === "push" && + authDaemonMode === + "site")) ? ( Date: Tue, 2 Jun 2026 16:38:04 -0700 Subject: [PATCH 5/9] Restrict the number of sites in the api --- .../siteResource/createSiteResource.ts | 19 +++++++ .../siteResource/updateSiteResource.ts | 19 +++++++ src/components/PrivateResourceForm.tsx | 53 +++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index a15b10555..0d012bf25 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -173,6 +173,25 @@ const createSiteResourceSchema = z { message: "At least one of siteIds or siteId must be provided" } + ) + .refine( + (data) => { + if (data.mode !== "ssh") return true; + const isSingleSiteMode = + data.authDaemonMode === "native" || + (data.pamMode === "push" && data.authDaemonMode === "site") || + (data.pamMode === "push" && data.authDaemonMode === undefined); + if (!isSingleSiteMode) return true; + const effectiveSiteIds = [ + ...(data.siteIds ?? []), + ...(data.siteId !== undefined ? [data.siteId] : []) + ]; + const uniqueSiteIds = new Set(effectiveSiteIds); + return uniqueSiteIds.size <= 1; + }, + { + message: "Only one site is allowed for this SSH daemon mode" + } ); export type CreateSiteResourceBody = z.infer; diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index a52d15d6a..ffdf51f35 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -181,6 +181,25 @@ const updateSiteResourceSchema = z { message: "At least one of siteIds or siteId must be provided" } + ) + .refine( + (data) => { + if (data.mode !== "ssh") return true; + const isSingleSiteMode = + data.authDaemonMode === "native" || + (data.pamMode === "push" && data.authDaemonMode === "site") || + (data.pamMode === "push" && data.authDaemonMode === undefined); + if (!isSingleSiteMode) return true; + const effectiveSiteIds = [ + ...(data.siteIds ?? []), + ...(data.siteId !== undefined ? [data.siteId] : []) + ]; + const uniqueSiteIds = new Set(effectiveSiteIds); + return uniqueSiteIds.size <= 1; + }, + { + message: "Only one site is allowed for this SSH daemon mode" + } ); export type UpdateSiteResourceBody = z.infer; diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index adbbc5f22..fb80e0ce0 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -1885,6 +1885,36 @@ export function PrivateResourceForm({ "authDaemonPort", null ); + } else if ( + v === "push" + ) { + // push + site (default) = single site + const curAuthMode = + form.getValues( + "authDaemonMode" + ); + if ( + curAuthMode !== + "remote" && + selectedSites.length > + 1 + ) { + const first = + selectedSites.slice( + 0, + 1 + ); + setSelectedSites( + first + ); + form.setValue( + "siteIds", + first.map( + (s) => + s.siteId + ) + ); + } } }} cols={2} @@ -1952,6 +1982,29 @@ export function PrivateResourceForm({ "authDaemonPort", null ); + // site daemon = single site + if ( + selectedSites.length > + 1 + ) { + const first = + selectedSites.slice( + 0, + 1 + ); + setSelectedSites( + first + ); + form.setValue( + "siteIds", + first.map( + ( + s + ) => + s.siteId + ) + ); + } } }} cols={2} From ffd0d17b58bc9649421775523b73884a78c2936b Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 16:42:26 -0700 Subject: [PATCH 6/9] Add proxy protocl support in blueprints --- server/lib/blueprints/proxyResources.ts | 30 ++++++++++++++++++++++++- server/lib/blueprints/types.ts | 21 ++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index d4a2d1909..28bc1c90d 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -337,6 +337,15 @@ export async function updateProxyResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + proxyProtocol: + resourceData.mode === "tcp" + ? (resourceData["proxy-protocol"] ?? false) + : false, + proxyProtocolVersion: + resourceData.mode === "tcp" + ? (resourceData["proxy-protocol-version"] ?? + 1) + : 1, resourcePolicyId: sharedPolicy.resourcePolicyId }) .where( @@ -504,6 +513,15 @@ export async function updateProxyResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + proxyProtocol: + resourceData.mode === "tcp" + ? (resourceData["proxy-protocol"] ?? false) + : false, + proxyProtocolVersion: + resourceData.mode === "tcp" + ? (resourceData["proxy-protocol-version"] ?? + 1) + : 1, resourcePolicyId: null, defaultResourcePolicyId: inlinePolicyId }) @@ -994,6 +1012,14 @@ export async function updateProxyResources( maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + proxyProtocol: + resourceData.mode === "tcp" + ? (resourceData["proxy-protocol"] ?? false) + : false, + proxyProtocolVersion: + resourceData.mode === "tcp" + ? (resourceData["proxy-protocol-version"] ?? 1) + : 1, defaultResourcePolicyId: inlinePolicy.resourcePolicyId, resourcePolicyId: sharedPolicyId, // Only set these resource-level fields when using a shared policy @@ -1231,7 +1257,9 @@ async function syncRoleResources( })) ); role = created; - logger.info(`Auto-created role "${roleName}" in org ${orgId} from blueprint`); + logger.info( + `Auto-created role "${roleName}" in org ${orgId} from blueprint` + ); } if (role.isAdmin) { diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index fc540a730..454d83aa9 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -201,7 +201,9 @@ export const PublicResourceSchema = z headers: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional(), - "auth-daemon": AuthDaemonSchema.optional() + "auth-daemon": AuthDaemonSchema.optional(), + "proxy-protocol": z.boolean().optional(), + "proxy-protocol-version": z.int().min(1).optional() }) .refine( (resource) => { @@ -378,6 +380,23 @@ export const PublicResourceSchema = z 'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.' } ) + .refine( + (resource) => { + const effectiveMode = resource.mode ?? resource.protocol; + if (effectiveMode !== "tcp") { + return ( + resource["proxy-protocol"] === undefined && + resource["proxy-protocol-version"] === undefined + ); + } + return true; + }, + { + path: ["proxy-protocol"], + message: + "'proxy-protocol' and 'proxy-protocol-version' can only be set when mode is 'tcp'" + } + ) .transform((resource) => { // Normalize: prefer mode, fall back to protocol for backwards compatibility if (resource.mode === undefined && resource.protocol !== undefined) { From 12cbd405968c5f82a7044af2b1fbb5c2441a0206 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 16:56:58 -0700 Subject: [PATCH 7/9] Fix types --- server/routers/badger/verifySession.ts | 27 ++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index fde80316b..2557a2678 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -17,6 +17,9 @@ import { ResourceHeaderAuthExtendedCompatibility, ResourcePassword, ResourcePincode, + ResourcePolicyPincode, + ResourcePolicyPassword, + ResourcePolicyHeaderAuth, ResourceRule } from "@server/db"; import config from "@server/lib/config"; @@ -134,9 +137,12 @@ export async function verifyResourceSession( let resourceData: | { resource: Resource | null; - pincode: ResourcePincode | null; - password: ResourcePassword | null; - headerAuth: ResourceHeaderAuth | null; + pincode: ResourcePincode | ResourcePolicyPincode | null; + password: ResourcePassword | ResourcePolicyPassword | null; + headerAuth: + | ResourceHeaderAuth + | ResourcePolicyHeaderAuth + | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; } @@ -577,7 +583,11 @@ export async function verifyResourceSession( return notAllowed(res, redirectPath, resource.orgId); } - if (pincode && resourceSession.pincodeId) { + if ( + pincode && + (resourceSession.pincodeId || + resourceSession.policyPincodeId) + ) { logger.debug( "Resource allowed because pincode session is valid" ); @@ -596,7 +606,11 @@ export async function verifyResourceSession( return allowed(res, undefined, dontStripSession); } - if (password && resourceSession.passwordId) { + if ( + password && + (resourceSession.passwordId || + resourceSession.policyPasswordId) + ) { logger.debug( "Resource allowed because password session is valid" ); @@ -617,7 +631,8 @@ export async function verifyResourceSession( if ( resource.emailWhitelistEnabled && - resourceSession.whitelistId + (resourceSession.whitelistId || + resourceSession.policyWhitelistId) ) { logger.debug( "Resource allowed because whitelist session is valid" From 128db20755a7090a7e67dca39bc97b5569d2e49a Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 17:13:10 -0700 Subject: [PATCH 8/9] Remove migration test --- server/setup/scriptsSqlite/1.19.0.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index ac6f53901..9ea84261b 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -32,8 +32,6 @@ export function generateName(): string { return name.replace(/[^a-z0-9-]/g, ""); } -await migration(); - export default async function migration() { console.log(`Running setup script ${version}...`); From f2f56dc6c24af2b683e8d241deaad269893d11e8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Jun 2026 18:06:42 -0700 Subject: [PATCH 9/9] Properly paywall the new resource types --- server/lib/billing/tierMatrix.ts | 22 ++++-- .../routers/billing/featureLifecycle.ts | 13 ++-- server/private/routers/external.ts | 2 +- server/private/routers/ssh/signSshKey.ts | 2 +- server/routers/resource/createResource.ts | 17 ++++- server/routers/role/createRole.ts | 33 ++++++--- server/routers/role/updateRole.ts | 18 +++-- .../siteResource/createSiteResource.ts | 13 +++- .../siteResource/updateSiteResource.ts | 4 +- .../resources/public/[niceId]/rdp/page.tsx | 70 ++++++++++++------- .../resources/public/[niceId]/ssh/page.tsx | 20 +++++- .../resources/public/[niceId]/vnc/page.tsx | 70 ++++++++++++------- .../settings/resources/public/create/page.tsx | 66 ++++++++++++++++- src/components/CreateRoleForm.tsx | 14 ++-- src/components/EditRoleForm.tsx | 14 ++-- src/components/PrivateResourceForm.tsx | 43 +++++++++--- src/components/RoleForm.tsx | 6 +- 17 files changed, 312 insertions(+), 115 deletions(-) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 2065398f2..45434aac4 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -16,18 +16,18 @@ export enum TierFeature { SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning - SshPam = "sshPam", FullRbac = "fullRbac", SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SIEM = "siem", // handle downgrade by disabling SIEM integrations - HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces StandaloneHealthChecks = "standaloneHealthChecks", AlertingRules = "alertingRules", WildcardSubdomain = "wildcardSubdomain", Labels = "labels", NewtAutoUpdate = "newtAutoUpdate", - ResourcePolicies = "resourcePolicies" + ResourcePolicies = "resourcePolicies", + AdvancedPublicResources = "advancedPublicResources", + AdvancedPrivateResources = "advancedPrivateResources" } export const tierMatrix: Record = { @@ -62,15 +62,25 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SIEM]: ["enterprise"], - [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"], [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.ResourcePolicies]: ["tier3", "enterprise"] + [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], + [TierFeature.AdvancedPublicResources]: [ + "tier1", + "tier2", + "tier3", + "enterprise" + ], + [TierFeature.AdvancedPrivateResources]: [ + "tier1", + "tier2", + "tier3", + "enterprise" + ] }; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 6cb98ba5d..75d11c756 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -308,8 +308,8 @@ async function disableFeature( await disableAutoProvisioning(orgId); break; - case TierFeature.SshPam: - await disableSshPam(orgId); + case TierFeature.AdvancedPrivateResources: + await disableAdvancedPrivateResources(orgId); break; case TierFeature.FullRbac: @@ -357,10 +357,11 @@ async function disableDeviceApprovals(orgId: string): Promise { logger.info(`Disabled device approvals on all roles for org ${orgId}`); } -async function disableSshPam(orgId: string): Promise { - logger.info( - `Disabled SSH PAM options on all roles and site resources for org ${orgId}` - ); +async function disableAdvancedPrivateResources(orgId: string): Promise { + // TODO: implement logic to disable advanced private resourcs like ssh and ssh pam + // logger.info( + // `Disabled advanced private resources on all roles and site resources for org ${orgId}` + // ); } async function disableFullRbac(orgId: string): Promise { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 82d63d7dd..0598a1514 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -610,7 +610,7 @@ authenticated.put( authenticated.post( "/org/:orgId/ssh/sign-key", verifyValidLicense, - verifyValidSubscription(tierMatrix.sshPam), + verifyValidSubscription(tierMatrix.advancedPrivateResources), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.signSshKey), diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index efddfc0d9..72152108a 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -149,7 +149,7 @@ export async function signSshKey( const isLicensed = await isLicensedOrSubscribed( orgId, - tierMatrix.sshPam + tierMatrix.advancedPrivateResources ); if (!isLicensed) { return next( diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 44af04f7c..0bb90e7b8 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -31,7 +31,7 @@ import { } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { getUniqueResourceName, getUniqueResourcePolicyName @@ -342,6 +342,21 @@ async function createHttpResource( } } + if ( + ["ssh", "rdp", "vnc"].includes(mode!) && + !isLicensedOrSubscribed( + orgId!, + tierMatrix[TierFeature.AdvancedPublicResources] + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Your current subscription does not support browser gateway resources. Please upgrade to access this feature." + ) + ); + } + // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index d7aceb743..e193c5018 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -123,23 +123,40 @@ export async function createRole( ); } - const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + const isLicensedDeviceApprovals = await isLicensedOrSubscribed( + orgId, + tierMatrix.deviceApprovals + ); if (!isLicensedDeviceApprovals) { roleData.requireDeviceApproval = undefined; } - const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + const isLicensedSshPam = await isLicensedOrSubscribed( + orgId, + tierMatrix.advancedPrivateResources + ); const roleInsertValues: Record = { name: roleData.name, orgId }; - if (roleData.description !== undefined) roleInsertValues.description = roleData.description; - if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval; + if (roleData.description !== undefined) + roleInsertValues.description = roleData.description; + if (roleData.requireDeviceApproval !== undefined) + roleInsertValues.requireDeviceApproval = + roleData.requireDeviceApproval; if (isLicensedSshPam) { - if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode; - if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands); - if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir; - if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups); + if (roleData.sshSudoMode !== undefined) + roleInsertValues.sshSudoMode = roleData.sshSudoMode; + if (roleData.sshSudoCommands !== undefined) + roleInsertValues.sshSudoCommands = JSON.stringify( + roleData.sshSudoCommands + ); + if (roleData.sshCreateHomeDir !== undefined) + roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir; + if (roleData.sshUnixGroups !== undefined) + roleInsertValues.sshUnixGroups = JSON.stringify( + roleData.sshUnixGroups + ); } await db.transaction(async (trx) => { diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 29f43850f..eb3239419 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -134,12 +134,18 @@ export async function updateRole( ); } - const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + const isLicensedDeviceApprovals = await isLicensedOrSubscribed( + orgId, + tierMatrix.deviceApprovals + ); if (!isLicensedDeviceApprovals) { updateData.requireDeviceApproval = undefined; } - const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + const isLicensedSshPam = await isLicensedOrSubscribed( + orgId, + tierMatrix.advancedPrivateResources + ); if (!isLicensedSshPam) { delete updateData.sshSudoMode; delete updateData.sshSudoCommands; @@ -147,10 +153,14 @@ export async function updateRole( delete updateData.sshUnixGroups; } else { if (Array.isArray(updateData.sshSudoCommands)) { - updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands); + updateData.sshSudoCommands = JSON.stringify( + updateData.sshSudoCommands + ); } if (Array.isArray(updateData.sshUnixGroups)) { - updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups); + updateData.sshUnixGroups = JSON.stringify( + updateData.sshUnixGroups + ); } } diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 0d012bf25..3f38cc7e1 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -293,7 +293,7 @@ export async function createSiteResource( if (mode == "http") { const hasHttpFeature = await isLicensedOrSubscribed( orgId, - tierMatrix[TierFeature.HTTPPrivateResources] + tierMatrix[TierFeature.AdvancedPrivateResources] ); if (!hasHttpFeature) { return next( @@ -425,9 +425,18 @@ export async function createSiteResource( const isLicensedSshPam = await isLicensedOrSubscribed( orgId, - tierMatrix.sshPam + tierMatrix.advancedPrivateResources ); + if (mode == "ssh" && !isLicensedSshPam) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "SSH private resources are not included in your current plan. Please upgrade." + ) + ); + } + let updatedNiceId = niceId; if (!niceId) { updatedNiceId = await getUniqueSiteResourceName(orgId); diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index ffdf51f35..d503a2b5c 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -314,7 +314,7 @@ export async function updateSiteResource( if (mode == "http") { const hasHttpFeature = await isLicensedOrSubscribed( existingSiteResource.orgId, - tierMatrix[TierFeature.HTTPPrivateResources] + tierMatrix[TierFeature.AdvancedPrivateResources] ); if (!hasHttpFeature) { return next( @@ -328,7 +328,7 @@ export async function updateSiteResource( const isLicensedSshPam = await isLicensedOrSubscribed( existingSiteResource.orgId, - tierMatrix.sshPam + tierMatrix.advancedPrivateResources ); const [org] = await db 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 defd4891a..ee564156a 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/rdp/page.tsx @@ -10,11 +10,14 @@ import { SettingsSectionTitle } 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 { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { useQuery } from "@tanstack/react-query"; @@ -48,13 +51,21 @@ export default function SshSettingsPage(props: { }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); + const { isPaidUser } = usePaidStatus(); + const disabled = !isPaidUser( + tierMatrix[TierFeature.AdvancedPublicResources] + ); return ( + ); @@ -63,11 +74,13 @@ export default function SshSettingsPage(props: { function SshServerForm({ orgId, resource, - updateResource + updateResource, + disabled }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; + disabled: boolean; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -220,31 +233,36 @@ function SshServerForm({ {t("rdpServerDescription")} - - - - - -
- -
+
+ + + + + +
+ +
+
); } 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 de207b872..1c65eef91 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -11,10 +11,13 @@ import { } from "@app/components/Settings"; import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SitesSelector, type Selectedsite } from "@app/components/site-selector"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { @@ -68,13 +71,21 @@ export default function SshSettingsPage(props: { }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); + const { isPaidUser } = usePaidStatus(); + const disabled = !isPaidUser( + tierMatrix[TierFeature.AdvancedPublicResources] + ); return ( + ); @@ -83,11 +94,13 @@ export default function SshSettingsPage(props: { function SshServerForm({ orgId, resource, - updateResource + updateResource, + disabled }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; + disabled: boolean; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -366,6 +379,10 @@ function SshServerForm({ {t("sshServerDescription")} +
@@ -520,6 +537,7 @@ function SshServerForm({ {t("saveSettings")} +
); } 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 93c35925e..51efd0311 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/vnc/page.tsx @@ -10,11 +10,14 @@ import { SettingsSectionTitle } 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 { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { useQuery } from "@tanstack/react-query"; @@ -46,13 +49,21 @@ export default function SshSettingsPage(props: { }) { const params = use(props.params); const { resource, updateResource } = useResourceContext(); + const { isPaidUser } = usePaidStatus(); + const disabled = !isPaidUser( + tierMatrix[TierFeature.AdvancedPublicResources] + ); return ( + ); @@ -61,11 +72,13 @@ export default function SshSettingsPage(props: { function SshServerForm({ orgId, resource, - updateResource + updateResource, + disabled }: { orgId: string; resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; + disabled: boolean; }) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -218,31 +231,36 @@ function SshServerForm({ {t("vncServerDescription")} - - - - - -
- -
+
+ + + + + +
+ +
+
); } diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 93a2d6d72..93c8e8934 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -72,7 +72,10 @@ import { } from "@app/components/ui/tooltip"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries } from "@app/lib/queries"; @@ -226,6 +229,8 @@ export default function Page() { orgQueries.sites({ orgId: orgId as string }) ); + const { isPaidUser } = usePaidStatus(); + const [remoteExitNodes, setRemoteExitNodes] = useState< ListRemoteExitNodesResponse["remoteExitNodes"] >([]); @@ -238,6 +243,14 @@ export default function Page() { // Resource type state const [resourceType, setResourceType] = useState("http"); + const isBrowserGatewayType = + resourceType === "ssh" || + resourceType === "rdp" || + resourceType === "vnc"; + const browserGatewayDisabled = + isBrowserGatewayType && + !isPaidUser(tierMatrix[TierFeature.AdvancedPublicResources]); + // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); @@ -870,6 +883,14 @@ export default function Page() { {/* SSH Server Section */} {resourceType === "ssh" && ( + {t("sshServer")} @@ -878,6 +899,14 @@ export default function Page() { {t("sshServerDescription")} +
{/* Mode */} @@ -1098,12 +1127,21 @@ export default function Page() {
+ )} {/* RDP Server Section */} {resourceType === "rdp" && ( + {t("rdpServer")} @@ -1112,6 +1150,14 @@ export default function Page() { {t("rdpServerDescription")} +
+
)} {/* VNC Server Section */} {resourceType === "vnc" && ( + {t("vncServer")} @@ -1150,6 +1205,14 @@ export default function Page() { {t("vncServerDescription")} +
+
)} @@ -1225,7 +1289,7 @@ export default function Page() { } }} loading={createLoading} - disabled={!areAllTargetsValid()} + disabled={!areAllTargetsValid() || browserGatewayDisabled} > {t("resourceCreate")} diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 537618ec0..080d5d293 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -16,10 +16,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import type { - CreateRoleBody, - CreateRoleResponse -} from "@server/routers/role"; +import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useTransition } from "react"; @@ -50,7 +47,7 @@ export default function CreateRoleForm({ requireDeviceApproval: values.requireDeviceApproval, allowSsh: values.allowSsh }; - if (isPaidUser(tierMatrix.sshPam)) { + if (isPaidUser(tierMatrix.advancedPrivateResources)) { payload.sshSudoMode = values.sshSudoMode; payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshSudoCommands = @@ -69,10 +66,9 @@ export default function CreateRoleForm({ } } const res = await api - .put>( - `/org/${org?.org.orgId}/role`, - payload - ) + .put< + AxiosResponse + >(`/org/${org?.org.orgId}/role`, payload) .catch((e) => { toast({ variant: "destructive", diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index aea0b93ad..8fa674bd1 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -16,10 +16,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import type { Role } from "@server/db"; -import type { - UpdateRoleBody, - UpdateRoleResponse -} from "@server/routers/role"; +import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useTransition } from "react"; @@ -53,7 +50,7 @@ export default function EditRoleForm({ payload.name = values.name; payload.description = values.description || undefined; } - if (isPaidUser(tierMatrix.sshPam)) { + if (isPaidUser(tierMatrix.advancedPrivateResources)) { payload.sshSudoMode = values.sshSudoMode; payload.sshCreateHomeDir = values.sshCreateHomeDir; payload.sshSudoCommands = @@ -72,10 +69,9 @@ export default function EditRoleForm({ } } const res = await api - .post>( - `/role/${role.roleId}`, - payload - ) + .post< + AxiosResponse + >(`/role/${role.roleId}`, payload) .catch((e) => { toast({ variant: "destructive", diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index fb80e0ce0..856e18885 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -224,8 +224,10 @@ export function PrivateResourceForm({ const { env } = useEnvContext(); const { isPaidUser } = usePaidStatus(); const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; - const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); - const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources); + const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); + const httpSectionDisabled = !isPaidUser( + tierMatrix.advancedPrivateResources + ); const nameRequiredKey = variant === "create" @@ -594,6 +596,7 @@ export function PrivateResourceForm({ const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); const isHttpMode = mode === "http"; + const isSshMode = mode === "ssh"; const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const pamMode = form.watch("pamMode") ?? "passthrough"; const isNative = sshServerMode === "native"; @@ -739,8 +742,17 @@ export function PrivateResourceForm({ ]); useEffect(() => { - onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled); - }, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]); + onSubmitDisabledChange?.( + (isHttpMode && httpSectionDisabled) || + (isSshMode && sshSectionDisabled) + ); + }, [ + isHttpMode, + httpSectionDisabled, + isSshMode, + sshSectionDisabled, + onSubmitDisabledChange + ]); return (
@@ -1129,8 +1141,10 @@ export function PrivateResourceForm({ "" } disabled={ - isHttpMode && - httpSectionDisabled + (isHttpMode && + httpSectionDisabled) || + (isSshMode && + sshSectionDisabled) } onChange={(e) => field.onChange( @@ -1169,6 +1183,10 @@ export function PrivateResourceForm({ field.value ?? "" } + disabled={ + isSshMode && + sshSectionDisabled + } /> @@ -1202,7 +1220,10 @@ export function PrivateResourceForm({ "" } disabled={ - httpSectionDisabled + (isHttpMode && + httpSectionDisabled) || + (isSshMode && + sshSectionDisabled) } onChange={(e) => { const raw = @@ -1237,9 +1258,9 @@ export function PrivateResourceForm({
- {isHttpMode && ( + {(isHttpMode || isSshMode) && ( )} @@ -1773,7 +1794,9 @@ export function PrivateResourceForm({ {/* SSH Access tab (ssh mode only) */} {!disableEnterpriseFeatures && mode === "ssh" && (
- + {/* Mode */}
diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index 2194344b7..9b8d7034b 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -164,7 +164,7 @@ export function RoleForm({ } }, [variant, role, form]); - const sshDisabled = !isPaidUser(tierMatrix.sshPam); + const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); const sshSudoMode = form.watch("sshSudoMode"); const isAdminRole = variant === "edit" && role?.isAdmin === true; @@ -319,7 +319,9 @@ export function RoleForm({ {/* SSH tab - hidden when enterprise features are disabled */} {!env.flags.disableEnterpriseFeatures && (
- +