Properly paywall the new resource types

This commit is contained in:
Owen
2026-06-02 18:06:42 -07:00
parent 128db20755
commit f2f56dc6c2
17 changed files with 312 additions and 115 deletions

View File

@@ -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<TierFeature, Tier[]> = {
@@ -62,15 +62,25 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"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"
]
};

View File

@@ -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<void> {
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
}
async function disableSshPam(orgId: string): Promise<void> {
logger.info(
`Disabled SSH PAM options on all roles and site resources for org ${orgId}`
);
async function disableAdvancedPrivateResources(orgId: string): Promise<void> {
// 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<void> {

View File

@@ -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),

View File

@@ -149,7 +149,7 @@ export async function signSshKey(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.sshPam
tierMatrix.advancedPrivateResources
);
if (!isLicensed) {
return next(

View File

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

View File

@@ -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<string, unknown> = {
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) => {

View File

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

View File

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

View File

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

View File

@@ -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 (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
/>
</SettingsContainer>
);
@@ -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")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
defaultPort={3389}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</fieldset>
</SettingsSection>
);
}

View File

@@ -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 (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
/>
</SettingsContainer>
);
@@ -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")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
@@ -520,6 +537,7 @@ function SshServerForm({
{t("saveSettings")}
</Button>
</form>
</fieldset>
</SettingsSection>
);
}

View File

@@ -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 (
<SettingsContainer>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
/>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
disabled={disabled}
/>
</SettingsContainer>
);
@@ -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")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
<fieldset
disabled={disabled}
className={disabled ? "opacity-50 pointer-events-none" : ""}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
defaultPort={5900}
/>
</SettingsSectionForm>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</fieldset>
</SettingsSection>
);
}

View File

@@ -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<NewResourceType>("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<LocalTarget[]>([]);
@@ -870,6 +883,14 @@ export default function Page() {
{/* SSH Server Section */}
{resourceType === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
@@ -878,6 +899,14 @@ export default function Page() {
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
{/* Mode */}
@@ -1098,12 +1127,21 @@ export default function Page() {
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* RDP Server Section */}
{resourceType === "rdp" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("rdpServer")}
@@ -1112,6 +1150,14 @@ export default function Page() {
{t("rdpServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
@@ -1136,12 +1182,21 @@ export default function Page() {
/>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* VNC Server Section */}
{resourceType === "vnc" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={
tierMatrix[
TierFeature
.AdvancedPublicResources
]
}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("vncServer")}
@@ -1150,6 +1205,14 @@ export default function Page() {
{t("vncServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={browserGatewayDisabled}
className={
browserGatewayDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<BrowserGatewayTargetForm
@@ -1174,6 +1237,7 @@ export default function Page() {
/>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
@@ -1225,7 +1289,7 @@ export default function Page() {
}
}}
loading={createLoading}
disabled={!areAllTargetsValid()}
disabled={!areAllTargetsValid() || browserGatewayDisabled}
>
{t("resourceCreate")}
</Button>

View File

@@ -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<AxiosResponse<CreateRoleResponse>>(
`/org/${org?.org.orgId}/role`,
payload
)
.put<
AxiosResponse<CreateRoleResponse>
>(`/org/${org?.org.orgId}/role`, payload)
.catch((e) => {
toast({
variant: "destructive",

View File

@@ -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<AxiosResponse<UpdateRoleResponse>>(
`/role/${role.roleId}`,
payload
)
.post<
AxiosResponse<UpdateRoleResponse>
>(`/role/${role.roleId}`, payload)
.catch((e) => {
toast({
variant: "destructive",

View File

@@ -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 (
<Form {...form}>
@@ -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
}
/>
</FormControl>
<FormMessage />
@@ -1202,7 +1220,10 @@ export function PrivateResourceForm({
""
}
disabled={
httpSectionDisabled
(isHttpMode &&
httpSectionDisabled) ||
(isSshMode &&
sshSectionDisabled)
}
onChange={(e) => {
const raw =
@@ -1237,9 +1258,9 @@ export function PrivateResourceForm({
</div>
</div>
{isHttpMode && (
{(isHttpMode || isSshMode) && (
<PaidFeaturesAlert
tiers={tierMatrix.httpPrivateResources}
tiers={tierMatrix.advancedPrivateResources}
/>
)}
@@ -1773,7 +1794,9 @@ export function PrivateResourceForm({
{/* SSH Access tab (ssh mode only) */}
{!disableEnterpriseFeatures && mode === "ssh" && (
<div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
{/* Mode */}
<div className="space-y-3">

View File

@@ -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 && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<FormField
control={form.control}
name="allowSsh"