Compare commits

..

22 Commits

Author SHA1 Message Date
Owen Schwartz
6e271028f3 Merge pull request #3245 from fosrl/dev
Bugfixes
2026-06-11 16:17:41 -07:00
Owen
820f66e58f Properly hide things with disable enterprise flag 2026-06-11 16:10:29 -07:00
Owen
b0fdc10e06 Properly hide things with disable enterprise flag 2026-06-11 16:01:32 -07:00
miloschwartz
b82b41ed26 fix migration 2026-06-11 15:02:29 -07:00
miloschwartz
3e977ba00d make paid alert position more consistent on resource 2026-06-11 12:38:08 -07:00
Owen Schwartz
a724b07846 Merge pull request #3244 from fosrl/dev
fix paywalling
2026-06-11 12:27:49 -07:00
Owen
5f0bc71bcd Merge branch 'main' into dev 2026-06-11 12:26:31 -07:00
miloschwartz
aea7827c1a fix paywalling 2026-06-11 12:26:01 -07:00
Owen Schwartz
d865c4c55b Merge pull request #3242 from fosrl/dev
Use ssh like mode host
2026-06-11 11:29:45 -07:00
Owen
5baf0c3c09 Use ssh like mode host 2026-06-11 11:11:50 -07:00
Owen Schwartz
cfe33eb974 Merge pull request #3241 from fosrl/dev
dev
2026-06-10 21:47:44 -07:00
Owen
71273e1b1c Try to fix large query problem 2026-06-10 21:41:34 -07:00
Owen
02f6e2a8c3 Add ; fix lint 2026-06-10 20:56:26 -07:00
Owen Schwartz
3cc244a1d3 Merge pull request #3240 from fosrl/dev
Fix small bugs with paid features, ui, docs
2026-06-10 20:49:59 -07:00
Owen
1d9c4dd9e2 Fix padding 2026-06-10 20:46:53 -07:00
Owen
b9dd0c8e43 Add advantech install link 2026-06-10 20:46:43 -07:00
Owen
cd052976eb Properly paywall the edit policy screen 2026-06-10 20:38:59 -07:00
Owen
cc498f0e33 Properly paywall ui for labels 2026-06-10 20:32:07 -07:00
Owen
1a942937e6 Remove precheck on websocket for now 2026-06-10 20:24:41 -07:00
Owen
d81d1a6b7f Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-10 20:24:22 -07:00
Owen
f64d04e827 Add loading back to create resource 2026-06-10 18:23:01 -07:00
miloschwartz
540aee3fe2 update docs links 2026-06-10 17:52:42 -07:00
34 changed files with 1064 additions and 2776 deletions

View File

@@ -34,4 +34,5 @@ build.ts
tsconfig.json
Dockerfile*
drizzle.config.ts
allowedDevOrigins.json
allowedDevOrigins.json
scratch/

View File

@@ -214,6 +214,7 @@
"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.",
"resourcePoliciesBannerButtonText": "Learn More",
"resourcePoliciesTitle": "Manage Public Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
"resourcePoliciesAttachedResources": "{count} resource(s)",
@@ -983,7 +984,7 @@
"sharedPolicy": "Shared Policy",
"sharedPolicyNoneDescription": "This resource has its own policy.",
"resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.",
"resourceSharedPolicyInheritedDescription": "This resource inherits authentication and access rules controls from <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyInheritedDescription": "This resource inherits from <policyLink>{policyName}</policyLink>.",
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource to add to the policy. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
"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 <policyLink>{policyName}</policyLink>.",
"resourceUsersRoles": "Access Controls",

