Compare commits

...

28 Commits

Author SHA1 Message Date
Owen Schwartz d9952b0762 Merge pull request #3250 from fosrl/dev
1.19.1
2026-06-11 22:05:24 -07:00
Owen 935593885a Adjust 1.19 and add 1.19.1 to ensure sso not null 2026-06-11 22:01:20 -07:00
miloschwartz 3fcfd3304f fix address input width 2026-06-11 18:34:22 -07:00
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
Owen Schwartz 10542d7282 Merge pull request #3239 from fosrl/dev
1.19.0
2026-06-10 16:50:32 -07:00
Owen Schwartz 7fa1180d10 Merge pull request #3221 from fosrl/dev
1.19.0-rc.1
2026-06-04 15:45:27 -07:00
Owen Schwartz 8b50f1fb65 Merge pull request #3218 from fosrl/dev
Fix installer
2026-06-04 11:21:59 -07:00
Owen Schwartz 527d4cc777 Merge pull request #3215 from fosrl/dev
1.19.0-rc.0
2026-06-04 10:34:20 -07:00
28 changed files with 539 additions and 406 deletions
+1
View File
@@ -35,3 +35,4 @@ tsconfig.json
Dockerfile* Dockerfile*
drizzle.config.ts drizzle.config.ts
allowedDevOrigins.json allowedDevOrigins.json
scratch/
+1 -1
View File
@@ -984,7 +984,7 @@
"sharedPolicy": "Shared Policy", "sharedPolicy": "Shared Policy",
"sharedPolicyNoneDescription": "This resource has its own policy.", "sharedPolicyNoneDescription": "This resource has its own policy.",
"resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.", "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>.", "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>.", "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", "resourceUsersRoles": "Access Controls",
+5 -1
View File
@@ -415,7 +415,11 @@ export async function updatePrivateResources(
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | 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( const { value, release } = await getNextAvailableAliasAddress(
orgId, orgId,
trx trx
+14 -5
View File
@@ -504,7 +504,7 @@ export function generateRemoteSubnets(
const parseResult = cidrSchema.safeParse(sr.destination); const parseResult = cidrSchema.safeParse(sr.destination);
return parseResult.success; return parseResult.success;
} }
if (sr.mode === "host") { if (sr.mode === "host" || sr.mode === "ssh") {
// check if its a valid IP using zod // check if its a valid IP using zod
const ipSchema = z.union([z.ipv4(), z.ipv6()]); const ipSchema = z.union([z.ipv4(), z.ipv6()]);
const parseResult = ipSchema.safeParse(sr.destination); const parseResult = ipSchema.safeParse(sr.destination);
@@ -514,7 +514,7 @@ export function generateRemoteSubnets(
}) })
.map((sr) => { .map((sr) => {
if (sr.mode === "cidr") return sr.destination; if (sr.mode === "cidr") return sr.destination;
if (sr.mode === "host") { if (sr.mode === "host" || sr.mode === "ssh") {
return `${sr.destination}/32`; return `${sr.destination}/32`;
} }
return ""; // This should never be reached due to filtering, but satisfies TypeScript return ""; // This should never be reached due to filtering, but satisfies TypeScript
@@ -531,7 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
.filter( .filter(
(sr) => (sr) =>
sr.aliasAddress && sr.aliasAddress &&
((sr.alias && sr.mode == "host") || ((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http")) (sr.fullDomain && sr.mode == "http"))
) )
.map((sr) => ({ .map((sr) => ({
@@ -577,6 +577,10 @@ export function generateSubnetProxyTargets(
continue; continue;
} }
if (!siteResource.destination) {
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
const portRange = [ const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -584,7 +588,7 @@ export function generateSubnetProxyTargets(
]; ];
const disableIcmp = siteResource.disableIcmp ?? false; const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") { if (siteResource.mode == "host" || siteResource.mode == "ssh") {
let destination = siteResource.destination; let destination = siteResource.destination;
// check if this is a valid ip // check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]); const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -665,6 +669,11 @@ export async function generateSubnetProxyTargetV2(
return; return;
} }
if (!siteResource.destination) {
// ssh can have no destination
return;
}
const targets: SubnetProxyTargetV2[] = []; const targets: SubnetProxyTargetV2[] = [];
const portRange = [ const portRange = [
@@ -673,7 +682,7 @@ export async function generateSubnetProxyTargetV2(
]; ];
const disableIcmp = siteResource.disableIcmp ?? false; const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") { if (siteResource.mode == "host" || siteResource.mode == "ssh") {
let destination = siteResource.destination; let destination = siteResource.destination;
// check if this is a valid ip // check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]); const ipSchema = z.union([z.ipv4(), z.ipv6()]);
+4
View File
@@ -181,6 +181,7 @@ class TelemetryClient {
let numPrivResourceHosts = 0; let numPrivResourceHosts = 0;
let numPrivResourceCidr = 0; let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0; let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0;
for (const res of allPrivateResources) { for (const res of allPrivateResources) {
if (res.mode === "host") { if (res.mode === "host") {
numPrivResourceHosts += 1; numPrivResourceHosts += 1;
@@ -188,6 +189,8 @@ class TelemetryClient {
numPrivResourceCidr += 1; numPrivResourceCidr += 1;
} else if (res.mode === "http") { } else if (res.mode === "http") {
numPrivResourceHttp += 1; numPrivResourceHttp += 1;
} else if (res.mode === "ssh") {
numPrivResourceSsh += 1;
} }
if (res.alias) { if (res.alias) {
@@ -207,6 +210,7 @@ class TelemetryClient {
numPrivateResourceHosts: numPrivResourceHosts, numPrivateResourceHosts: numPrivResourceHosts,
numPrivateResourceCidr: numPrivResourceCidr, numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp, numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh,
numAlertRules: numAlertRules.count, numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count, numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count, numMachineClients: machineClients.count,
+2 -4
View File
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, Org } from "@server/db"; import { db, Org, primaryDb } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -635,9 +635,7 @@ export async function validateOidcCallback(
} }
}); });
db.transaction(async (trx) => { calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => {
await calculateUserClientsForOrgs(userId!, trx);
}).catch((err) => {
logger.error( logger.error(
"Error calculating user clients after syncing orgs and roles for OIDC user", "Error calculating user clients after syncing orgs and roles for OIDC user",
{ error: err } { error: err }
@@ -7,7 +7,7 @@ import {
userOrgRoles, userOrgRoles,
userOrgs userOrgs
} from "@server/db"; } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -224,7 +224,7 @@ export async function listUserResourceAliases(
const whereClause = and( const whereClause = and(
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.mode, "host"), or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")),
isNotNull(siteResources.alias), isNotNull(siteResources.alias),
ne(siteResources.alias, ""), ne(siteResources.alias, ""),
inArray(siteResources.siteResourceId, accessibleSiteResourceIds) inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
+39 -36
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( const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId, orgId,
tierMatrix.labels tierMatrix.labels
@@ -364,14 +343,38 @@ export async function listSites(
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const conditions = [eq(sites.orgId, orgId)];
const conditions = [ if (req.user) {
and( const userAccessConditions = [
inArray(sites.siteId, accessibleSiteIds), inArray(
eq(sites.orgId, orgId) 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") { if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online)); 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)); const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery const countQuery = db
const countQuery = db.$count( .select({ count: sql<number>`count(*)` })
querySitesBase() .from(sites)
.where(and(...conditions)) .where(and(...conditions));
.as("filtered_sites")
);
const siteListQuery = baseQuery const siteListQuery = baseQuery
.limit(pageSize) .limit(pageSize)
@@ -441,11 +442,13 @@ export async function listSites(
: asc(sites.name) : asc(sites.name)
); );
const [totalCount, rows] = await Promise.all([ const [countRows, rows] = await Promise.all([
countQuery, countQuery,
siteListQuery siteListQuery
]); ]);
const totalCount = Number(countRows[0]?.count ?? 0);
// Get latest version asynchronously without blocking the response // Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion(); const latestNewtVersionPromise = getLatestNewtVersion();
@@ -445,7 +445,7 @@ export async function createSiteResource(
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null; let releaseAliasLock: (() => Promise<void>) | null = null;
if (mode === "host" || mode === "http") { if (mode === "host" || mode === "http" || mode === "ssh") {
const { value, release } = const { value, release } =
await getNextAvailableAliasAddress(orgId); await getNextAvailableAliasAddress(orgId);
aliasAddress = value; aliasAddress = value;
+3 -1
View File
@@ -44,6 +44,7 @@ import m38 from "./scriptsSqlite/1.18.0";
import m39 from "./scriptsSqlite/1.18.3"; import m39 from "./scriptsSqlite/1.18.3";
import m40 from "./scriptsSqlite/1.18.4"; import m40 from "./scriptsSqlite/1.18.4";
import m41 from "./scriptsSqlite/1.19.0"; import m41 from "./scriptsSqlite/1.19.0";
import m42 from "./scriptsSqlite/1.19.1";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -85,7 +86,8 @@ const migrations = [
{ version: "1.18.0", run: m38 }, { version: "1.18.0", run: m38 },
{ version: "1.18.3", run: m39 }, { version: "1.18.3", run: m39 },
{ version: "1.18.4", run: m40 }, { version: "1.18.4", run: m40 },
{ version: "1.19.0", run: m41 } { version: "1.19.0", run: m41 },
{ version: "1.19.1", run: m42 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;
+27 -23
View File
@@ -228,7 +228,7 @@ export default async function migration() {
).run(); ).run();
db.prepare( db.prepare(
` `
UPDATE 'siteResources' SET 'destination2' = 'destination'; UPDATE 'siteResources' SET "destination2" = "destination";
` `
).run(); ).run();
db.prepare( db.prepare(
@@ -349,9 +349,9 @@ export default async function migration() {
db.prepare( db.prepare(
` `
UPDATE 'targets' UPDATE 'targets'
SET 'mode' = ( SET "mode" = (
SELECT 'mode' FROM 'resources' SELECT "mode" FROM 'resources'
WHERE 'resources'.'resourceId' = 'targets'.'resourceId' WHERE "resources"."resourceId" = "targets"."resourceId"
); );
` `
).run(); ).run();
@@ -680,25 +680,6 @@ export default async function migration() {
deleteResourceRules.run(resource.resourceId); deleteResourceRules.run(resource.resourceId);
deleteResourceWhitelist.run(resource.resourceId); deleteResourceWhitelist.run(resource.resourceId);
} }
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'sso' integer;`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;`
).run();
}); });
migrateInlinePolicies(); migrateInlinePolicies();
@@ -707,6 +688,29 @@ export default async function migration() {
); );
} }
// add one more transaction
db.transaction(() => {
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'sso' integer;`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;`
).run();
})();
console.log("Migrated database"); console.log("Migrated database");
} catch (e) { } catch (e) {
console.log("Failed to migrate db:", e); console.log("Failed to migrate db:", e);
+59
View File
@@ -0,0 +1,59 @@
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.19.1";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.transaction(() => {
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'sso2' integer;`
).run();
db.prepare(`UPDATE 'resources' SET "sso2" = "sso";`).run();
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
db.prepare(
`ALTER TABLE 'resources' RENAME COLUMN 'sso2' TO 'sso';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'applyRules2' integer;`
).run();
db.prepare(
`UPDATE 'resources' SET "applyRules2" = "applyRules";`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
).run();
db.prepare(
`ALTER TABLE 'resources' RENAME COLUMN 'applyRules2' TO 'applyRules';`
).run();
db.prepare(
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled2' integer;`
).run();
db.prepare(
`UPDATE 'resources' SET "emailWhitelistEnabled2" = "emailWhitelistEnabled";`
).run();
db.prepare(
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
).run();
db.prepare(
`ALTER TABLE 'resources' RENAME COLUMN 'emailWhitelistEnabled2' TO 'emailWhitelistEnabled';`
).run();
})();
console.log("Migrated database");
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}
@@ -3,7 +3,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable"; import OrgLabelsTable from "@app/components/OrgLabelsTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -49,6 +51,8 @@ export default async function LabelsPage({ params, searchParams }: Props) {
description={t("orgLabelsDescription")} description={t("orgLabelsDescription")}
/> />
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelsTable <OrgLabelsTable
labels={labels} labels={labels}
orgId={orgId} orgId={orgId}
+38 -30
View File
@@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { env } from "process";
// Schema for general organization settings // Schema for general organization settings
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
@@ -165,6 +166,7 @@ function DeleteForm({ org }: SectionFormProps) {
function GeneralSectionForm({ org }: SectionFormProps) { function GeneralSectionForm({ org }: SectionFormProps) {
const { updateOrg } = useOrgContext(); const { updateOrg } = useOrgContext();
const { env } = useEnvContext();
const form = useForm({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
GeneralFormSchema.pick({ GeneralFormSchema.pick({
@@ -265,36 +267,42 @@ function GeneralSectionForm({ org }: SectionFormProps) {
<PaidFeaturesAlert <PaidFeaturesAlert
tiers={tierMatrix.newtAutoUpdate} tiers={tierMatrix.newtAutoUpdate}
/> />
<FormField {!env.flags.disableEnterpriseFeatures && (
control={form.control} <FormField
name="settingsEnableGlobalNewtAutoUpdate" control={form.control}
render={({ field }) => ( name="settingsEnableGlobalNewtAutoUpdate"
<FormItem> render={({ field }) => (
<FormControl> <FormItem>
<SwitchInput <FormControl>
id="settings-enable-global-newt-auto-update" <SwitchInput
label={t("newtAutoUpdate")} id="settings-enable-global-newt-auto-update"
checked={field.value} label={t("newtAutoUpdate")}
onCheckedChange={field.onChange} checked={field.value}
disabled={!hasAutoUpdateFeature} onCheckedChange={
/> field.onChange
</FormControl> }
<FormDescription> disabled={
{t("newtAutoUpdateDescription")}{" "} !hasAutoUpdateFeature
<a }
href="https://docs.pangolin.net/manage/sites/auto-update" />
target="_blank" </FormControl>
rel="noopener noreferrer" <FormDescription>
className="text-primary hover:underline inline-flex items-center gap-1" {t("newtAutoUpdateDescription")}{" "}
> <a
{t("learnMore")} href="https://docs.pangolin.net/manage/sites/auto-update"
<ExternalLink className="size-3.5 shrink-0" /> target="_blank"
</a> rel="noopener noreferrer"
</FormDescription> className="text-primary hover:underline inline-flex items-center gap-1"
<FormMessage /> >
</FormItem> {t("learnMore")}
)} <ExternalLink className="size-3.5 shrink-0" />
/> </a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@@ -19,14 +19,14 @@ import {
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionFooter, SettingsSectionFooter,
SettingsFormCell,
SettingsFormGrid, SettingsFormGrid,
SettingsSectionForm, SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle, SettingsSectionTitle,
SettingsSubsectionDescription, SettingsSubsectionDescription,
SettingsSubsectionHeader, SettingsSubsectionHeader,
SettingsSubsectionTitle SettingsSubsectionTitle,
SettingsFormCell
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -70,7 +70,7 @@ export default function GeneralForm() {
const api = createApiClient({ env }); const api = createApiClient({ env });
const showResourcePolicy = const hasResourcePolicies =
build !== "oss" && build !== "oss" &&
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]); isPaidUser(tierMatrix[TierFeature.ResourcePolicies]);
@@ -86,7 +86,7 @@ export default function GeneralForm() {
...orgQueries.resourcePolicy({ ...orgQueries.resourcePolicy({
resourcePolicyId: selectedSharedPolicyId! resourcePolicyId: selectedSharedPolicyId!
}), }),
enabled: showResourcePolicy && selectedSharedPolicyId !== null enabled: hasResourcePolicies && selectedSharedPolicyId !== null
}); });
const [resourceFullDomain, setResourceFullDomain] = useState( const [resourceFullDomain, setResourceFullDomain] = useState(
@@ -153,11 +153,10 @@ export default function GeneralForm() {
let resourcePolicyId: number | null | undefined; let resourcePolicyId: number | null | undefined;
if ( if (!["tcp", "udp"].includes(resource.mode)) {
showResourcePolicy && if (hasResourcePolicies || selectedSharedPolicyId === null) {
!["tcp", "udp"].includes(resource.mode) resourcePolicyId = selectedSharedPolicyId;
) { }
resourcePolicyId = selectedSharedPolicyId;
} }
const res = await api const res = await api
@@ -297,28 +296,6 @@ export default function GeneralForm() {
/> />
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"resourceGeneralDetailsSubsection"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
[
"tcp",
"udp",
].includes(
resource.mode
)
? "resourceGeneralDetailsSubsectionPortDescription"
: "resourceGeneralDetailsSubsectionDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}
@@ -476,10 +453,9 @@ export default function GeneralForm() {
</div> </div>
</SettingsFormCell> </SettingsFormCell>
)} )}
{showResourcePolicy && { !["tcp", "udp"].includes(
!["tcp", "udp"].includes(
resource.mode resource.mode
) && ( ) && !env.flags.disableEnterpriseFeatures && (
<> <>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<SettingsSubsectionHeader> <SettingsSubsectionHeader>
@@ -169,20 +169,27 @@ export default function ResourceMaintenancePage() {
{ {
id: "automatic", id: "automatic",
title: `${t("automatic")} (${t("recommended")})`, title: `${t("automatic")} (${t("recommended")})`,
description: t("automaticModeDescription"), description: t("automaticModeDescription")
disabled: isMaintenanceDisabled
}, },
{ {
id: "forced", id: "forced",
title: t("forced"), title: t("forced"),
description: t("forcedModeDescription"), description: t("forcedModeDescription")
disabled: isMaintenanceDisabled
} }
]; ];
return ( return (
<SettingsContainer> <>
<SettingsSection> <PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<div
className={
isMaintenanceDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
{t("maintenanceMode")} {t("maintenanceMode")}
@@ -193,7 +200,6 @@ export default function ResourceMaintenancePage() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...maintenanceForm}> <Form {...maintenanceForm}>
<form <form
@@ -205,46 +211,33 @@ export default function ResourceMaintenancePage() {
<FormField <FormField
control={maintenanceForm.control} control={maintenanceForm.control}
name="maintenanceModeEnabled" name="maintenanceModeEnabled"
render={({ field }) => { render={({ field }) => (
const isDisabled = !isPaidUser( <FormItem>
tierMatrix.maintencePage <FormControl>
); <SwitchInput
id="enable-maintenance"
return ( checked={
<FormItem> field.value
<FormControl> }
<SwitchInput label={t(
id="enable-maintenance" "enableMaintenanceMode"
checked={ )}
field.value description={t(
} "enableMaintenanceModeDescription"
label={t( )}
"enableMaintenanceMode" onCheckedChange={(
)} val
description={t( ) => {
"enableMaintenanceModeDescription" maintenanceForm.setValue(
)} "maintenanceModeEnabled",
disabled={
isDisabled
}
onCheckedChange={(
val val
) => { );
if ( }}
!isDisabled />
) { </FormControl>
maintenanceForm.setValue( <FormMessage />
"maintenanceModeEnabled", </FormItem>
val )}
);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/> />
</SettingsFormCell> </SettingsFormCell>
@@ -329,11 +322,6 @@ export default function ResourceMaintenancePage() {
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder="We'll be back soon!" placeholder="We'll be back soon!"
/> />
</FormControl> </FormControl>
@@ -365,11 +353,6 @@ export default function ResourceMaintenancePage() {
<Textarea <Textarea
{...field} {...field}
rows={4} rows={4}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t( placeholder={t(
"maintenancePageMessagePlaceholder" "maintenancePageMessagePlaceholder"
)} )}
@@ -402,11 +385,6 @@ export default function ResourceMaintenancePage() {
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
disabled={
!isPaidUser(
tierMatrix.maintencePage
)
}
placeholder={t( placeholder={t(
"maintenanceTime" "maintenanceTime"
)} )}
@@ -430,20 +408,19 @@ export default function ResourceMaintenancePage() {
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={maintenanceSaveLoading} loading={maintenanceSaveLoading}
disabled={ disabled={maintenanceSaveLoading}
maintenanceSaveLoading || form="maintenance-settings-form"
!isPaidUser(tierMatrix.maintencePage) >
} {t("saveSettings")}
form="maintenance-settings-form" </Button>
> </SettingsSectionFooter>
{t("saveSettings")} </SettingsSection>
</Button> </SettingsContainer>
</SettingsSectionFooter> </div>
</SettingsSection> </>
</SettingsContainer>
); );
} }
@@ -80,7 +80,6 @@ import { toASCII } from "punycode";
import { import {
useMemo, useMemo,
useState, useState,
useTransition,
useEffect useEffect
} from "react"; } from "react";
import { useForm, type Resolver } from "react-hook-form"; import { useForm, type Resolver } from "react-hook-form";
@@ -229,7 +228,7 @@ export default function Page() {
>([]); >([]);
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
const [createLoading, startTransition] = useTransition(); const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>(""); const [niceId, setNiceId] = useState<string>("");
@@ -461,6 +460,7 @@ export default function Page() {
}; };
async function onSubmit() { async function onSubmit() {
setCreateLoading(true);
const baseData = baseForm.getValues(); const baseData = baseForm.getValues();
try { try {
@@ -707,6 +707,8 @@ export default function Page() {
t("resourceErrorCreateMessageDescription") t("resourceErrorCreateMessageDescription")
) )
}); });
} finally {
setCreateLoading(false);
} }
} }
@@ -762,7 +764,7 @@ export default function Page() {
ssh: "SSH", ssh: "SSH",
rdp: "RDP", rdp: "RDP",
vnc: "VNC", vnc: "VNC",
} };
} }
const typeOptions: OptionSelectOption<NewResourceType>[] = const typeOptions: OptionSelectOption<NewResourceType>[] =
@@ -1427,7 +1429,7 @@ export default function Page() {
} }
}} }}
loading={createLoading} loading={createLoading}
disabled={!areAllTargetsValid() || browserGatewayDisabled} disabled={!areAllTargetsValid() || browserGatewayDisabled || createLoading}
> >
{t("resourceCreate")} {t("resourceCreate")}
</Button> </Button>
@@ -253,85 +253,87 @@ export default function GeneralPage() {
<PaidFeaturesAlert <PaidFeaturesAlert
tiers={tierMatrix.newtAutoUpdate} tiers={tierMatrix.newtAutoUpdate}
/> />
{site && site.type === "newt" && ( {site &&
<FormField site.type === "newt" &&
control={form.control} !env.flags.disableEnterpriseFeatures && (
name="autoUpdateEnabled" <FormField
render={({ field }) => { control={form.control}
const isOverriding = form.watch( name="autoUpdateEnabled"
"autoUpdateOverrideOrg" render={({ field }) => {
); const isOverriding = form.watch(
return ( "autoUpdateOverrideOrg"
<FormItem> );
<FormControl> return (
<div className=""> <FormItem>
<SwitchInput <FormControl>
id="auto-update-enabled" <div className="">
label={t( <SwitchInput
"siteAutoUpdateLabel" id="auto-update-enabled"
)} label={t(
checked={ "siteAutoUpdateLabel"
field.value )}
} checked={
onCheckedChange={( field.value
checked }
) => { onCheckedChange={(
field.onChange(
checked checked
); ) => {
form.setValue( field.onChange(
"autoUpdateOverrideOrg", checked
true );
);
}}
disabled={
!hasAutoUpdateFeature
}
/>
{isOverriding && (
<ButtonUI
type="button"
variant="link"
size="sm"
className="text-sm text-muted-foreground px-0"
onClick={() => {
form.setValue( form.setValue(
"autoUpdateOverrideOrg", "autoUpdateOverrideOrg",
false true
);
form.setValue(
"autoUpdateEnabled",
orgAutoUpdate
); );
}} }}
> disabled={
{t( !hasAutoUpdateFeature
"siteAutoUpdateResetToOrg" }
)} />
</ButtonUI> {isOverriding && (
)} <ButtonUI
</div> type="button"
</FormControl> variant="link"
<FormDescription> size="sm"
{t( className="text-sm text-muted-foreground px-0"
"siteAutoUpdateDescription" onClick={() => {
)}{" "} form.setValue(
<a "autoUpdateOverrideOrg",
href="https://docs.pangolin.net/manage/sites/auto-update" false
target="_blank" );
rel="noopener noreferrer" form.setValue(
className="text-primary hover:underline inline-flex items-center gap-1" "autoUpdateEnabled",
> orgAutoUpdate
{t("learnMore")} );
<ExternalLink className="size-3.5 shrink-0" /> }}
</a> >
</FormDescription> {t(
<FormMessage /> "siteAutoUpdateResetToOrg"
</FormItem> )}
); </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>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
+2 -11
View File
@@ -23,7 +23,7 @@ import {
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod"; import { z } from "zod";
import { createElement, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
@@ -37,15 +37,6 @@ import {
InfoSections, InfoSections,
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } 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 { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig"; import { generateKeypair } from "../[niceId]/wireguardConfig";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -570,7 +561,7 @@ export default function Page() {
</Button> </Button>
</SettingsFormCell> </SettingsFormCell>
{showAdvancedSettings && ( {showAdvancedSettings && (
<SettingsFormCell span="quarter"> <SettingsFormCell span="half">
<FormField <FormField
control={ control={
form.control form.control
+6 -5
View File
@@ -156,10 +156,11 @@ export const orgNavSections = (
] ]
: []), : []),
// PaidFeaturesAlert // PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || ...(!env?.flags.disableEnterpriseFeatures &&
build === "saas" || (build === "saas" ||
env?.app.identityProviderMode === "org" || env?.app.identityProviderMode === "org" ||
(env?.app.identityProviderMode === undefined && build !== "oss") (env?.app.identityProviderMode === undefined &&
build !== "oss"))
? [ ? [
{ {
title: "sidebarIdentityProviders", title: "sidebarIdentityProviders",
@@ -259,7 +260,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys", href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" /> icon: <KeyRound className="size-4 flex-none" />
}, },
...(build !== "oss" ...(!env?.flags.disableEnterpriseFeatures
? [ ? [
{ {
title: "labels", title: "labels",
+15 -15
View File
@@ -124,21 +124,21 @@ export default function VncClient({
authToken: target.authToken authToken: target.authToken
}); });
try { // try {
const checkParams = new URLSearchParams(params); // const checkParams = new URLSearchParams(params);
checkParams.set("checkOnly", "1"); // checkParams.set("checkOnly", "1");
const response = await fetch(`${base}?${checkParams.toString()}`); // const response = await fetch(`${base}?${checkParams.toString()}`);
if (!response.ok) { // if (!response.ok) {
const detail = (await response.text()).trim(); // const detail = (await response.text()).trim();
setConnectError(detail || t("sshErrorConnectionClosed")); // setConnectError(detail || t("sshErrorConnectionClosed"));
setConnecting(false); // setConnecting(false);
return; // return;
} // }
} catch { // } catch {
setConnectError(t("sshErrorWebSocket")); // setConnectError(t("sshErrorWebSocket"));
setConnecting(false); // setConnecting(false);
return; // return;
} // }
let RFB: new ( let RFB: new (
target: HTMLElement, target: HTMLElement,
+9 -1
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -18,6 +20,7 @@ import {
CredenzaTitle CredenzaTitle
} from "./Credenza"; } from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm"; import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = { export type CreateOrgLabelDialogProps = {
@@ -35,6 +38,8 @@ export function CreateOrgLabelDialog({
}: CreateOrgLabelDialogProps) { }: CreateOrgLabelDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const canManageLabels = isPaidUser(tierMatrix.labels);
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) { async function createOrgLabel(data: { name: string; color: string }) {
@@ -79,8 +84,11 @@ export function CreateOrgLabelDialog({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm <OrgLabelForm
disabled={!canManageLabels}
onSubmit={(data) => { onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => createOrgLabel(data)); startTransition(async () => createOrgLabel(data));
}} }}
/> />
@@ -98,7 +106,7 @@ export function CreateOrgLabelDialog({
<Button <Button
type="submit" type="submit"
form="org-label-form" form="org-label-form"
disabled={isSubmitting} disabled={isSubmitting || !canManageLabels}
loading={isSubmitting} loading={isSubmitting}
> >
{t("labelCreate")} {t("labelCreate")}
+9 -1
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -18,6 +20,7 @@ import {
CredenzaTitle CredenzaTitle
} from "./Credenza"; } from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm"; import { OrgLabelForm } from "./OrgLabelForm";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export type EditOrgLabelDialogProps = { export type EditOrgLabelDialogProps = {
@@ -41,6 +44,8 @@ export function EditOrgLabelDialog({
}: EditOrgLabelDialogProps) { }: EditOrgLabelDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const canManageLabels = isPaidUser(tierMatrix.labels);
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
async function editOrgLabel(data: { name: string; color: string }) { async function editOrgLabel(data: { name: string; color: string }) {
@@ -85,9 +90,12 @@ export function EditOrgLabelDialog({
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelForm <OrgLabelForm
disabled={!canManageLabels}
defaultValue={label} defaultValue={label}
onSubmit={(data) => { onSubmit={(data) => {
if (!canManageLabels) return;
startTransition(async () => editOrgLabel(data)); startTransition(async () => editOrgLabel(data));
}} }}
/> />
@@ -105,7 +113,7 @@ export function EditOrgLabelDialog({
<Button <Button
type="submit" type="submit"
form="org-label-form" form="org-label-form"
disabled={isSubmitting} disabled={isSubmitting || !canManageLabels}
loading={isSubmitting} loading={isSubmitting}
> >
{t("labelEdit")} {t("labelEdit")}
+11 -5
View File
@@ -35,9 +35,14 @@ export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = { export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void; onSubmit: (data: LabelFormData) => void;
defaultValue?: LabelFormData; defaultValue?: LabelFormData;
disabled?: boolean;
}; };
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) { export function OrgLabelForm({
onSubmit,
defaultValue,
disabled = false
}: OrgLabelFormProps) {
const t = useTranslations(); const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS); const colorValues = Object.values(LABEL_COLORS);
@@ -70,9 +75,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
<FormItem> <FormItem>
<FormLabel>{t("labelNameField")}</FormLabel> <FormLabel>{t("labelNameField")}</FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} disabled={disabled} />
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -88,6 +91,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
disabled={disabled}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue <SelectValue
@@ -110,7 +114,9 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
}} }}
/> />
<span data-name> <span data-name>
{color.charAt(0).toUpperCase() + {color
.charAt(0)
.toUpperCase() +
color.slice(1)} color.slice(1)}
</span> </span>
</SelectItem> </SelectItem>
+36 -37
View File
@@ -12,14 +12,7 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table"; import { type PaginationState } from "@tanstack/react-table";
import { import { ArrowRight, MoreHorizontal } from "lucide-react";
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal,
PencilIcon,
PencilLineIcon
} from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useActionState, useMemo, useState, useTransition } from "react"; import { useActionState, useMemo, useState, useTransition } from "react";
@@ -109,7 +102,7 @@ export default function OrgLabelsTable({
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-1.5 group"> <div className="flex items-center gap-1.5 group">
<div <div
className="size-2.5 rounded-full bg-(--color) flex-none" className="size-2 rounded-full bg-(--color) flex-none"
style={{ style={{
// @ts-expect-error css color // @ts-expect-error css color
"--color": row.original.color "--color": row.original.color
@@ -125,34 +118,40 @@ export default function OrgLabelsTable({
enableHiding: false, enableHiding: false,
header: () => <span className="p-3"></span>, header: () => <span className="p-3"></span>,
cell: ({ row }) => ( cell: ({ row }) => (
<DropdownMenu> <div className="flex items-center gap-2 justify-end">
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button variant="ghost" className="h-8 w-8 p-0"> <DropdownMenuTrigger asChild>
<span className="sr-only">{t("openMenu")}</span> <Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" /> <span className="sr-only">
</Button> {t("openMenu")}
</DropdownMenuTrigger> </span>
<DropdownMenuContent align="end"> <MoreHorizontal className="h-4 w-4" />
<DropdownMenuItem </Button>
onClick={() => { </DropdownMenuTrigger>
setSelectedLabel(row.original); <DropdownMenuContent align="end">
setIsEditModalOpen(true); <DropdownMenuItem
}} onClick={() => {
> setSelectedLabel(row.original);
{t("edit")} setIsDeleteModalOpen(true);
</DropdownMenuItem> }}
<DropdownMenuItem >
onClick={() => { <span className="text-red-500">
setSelectedLabel(row.original); {t("delete")}
setIsDeleteModalOpen(true); </span>
}} </DropdownMenuItem>
> </DropdownMenuContent>
<span className="text-red-500"> </DropdownMenu>
{t("delete")} <Button
</span> variant="outline"
</DropdownMenuItem> onClick={() => {
</DropdownMenuContent> setSelectedLabel(row.original);
</DropdownMenu> setIsEditModalOpen(true);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
) )
} }
], ],
+1 -1
View File
@@ -767,7 +767,7 @@ function TargetStatusCell({
if (!targets || targets.length === 0) { if (!targets || targets.length === 0) {
return ( return (
<div className="flex items-center gap-2 px-2"> <div className="flex items-center gap-2 px-0">
<StatusIcon status="unknown" /> <StatusIcon status="unknown" />
<span className="text-sm">{t("resourcesTableNoTargets")}</span> <span className="text-sm">{t("resourcesTableNoTargets")}</span>
</div> </div>
+34 -24
View File
@@ -20,6 +20,7 @@ import {
} from "react-icons/fa"; } from "react-icons/fa";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si"; import { SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem = string | { title: string; command: string }; export type CommandItem = string | { title: string; command: string };
@@ -50,9 +51,12 @@ export function NewtSiteInstallCommands({
version = "latest" version = "latest"
}: NewtSiteInstallCommandsProps) { }: NewtSiteInstallCommandsProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
const [acceptClients, setAcceptClients] = useState(true); const [acceptClients, setAcceptClients] = useState(true);
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true); const [allowPangolinSsh, setAllowPangolinSsh] = useState(
!env.flags.disableEnterpriseFeatures
);
const [platform, setPlatform] = useState<Platform>("linux"); const [platform, setPlatform] = useState<Platform>("linux");
const [architecture, setArchitecture] = useState( const [architecture, setArchitecture] = useState(
() => getArchitectures(platform)[0] () => getArchitectures(platform)[0]
@@ -71,7 +75,11 @@ export function NewtSiteInstallCommands({
: ""; : "";
const disableSshFlag = const disableSshFlag =
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : ""; supportsSshOption &&
!allowPangolinSsh &&
!env.flags.disableEnterpriseFeatures
? " --disable-ssh"
: "";
const runAsRootPrefix = const runAsRootPrefix =
supportsSshOption && allowPangolinSsh ? "sudo " : ""; supportsSshOption && allowPangolinSsh ? "sudo " : "";
@@ -306,27 +314,29 @@ WantedBy=default.target`
> >
{t("siteAcceptClientConnectionsDescription")} {t("siteAcceptClientConnectionsDescription")}
</p> </p>
{supportsSshOption && ( {supportsSshOption &&
<> !env.flags.disableEnterpriseFeatures && (
<div className="flex items-center space-x-2 mb-2 mt-2"> <>
<CheckboxWithLabel <div className="flex items-center space-x-2 mb-2 mt-2">
id="allowPangolinSsh" <CheckboxWithLabel
checked={allowPangolinSsh} id="allowPangolinSsh"
onCheckedChange={(checked) => { checked={allowPangolinSsh}
const value = checked as boolean; onCheckedChange={(checked) => {
setAllowPangolinSsh(value); const value =
}} checked as boolean;
label="Allow Pangolin SSH" setAllowPangolinSsh(value);
/> }}
</div> label="Allow Pangolin SSH"
<p />
id="allowPangolinSsh-desc" </div>
className="text-sm text-muted-foreground" <p
> id="allowPangolinSsh-desc"
{t("sitePangolinSshDescription")} className="text-sm text-muted-foreground"
</p> >
</> {t("sitePangolinSshDescription")}
)} </p>
</>
)}
</div> </div>
)} )}
@@ -354,7 +364,7 @@ WantedBy=default.target`
{t.rich("siteInstallAdvantechDocsDescription", { {t.rich("siteInstallAdvantechDocsDescription", {
docsLink: (chunks) => ( docsLink: (chunks) => (
<a <a
href="https://docs.pangolin.net/manage/sites/install-advantech" href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
@@ -8,13 +8,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { build } from "@server/build"; 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 { useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
import { PolicyAuthStackSection } from "./PolicyAuthStackSection"; import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection"; import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
export type EditPolicyFormSection = "general" | "authentication" | "rules"; export type EditPolicyFormSection = "general" | "authentication" | "rules";
@@ -71,13 +72,19 @@ export function EditPolicyForm({
return <></>; 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 = ( const authSection = (
<PolicyAuthStackSection <PolicyAuthStackSection
mode="edit" mode="edit"
orgId={org.org.orgId} orgId={org.org.orgId}
allIdps={allIdps} allIdps={allIdps}
emailEnabled={env.email.emailEnabled} emailEnabled={env.email.emailEnabled}
readonly={readonly} readonly={effectiveReadonly}
resourceId={resourceId} resourceId={resourceId}
/> />
); );
@@ -87,32 +94,82 @@ export function EditPolicyForm({
mode="edit" mode="edit"
isMaxmindAvailable={isMaxmindAvailable} isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindASNAvailable} isMaxmindAsnAvailable={isMaxmindASNAvailable}
readonly={readonly} readonly={effectiveReadonly}
resourceId={resourceId} resourceId={resourceId}
/> />
); );
if (section === "general") { 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") { if (section === "authentication") {
return authSection; return (
<>
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
{authSection}
</div>
</>
);
} }
if (section === "rules") { if (section === "rules") {
return rulesSection; return (
<>
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<div
className={
isDisabled
? "pointer-events-none opacity-50"
: undefined
}
>
{rulesSection}
</div>
</>
);
} }
return ( return (
<SettingsContainer> <>
{!hidePolicyNameForm && !isOverlay && ( {showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
<EditPolicyNameSectionForm readonly={readonly} /> <div
)} className={
isDisabled ? "pointer-events-none opacity-50" : undefined
}
>
<SettingsContainer>
{!hidePolicyNameForm && !isOverlay && (
<EditPolicyNameSectionForm
readonly={effectiveReadonly}
/>
)}
{authSection} {authSection}
{rulesSection} {rulesSection}
</SettingsContainer> </SettingsContainer>
</div>
</>
); );
} }