2912
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -43,24 +43,24 @@
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.12",
"@radix-ui/react-checkbox": "1.3.4",
"@radix-ui/react-collapsible": "1.1.13",
"@radix-ui/react-dialog": "1.1.16",
"@radix-ui/react-dropdown-menu": "2.1.17",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-label": "2.1.9",
"@radix-ui/react-popover": "1.1.16",
"@radix-ui/react-progress": "1.1.9",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.11",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.9",
"@radix-ui/react-slot": "1.2.5",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.14",
"@radix-ui/react-toast": "1.2.16",
"@radix-ui/react-tooltip": "1.2.9",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/body": "0.3.0",
"@react-email/components": "1.0.12",
"@react-email/render": "2.0.8",
@@ -91,32 +91,32 @@
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.1",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "16.2.9",
"next": "16.2.6",
"next-intl": "4.13.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "8.0.11",
"nodemailer": "8.0.9",
"oslo": "1.2.1",
"pg": "8.21.0",
"posthog-node": "5.35.6",
"qrcode.react": "4.2.0",
"react": "19.2.7",
"react": "19.2.6",
"react-day-picker": "9.14.0",
"react-dom": "19.2.7",
"react-dom": "19.2.6",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.76.1",
"react-icons": "5.6.0",
"recharts": "3.8.1",
"reodotdev": "1.1.0",
"semver": "7.8.4",
"semver": "7.8.1",
"sshpk": "1.18.0",
"stripe": "22.2.0",
"swagger-ui-express": "5.0.1",
@@ -152,10 +152,10 @@
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.9.1",
"@types/nodemailer": "8.0.1",
"@types/nodemailer": "8.0.0",
"@types/nprogress": "0.2.3",
"@types/pg": "8.20.0",
"@types/react": "19.2.17",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/sshpk": "1.17.4",

View File

@@ -415,7 +415,11 @@ export async function updatePrivateResources(
} else {
let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (resourceData.mode === "host" || resourceData.mode === "http") {
if (
resourceData.mode === "host" ||
resourceData.mode === "http" ||
resourceData.mode === "ssh"
) {
const { value, release } = await getNextAvailableAliasAddress(
orgId,
trx

View File

@@ -504,7 +504,7 @@ export function generateRemoteSubnets(
const parseResult = cidrSchema.safeParse(sr.destination);
return parseResult.success;
}
if (sr.mode === "host") {
if (sr.mode === "host" || sr.mode === "ssh") {
// check if its a valid IP using zod
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
const parseResult = ipSchema.safeParse(sr.destination);
@@ -514,7 +514,7 @@ export function generateRemoteSubnets(
})
.map((sr) => {
if (sr.mode === "cidr") return sr.destination;
if (sr.mode === "host") {
if (sr.mode === "host" || sr.mode === "ssh") {
return `${sr.destination}/32`;
}
return ""; // This should never be reached due to filtering, but satisfies TypeScript
@@ -531,7 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
.filter(
(sr) =>
sr.aliasAddress &&
((sr.alias && sr.mode == "host") ||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http"))
)
.map((sr) => ({
@@ -577,6 +577,10 @@ export function generateSubnetProxyTargets(
continue;
}
if (!siteResource.destination) {
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -584,7 +588,7 @@ export function generateSubnetProxyTargets(
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -665,6 +669,11 @@ export async function generateSubnetProxyTargetV2(
return;
}
if (!siteResource.destination) {
// ssh can have no destination
return;
}
const targets: SubnetProxyTargetV2[] = [];
const portRange = [
@@ -673,7 +682,7 @@ export async function generateSubnetProxyTargetV2(
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);

View File

@@ -181,6 +181,7 @@ class TelemetryClient {
let numPrivResourceHosts = 0;
let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
@@ -188,6 +189,8 @@ class TelemetryClient {
numPrivResourceCidr += 1;
} else if (res.mode === "http") {
numPrivResourceHttp += 1;
} else if (res.mode === "ssh") {
numPrivResourceSsh += 1;
}
if (res.alias) {
@@ -207,6 +210,7 @@ class TelemetryClient {
numPrivateResourceHosts: numPrivResourceHosts,
numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh,
numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Org } from "@server/db";
import { db, Org, primaryDb } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -635,9 +635,7 @@ export async function validateOidcCallback(
}
});
db.transaction(async (trx) => {
await calculateUserClientsForOrgs(userId!, trx);
}).catch((err) => {
calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => {
logger.error(
"Error calculating user clients after syncing orgs and roles for OIDC user",
{ error: err }

View File

@@ -7,7 +7,7 @@ import {
userOrgRoles,
userOrgs
} from "@server/db";
import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm";
import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
@@ -224,7 +224,7 @@ export async function listUserResourceAliases(
const whereClause = and(
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.mode, "host"),
or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")),
isNotNull(siteResources.alias),
ne(siteResources.alias, ""),
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)

View File

@@ -327,27 +327,6 @@ export async function listSites(
);
}
let accessibleSites;
if (req.user) {
accessibleSites = await db
.select({
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
})
.from(userSites)
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
.where(
or(
eq(userSites.userId, req.user!.userId),
inArray(roleSites.roleId, req.userOrgRoleIds!)
)
);
} else {
accessibleSites = await db
.select({ siteId: sites.siteId })
.from(sites)
.where(eq(sites.orgId, orgId));
}
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
@@ -364,14 +343,38 @@ export async function listSites(
labels: labelFilter
} = parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
const conditions = [eq(sites.orgId, orgId)];
const conditions = [
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
];
if (req.user) {
const userAccessConditions = [
inArray(
sites.siteId,
db
.select({ siteId: userSites.siteId })
.from(userSites)
.where(eq(userSites.userId, req.user.userId))
)
];
const roleIds = req.userOrgRoleIds ?? [];
if (roleIds.length > 0) {
userAccessConditions.push(
inArray(
sites.siteId,
db
.select({ siteId: roleSites.siteId })
.from(roleSites)
.where(inArray(roleSites.roleId, roleIds))
)
);
}
conditions.push(
userAccessConditions.length === 1
? userAccessConditions[0]
: or(...userAccessConditions)!
);
}
if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online));
@@ -418,17 +421,15 @@ export async function listSites(
)
);
}
conditions.push(or(...queryList));
conditions.push(or(...queryList)!);
}
const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
);
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(sites)
.where(and(...conditions));
const siteListQuery = baseQuery
.limit(pageSize)
@@ -441,11 +442,13 @@ export async function listSites(
: asc(sites.name)
);
const [totalCount, rows] = await Promise.all([
const [countRows, rows] = await Promise.all([
countQuery,
siteListQuery
]);
const totalCount = Number(countRows[0]?.count ?? 0);
// Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion();

View File

@@ -445,7 +445,7 @@ export async function createSiteResource(
let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (mode === "host" || mode === "http") {
if (mode === "host" || mode === "http" || mode === "ssh") {
const { value, release } =
await getNextAvailableAliasAddress(orgId);
aliasAddress = value;

View File

@@ -228,7 +228,7 @@ export default async function migration() {
).run();
db.prepare(
`
UPDATE 'siteResources' SET 'destination2' = 'destination';
UPDATE 'siteResources' SET "destination2" = "destination";
`
).run();
db.prepare(
@@ -349,9 +349,9 @@ export default async function migration() {
db.prepare(
`
UPDATE 'targets'
SET 'mode' = (
SELECT 'mode' FROM 'resources'
WHERE 'resources'.'resourceId' = 'targets'.'resourceId'
SET "mode" = (
SELECT "mode" FROM 'resources'
WHERE "resources"."resourceId" = "targets"."resourceId"
);
`
).run();

View File

@@ -3,7 +3,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
@@ -49,6 +51,8 @@ export default async function LabelsPage({ params, searchParams }: Props) {
description={t("orgLabelsDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelsTable
labels={labels}
orgId={orgId}

View File

@@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { ExternalLink } from "lucide-react";
import { env } from "process";
// Schema for general organization settings
const GeneralFormSchema = z.object({
@@ -165,6 +166,7 @@ function DeleteForm({ org }: SectionFormProps) {
function GeneralSectionForm({ org }: SectionFormProps) {
const { updateOrg } = useOrgContext();
const { env } = useEnvContext();
const form = useForm({
resolver: zodResolver(
GeneralFormSchema.pick({
@@ -265,36 +267,42 @@ function GeneralSectionForm({ org }: SectionFormProps) {
<PaidFeaturesAlert
tiers={tierMatrix.newtAutoUpdate}
/>
<FormField
control={form.control}
name="settingsEnableGlobalNewtAutoUpdate"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="settings-enable-global-newt-auto-update"
label={t("newtAutoUpdate")}
checked={field.value}
onCheckedChange={field.onChange}
disabled={!hasAutoUpdateFeature}
/>
</FormControl>
<FormDescription>
{t("newtAutoUpdateDescription")}{" "}
<a
href="https://docs.pangolin.net/manage/sites/configure-site#auto-update"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.disableEnterpriseFeatures && (
<FormField
control={form.control}
name="settingsEnableGlobalNewtAutoUpdate"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="settings-enable-global-newt-auto-update"
label={t("newtAutoUpdate")}
checked={field.value}
onCheckedChange={
field.onChange
}
disabled={
!hasAutoUpdateFeature
}
/>
</FormControl>
<FormDescription>
{t("newtAutoUpdateDescription")}{" "}
<a
href="https://docs.pangolin.net/manage/sites/auto-update"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -19,14 +19,14 @@ import {
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
SettingsSubsectionTitle,
SettingsFormCell
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -70,7 +70,7 @@ export default function GeneralForm() {
const api = createApiClient({ env });
const showResourcePolicy =
const hasResourcePolicies =
build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]);
@@ -86,7 +86,7 @@ export default function GeneralForm() {
...orgQueries.resourcePolicy({
resourcePolicyId: selectedSharedPolicyId!
}),
enabled: showResourcePolicy && selectedSharedPolicyId !== null
enabled: hasResourcePolicies && selectedSharedPolicyId !== null
});
const [resourceFullDomain, setResourceFullDomain] = useState(
@@ -153,11 +153,10 @@ export default function GeneralForm() {
let resourcePolicyId: number | null | undefined;
if (
showResourcePolicy &&
!["tcp", "udp"].includes(resource.mode)
) {
resourcePolicyId = selectedSharedPolicyId;
if (!["tcp", "udp"].includes(resource.mode)) {
if (hasResourcePolicies || selectedSharedPolicyId === null) {
resourcePolicyId = selectedSharedPolicyId;
}
}
const res = await api
@@ -297,28 +296,6 @@ export default function GeneralForm() {
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"resourceGeneralDetailsSubsection"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
[
"tcp",
"udp",
].includes(
resource.mode
)
? "resourceGeneralDetailsSubsectionPortDescription"
: "resourceGeneralDetailsSubsectionDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
@@ -476,10 +453,9 @@ export default function GeneralForm() {
</div>
</SettingsFormCell>
)}
{showResourcePolicy &&
!["tcp", "udp"].includes(
{ !["tcp", "udp"].includes(
resource.mode
) && (
) && !env.flags.disableEnterpriseFeatures && (
<>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>

View File

@@ -169,20 +169,27 @@ export default function ResourceMaintenancePage() {
{
id: "automatic",
title: `${t("automatic")} (${t("recommended")})`,
description: t("automaticModeDescription"),
disabled: isMaintenanceDisabled
description: t("automaticModeDescription")
},
{
id: "forced",
title: t("forced"),
description: t("forcedModeDescription"),
disabled: isMaintenanceDisabled
description: t("forcedModeDescription")
}
];
return (
<SettingsContainer>
<SettingsSection>
<>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<div
className={
isMaintenanceDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("maintenanceMode")}
@@ -193,7 +200,6 @@ export default function ResourceMaintenancePage() {
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<SettingsSectionForm variant="half">
<Form {...maintenanceForm}>
<form
@@ -205,46 +211,33 @@ export default function ResourceMaintenancePage() {
<FormField
control={maintenanceForm.control}
name="maintenanceModeEnabled"
render={({ field }) => {
const isDisabled = !isPaidUser(
tierMatrix.maintencePage
);
return (
<FormItem>
<FormControl>
<SwitchInput
id="enable-maintenance"
checked={
field.value
}
label={t(
"enableMaintenanceMode"
)}
description={t(
"enableMaintenanceModeDescription"
)}
disabled={
isDisabled
}
onCheckedChange={(
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-maintenance"
checked={
field.value
}
label={t(
"enableMaintenanceMode"
)}
description={t(
"enableMaintenanceModeDescription"
)}
onCheckedChange={(
val
) => {
maintenanceForm.setValue(
"maintenanceModeEnabled",
val
) => {
if (
!isDisabled
) {
maintenanceForm.setValue(
"maintenanceModeEnabled",
val
);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
@@ -329,11 +322,6 @@ export default function ResourceMaintenancePage() {
<FormControl>
<Input
{...field}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder="We'll be back soon!"
/>
</FormControl>
@@ -365,11 +353,6 @@ export default function ResourceMaintenancePage() {
<Textarea
{...field}
rows={4}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t(
"maintenancePageMessagePlaceholder"
)}
@@ -402,11 +385,6 @@ export default function ResourceMaintenancePage() {
<FormControl>
<Input
{...field}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t(
"maintenanceTime"
)}
@@ -430,20 +408,19 @@ export default function ResourceMaintenancePage() {
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={maintenanceSaveLoading}
disabled={
maintenanceSaveLoading ||
!isPaidUser(tierMatrix.maintencePage)
}
form="maintenance-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<SettingsSectionFooter>
<Button
type="submit"
loading={maintenanceSaveLoading}
disabled={maintenanceSaveLoading}
form="maintenance-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</div>
</>
);
}

View File

@@ -229,7 +229,7 @@ function RdpServerForm({
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp#site-and-host-configuration"
defaultPort={3389}
/>
</SettingsSectionForm>

View File

@@ -467,7 +467,7 @@ function SshServerForm({
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
href="https://docs.pangolin.net/manage/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
@@ -589,7 +589,7 @@ function SshServerForm({
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22}
/>
</SettingsFormCell>
@@ -602,7 +602,7 @@ function SshServerForm({
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22}
/>
</SettingsFormCell>

View File

@@ -80,7 +80,6 @@ import { toASCII } from "punycode";
import {
useMemo,
useState,
useTransition,
useEffect
} from "react";
import { useForm, type Resolver } from "react-hook-form";
@@ -229,7 +228,7 @@ export default function Page() {
>([]);
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
const [createLoading, startTransition] = useTransition();
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>("");
@@ -461,6 +460,7 @@ export default function Page() {
};
async function onSubmit() {
setCreateLoading(true);
const baseData = baseForm.getValues();
try {
@@ -707,6 +707,8 @@ export default function Page() {
t("resourceErrorCreateMessageDescription")
)
});
} finally {
setCreateLoading(false);
}
}
@@ -762,7 +764,7 @@ export default function Page() {
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
}
};
}
const typeOptions: OptionSelectOption<NewResourceType>[] =
@@ -1097,7 +1099,7 @@ export default function Page() {
"sshDaemonDisclaimer"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
href="https://docs.pangolin.net/manage/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
@@ -1235,7 +1237,7 @@ export default function Page() {
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22}
/>
</Form>
@@ -1256,7 +1258,7 @@ export default function Page() {
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
defaultPort={22}
/>
</Form>
@@ -1306,7 +1308,7 @@ export default function Page() {
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp#site-and-host-configuration"
defaultPort={3389}
/>
</Form>
@@ -1353,7 +1355,7 @@ export default function Page() {
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc#site-and-host-configuration"
defaultPort={5900}
/>
</Form>
@@ -1427,7 +1429,7 @@ export default function Page() {
}
}}
loading={createLoading}
disabled={!areAllTargetsValid() || browserGatewayDisabled}
disabled={!areAllTargetsValid() || browserGatewayDisabled || createLoading}
>
{t("resourceCreate")}
</Button>

View File

@@ -253,85 +253,87 @@ export default function GeneralPage() {
<PaidFeaturesAlert
tiers={tierMatrix.newtAutoUpdate}
/>
{site && site.type === "newt" && (
<FormField
control={form.control}
name="autoUpdateEnabled"
render={({ field }) => {
const isOverriding = form.watch(
"autoUpdateOverrideOrg"
);
return (
<FormItem>
<FormControl>
<div className="">
<SwitchInput
id="auto-update-enabled"
label={t(
"siteAutoUpdateLabel"
)}
checked={
field.value
}
onCheckedChange={(
checked
) => {
field.onChange(
{site &&
site.type === "newt" &&
!env.flags.disableEnterpriseFeatures && (
<FormField
control={form.control}
name="autoUpdateEnabled"
render={({ field }) => {
const isOverriding = form.watch(
"autoUpdateOverrideOrg"
);
return (
<FormItem>
<FormControl>
<div className="">
<SwitchInput
id="auto-update-enabled"
label={t(
"siteAutoUpdateLabel"
)}
checked={
field.value
}
onCheckedChange={(
checked
);
form.setValue(
"autoUpdateOverrideOrg",
true
);
}}
disabled={
!hasAutoUpdateFeature
}
/>
{isOverriding && (
<ButtonUI
type="button"
variant="link"
size="sm"
className="text-sm text-muted-foreground px-0"
onClick={() => {
) => {
field.onChange(
checked
);
form.setValue(
"autoUpdateOverrideOrg",
false
);
form.setValue(
"autoUpdateEnabled",
orgAutoUpdate
true
);
}}
>
{t(
"siteAutoUpdateResetToOrg"
)}
</ButtonUI>
)}
</div>
</FormControl>
<FormDescription>
{t(
"siteAutoUpdateDescription"
)}{" "}
<a
href="https://docs.pangolin.net/manage/sites/configure-site#auto-update"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
disabled={
!hasAutoUpdateFeature
}
/>
{isOverriding && (
<ButtonUI
type="button"
variant="link"
size="sm"
className="text-sm text-muted-foreground px-0"
onClick={() => {
form.setValue(
"autoUpdateOverrideOrg",
false
);
form.setValue(
"autoUpdateEnabled",
orgAutoUpdate
);
}}
>
{t(
"siteAutoUpdateResetToOrg"
)}
</ButtonUI>
)}
</div>
</FormControl>
<FormDescription>
{t(
"siteAutoUpdateDescription"
)}{" "}
<a
href="https://docs.pangolin.net/manage/sites/auto-update"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -23,7 +23,7 @@ import {
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { createElement, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
@@ -37,15 +37,6 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import {
FaApple,
FaCubes,
FaDocker,
FaFreebsd,
FaWindows
} from "react-icons/fa";
import { SiNixos, SiKubernetes } from "react-icons/si";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig";
import { createApiClient, formatAxiosError } from "@app/lib/api";

View File

@@ -156,10 +156,11 @@ export const orgNavSections = (
]
: []),
// PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||
env?.app.identityProviderMode === "org" ||
(env?.app.identityProviderMode === undefined && build !== "oss")
...(!env?.flags.disableEnterpriseFeatures &&
(build === "saas" ||
env?.app.identityProviderMode === "org" ||
(env?.app.identityProviderMode === undefined &&
build !== "oss"))
? [
{
title: "sidebarIdentityProviders",
@@ -259,7 +260,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
...(build !== "oss"
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "labels",

View File

@@ -596,7 +596,7 @@ export default function SshClient({
<p className="text-sm text-muted-foreground">
{t("sshPrivateKeyDisclaimer")}{" "}
<Link
href="https://docs.pangolin.net/"
href="https://docs.pangolin.net/manage/ssh#authentication-method"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -124,21 +124,21 @@ export default function VncClient({
authToken: target.authToken
});
try {
const checkParams = new URLSearchParams(params);
checkParams.set("checkOnly", "1");
const response = await fetch(`${base}?${checkParams.toString()}`);
if (!response.ok) {
const detail = (await response.text()).trim();
setConnectError(detail || t("sshErrorConnectionClosed"));
setConnecting(false);
return;
}
} catch {
setConnectError(t("sshErrorWebSocket"));
setConnecting(false);
return;
}
// try {
// const checkParams = new URLSearchParams(params);
// checkParams.set("checkOnly", "1");
// const response = await fetch(`${base}?${checkParams.toString()}`);
// if (!response.ok) {
// const detail = (await response.text()).trim();
// setConnectError(detail || t("sshErrorConnectionClosed"));
// setConnecting(false);
// return;
// }
// } catch {
// setConnectError(t("sshErrorWebSocket"));
// setConnecting(false);
// return;
// }
let RFB: new (
target: HTMLElement,

View File

@@ -222,7 +222,7 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
<a
href={
props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/public/ssh"
"https://docs.pangolin.net/manage/resources/private/multi-site-routing"
}
target="_blank"
rel="noopener noreferrer"

View File

@@ -1,8 +1,10 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
@@ -18,6 +20,7 @@ import {
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = {
@@ -35,6 +38,8 @@ export function CreateOrgLabelDialog({
}: CreateOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const canManageLabels = isPaidUser(tierMatrix.labels);
const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
@@ -79,8 +84,11 @@ export function CreateOrgLabelDialog({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm
disabled={!canManageLabels}
onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => createOrgLabel(data));
}}
/>
@@ -98,7 +106,7 @@ export function CreateOrgLabelDialog({
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
disabled={isSubmitting || !canManageLabels}
loading={isSubmitting}
>
{t("labelCreate")}

View File

@@ -1,8 +1,10 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
@@ -18,6 +20,7 @@ import {
CredenzaTitle
} from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = {
@@ -41,6 +44,8 @@ export function EditOrgLabelDialog({
}: EditOrgLabelDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const canManageLabels = isPaidUser(tierMatrix.labels);
const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) {
@@ -85,9 +90,12 @@ export function EditOrgLabelDialog({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm
disabled={!canManageLabels}
defaultValue={label}
onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => editOrgLabel(data));
}}
/>
@@ -105,7 +113,7 @@ export function EditOrgLabelDialog({
<Button
type="submit"
form="org-label-form"
disabled={isSubmitting}
disabled={isSubmitting || !canManageLabels}
loading={isSubmitting}
>
{t("labelEdit")}

View File

@@ -35,9 +35,14 @@ export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData;
disabled?: boolean;
};
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
export function OrgLabelForm({
onSubmit,
defaultValue,
disabled = false
}: OrgLabelFormProps) {
const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS);
@@ -70,9 +75,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
<FormItem>
<FormLabel>{t("labelNameField")}</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
@@ -88,6 +91,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
<Select
onValueChange={field.onChange}
value={field.value}
disabled={disabled}
>
<SelectTrigger className="w-full">
<SelectValue
@@ -110,7 +114,9 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
}}
/>
<span data-name>
{color.charAt(0).toUpperCase() +
{color
.charAt(0)
.toUpperCase() +
color.slice(1)}
</span>
</SelectItem>

View File

@@ -12,14 +12,7 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal,
PencilIcon,
PencilLineIcon
} from "lucide-react";
import { ArrowRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { useActionState, useMemo, useState, useTransition } from "react";
@@ -109,7 +102,7 @@ export default function OrgLabelsTable({
cell: ({ row }) => (
<div className="flex items-center gap-1.5 group">
<div
className="size-2.5 rounded-full bg-(--color) flex-none"
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": row.original.color
@@ -125,34 +118,40 @@ export default function OrgLabelsTable({
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t("openMenu")}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsEditModalOpen(true);
}}
>
{t("edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedLabel(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
onClick={() => {
setSelectedLabel(row.original);
setIsEditModalOpen(true);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
)
}
],

View File

@@ -2079,7 +2079,7 @@ export function PrivateResourceForm({
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/private/ssh"
href="https://docs.pangolin.net/manage/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -767,7 +767,7 @@ function TargetStatusCell({
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2 px-2">
<div className="flex items-center gap-2 px-0">
<StatusIcon status="unknown" />
<span className="text-sm">{t("resourcesTableNoTargets")}</span>
</div>

View File

@@ -1,7 +1,9 @@
"use client";
import { Shield } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Shield, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
export const ResourcePoliciesBanner = () => {
@@ -14,7 +16,22 @@ export const ResourcePoliciesBanner = () => {
title={t("resourcePoliciesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("resourcePoliciesBannerDescription")}
/>
>
<Link
href="https://docs.pangolin.net/manage/resources/public/resource-policies"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("resourcePoliciesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};

View File

@@ -20,6 +20,7 @@ import {
} from "react-icons/fa";
import { ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem = string | { title: string; command: string };
@@ -50,9 +51,12 @@ export function NewtSiteInstallCommands({
version = "latest"
}: NewtSiteInstallCommandsProps) {
const t = useTranslations();
const { env } = useEnvContext();
const [acceptClients, setAcceptClients] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(
!env.flags.disableEnterpriseFeatures
);
const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0]
@@ -71,7 +75,11 @@ export function NewtSiteInstallCommands({
: "";
const disableSshFlag =
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : "";
supportsSshOption &&
!allowPangolinSsh &&
!env.flags.disableEnterpriseFeatures
? " --disable-ssh"
: "";
const runAsRootPrefix =
supportsSshOption && allowPangolinSsh ? "sudo " : "";
@@ -306,27 +314,29 @@ WantedBy=default.target`
>
{t("siteAcceptClientConnectionsDescription")}
</p>
{supportsSshOption && (
<>
<div className="flex items-center space-x-2 mb-2 mt-2">
<CheckboxWithLabel
id="allowPangolinSsh"
checked={allowPangolinSsh}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAllowPangolinSsh(value);
}}
label="Allow Pangolin SSH"
/>
</div>
<p
id="allowPangolinSsh-desc"
className="text-sm text-muted-foreground"
>
{t("sitePangolinSshDescription")}
</p>
</>
)}
{supportsSshOption &&
!env.flags.disableEnterpriseFeatures && (
<>
<div className="flex items-center space-x-2 mb-2 mt-2">
<CheckboxWithLabel
id="allowPangolinSsh"
checked={allowPangolinSsh}
onCheckedChange={(checked) => {
const value =
checked as boolean;
setAllowPangolinSsh(value);
}}
label="Allow Pangolin SSH"
/>
</div>
<p
id="allowPangolinSsh-desc"
className="text-sm text-muted-foreground"
>
{t("sitePangolinSshDescription")}
</p>
</>
)}
</div>
)}
@@ -354,7 +364,7 @@ WantedBy=default.target`
{t.rich("siteInstallAdvantechDocsDescription", {
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/install-advantech"
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"

View File

@@ -8,13 +8,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { orgQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
export type EditPolicyFormSection = "general" | "authentication" | "rules";
@@ -71,13 +72,19 @@ export function EditPolicyForm({
return <></>;
}
const policyTiers = tierMatrix[TierFeature.ResourcePolicies];
const isInlinePolicy = hidePolicyNameForm && resourceId === undefined;
const showPaidAlert = !isInlinePolicy;
const isDisabled = showPaidAlert && !isPaidUser(policyTiers);
const effectiveReadonly = readonly || isDisabled;
const authSection = (
<PolicyAuthStackSection
mode="edit"
orgId={org.org.orgId}
allIdps={allIdps}
emailEnabled={env.email.emailEnabled}
readonly={readonly}
readonly={effectiveReadonly}
resourceId={resourceId}
/>
);
@@ -87,32 +94,82 @@ export function EditPolicyForm({
mode="edit"
isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindASNAvailable}
readonly={readonly}
readonly={effectiveReadonly}
resourceId={resourceId}
/>
);
if (section === "general") {
return <EditPolicyNameSectionForm readonly={readonly} />;
return (
<>
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
<EditPolicyNameSectionForm readonly={effectiveReadonly} />
</div>
</>
);
}
if (section === "authentication") {
return authSection;
return (
<>
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
{authSection}
</div>
</>
);
}
if (section === "rules") {
return rulesSection;
return (
<>
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
{rulesSection}
</div>
</>
);
}
return (
<SettingsContainer>
{!hidePolicyNameForm && !isOverlay && (
<EditPolicyNameSectionForm readonly={readonly} />
)}
<>
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<div
className={
isDisabled ? "pointer-events-none opacity-50" : undefined
}
>
<SettingsContainer>
{!hidePolicyNameForm && !isOverlay && (
<EditPolicyNameSectionForm
readonly={effectiveReadonly}
/>
)}
{authSection}
{authSection}
{rulesSection}
</SettingsContainer>
{rulesSection}
</SettingsContainer>
</div>
</>
);
}