mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-13 05:16:37 +00:00
Placeholder screen and certs are working
This commit is contained in:
@@ -19,7 +19,8 @@ export enum TierFeature {
|
||||
SshPam = "sshPam",
|
||||
FullRbac = "fullRbac",
|
||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||
SIEM = "siem" // handle downgrade by disabling SIEM integrations
|
||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||
HTTPPrivateResources = "httpPrivateResources" // handle downgrade by disabling HTTP private resources
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||
[TierFeature.SIEM]: ["enterprise"]
|
||||
[TierFeature.SIEM]: ["enterprise"],
|
||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export function encryptData(data: string, key: Buffer): string {
|
||||
const algorithm = "aes-256-gcm";
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
|
||||
let encrypted = cipher.update(data, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine IV, auth tag, and encrypted data
|
||||
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
|
||||
}
|
||||
|
||||
// Helper function to decrypt data (you'll need this to read certificates)
|
||||
export function decryptData(encryptedData: string, key: Buffer): string {
|
||||
const algorithm = "aes-256-gcm";
|
||||
const parts = encryptedData.split(":");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid encrypted data format");
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], "hex");
|
||||
const authTag = Buffer.from(parts[1], "hex");
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// openssl rand -hex 32 > config/encryption.key
|
||||
@@ -5,7 +5,7 @@ import config from "@server/lib/config";
|
||||
import z from "zod";
|
||||
import logger from "@server/logger";
|
||||
import semver from "semver";
|
||||
import { getValidCertificatesForDomains } from "#private/lib/certificates";
|
||||
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
|
||||
|
||||
interface IPRange {
|
||||
start: bigint;
|
||||
|
||||
@@ -15,9 +15,10 @@ import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import { certificates, domains, db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encryptData, decryptData } from "@server/lib/encryption";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import config from "#private/lib/config";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
interface AcmeCert {
|
||||
domain: { main: string; sans?: string[] };
|
||||
@@ -32,14 +33,6 @@ interface AcmeJson {
|
||||
};
|
||||
}
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const keyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||
if (!keyHex) {
|
||||
throw new Error("acmeCertSync: encryption key is not configured");
|
||||
}
|
||||
return Buffer.from(keyHex, "hex");
|
||||
}
|
||||
|
||||
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||
const lookupDomain = certDomain.startsWith("*.")
|
||||
@@ -99,9 +92,7 @@ async function syncAcmeCerts(
|
||||
try {
|
||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
|
||||
);
|
||||
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,15 +112,13 @@ async function syncAcmeCerts(
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptionKey = getEncryptionKey();
|
||||
|
||||
|
||||
for (const cert of resolverData.Certificates) {
|
||||
const domain = cert.domain?.main;
|
||||
|
||||
if (!domain) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert with missing domain`
|
||||
);
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -161,9 +150,9 @@ async function syncAcmeCerts(
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decryptData(
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
encryptionKey
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
if (storedCertPem === certPem) {
|
||||
logger.debug(
|
||||
@@ -185,9 +174,7 @@ async function syncAcmeCerts(
|
||||
if (firstCertPem) {
|
||||
try {
|
||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||
expiresAt = Math.floor(
|
||||
new Date(x509.validTo).getTime() / 1000
|
||||
);
|
||||
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
@@ -196,8 +183,8 @@ async function syncAcmeCerts(
|
||||
}
|
||||
|
||||
const wildcard = domain.startsWith("*.");
|
||||
const encryptedCert = encryptData(certPem, encryptionKey);
|
||||
const encryptedKey = encryptData(keyPem, encryptionKey);
|
||||
const encryptedCert = encrypt(certPem, config.getRawConfig().server.secret!);
|
||||
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
@@ -249,16 +236,16 @@ async function syncAcmeCerts(
|
||||
}
|
||||
|
||||
export function initAcmeCertSync(): void {
|
||||
const privateConfig = config.getRawPrivateConfig();
|
||||
const privateConfigData = privateConfig.getRawPrivateConfig();
|
||||
|
||||
if (!privateConfig.flags?.enable_acme_cert_sync) {
|
||||
if (!privateConfigData.flags?.enable_acme_cert_sync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const acmeJsonPath =
|
||||
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||
const resolver = privateConfig.acme?.resolver ?? "letsencrypt";
|
||||
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000;
|
||||
privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||
@@ -274,4 +261,4 @@ export function initAcmeCertSync(): void {
|
||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,23 +11,15 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import config from "./config";
|
||||
import privateConfig from "./config";
|
||||
import config from "@server/lib/config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import cache from "#private/lib/cache";
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
if (encryptionKey) {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
// Define the return type for clarity and type safety
|
||||
export type CertificateResult = {
|
||||
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
|
||||
domains: Set<string>,
|
||||
useCache: boolean = true
|
||||
): Promise<Array<CertificateResult>> {
|
||||
loadEncryptData(); // Ensure encryption key is loaded
|
||||
|
||||
|
||||
const finalResults: CertificateResult[] = [];
|
||||
const domainsToQuery = new Set<string>();
|
||||
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
|
||||
|
||||
// 2. If all domains were resolved from the cache, return early
|
||||
if (domainsToQuery.size === 0) {
|
||||
const decryptedResults = decryptFinalResults(finalResults);
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(finalResults);
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
function decryptFinalResults(
|
||||
finalResults: CertificateResult[]
|
||||
finalResults: CertificateResult[],
|
||||
secret: string
|
||||
): CertificateResult[] {
|
||||
const validCertsDecrypted = finalResults.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decryptData(
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
encryptionKey
|
||||
secret
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
||||
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
|
||||
@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
|
||||
}),
|
||||
server: z
|
||||
.object({
|
||||
encryption_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||
reo_client_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -96,7 +92,7 @@ export const privateConfigSchema = z.object({
|
||||
enable_redis: z.boolean().optional().default(false),
|
||||
use_pangolin_dns: z.boolean().optional().default(false),
|
||||
use_org_only_idp: z.boolean().optional(),
|
||||
enable_acme_cert_sync: z.boolean().optional().default(false)
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
|
||||
@@ -268,10 +268,10 @@ export async function getTraefikConfig(
|
||||
});
|
||||
|
||||
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
||||
const siteResourcesWithAliases = await db
|
||||
const siteResourcesWithFullDomain = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
alias: siteResources.alias,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
mode: siteResources.mode
|
||||
})
|
||||
.from(siteResources)
|
||||
@@ -279,7 +279,7 @@ export async function getTraefikConfig(
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.enabled, true),
|
||||
isNotNull(siteResources.alias),
|
||||
isNotNull(siteResources.fullDomain),
|
||||
eq(siteResources.mode, "http"),
|
||||
eq(siteResources.ssl, true),
|
||||
or(
|
||||
@@ -305,9 +305,9 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
// Include siteResource aliases so pangolin-dns also fetches certs for them
|
||||
for (const sr of siteResourcesWithAliases) {
|
||||
if (sr.alias) {
|
||||
domains.add(sr.alias);
|
||||
for (const sr of siteResourcesWithFullDomain) {
|
||||
if (sr.fullDomain) {
|
||||
domains.add(sr.fullDomain);
|
||||
}
|
||||
}
|
||||
// get the valid certs for these domains
|
||||
@@ -904,7 +904,7 @@ export async function getTraefikConfig(
|
||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||
// Traefik generates TLS certificates for those domains even when no
|
||||
// matching resource exists yet.
|
||||
if (siteResourcesWithAliases.length > 0) {
|
||||
if (siteResourcesWithFullDomain.length > 0) {
|
||||
// Build a set of domains already covered by normal resources
|
||||
const existingFullDomains = new Set<string>();
|
||||
for (const resource of resourcesMap.values()) {
|
||||
@@ -913,13 +913,13 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
for (const sr of siteResourcesWithAliases) {
|
||||
if (!sr.alias) continue;
|
||||
for (const sr of siteResourcesWithFullDomain) {
|
||||
if (!sr.fullDomain) continue;
|
||||
|
||||
// Skip if this alias is already handled by a resource router
|
||||
if (existingFullDomains.has(sr.alias)) continue;
|
||||
if (existingFullDomains.has(sr.fullDomain)) continue;
|
||||
|
||||
const alias = sr.alias;
|
||||
const fullDomain = sr.fullDomain;
|
||||
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||
const siteResourceServiceName = `${srKey}-service`;
|
||||
const siteResourceRouterName = `${srKey}-router`;
|
||||
@@ -970,7 +970,7 @@ export async function getTraefikConfig(
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${alias}\`)`,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100
|
||||
};
|
||||
|
||||
@@ -979,7 +979,7 @@ export async function getTraefikConfig(
|
||||
if (
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
const domainParts = alias.split(".");
|
||||
const domainParts = fullDomain.split(".");
|
||||
const wildCard =
|
||||
domainParts.length <= 2
|
||||
? `*.${domainParts.join(".")}`
|
||||
@@ -999,11 +999,11 @@ export async function getTraefikConfig(
|
||||
} else {
|
||||
// pangolin-dns: only add route if we already have a valid cert
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === alias
|
||||
(cert) => cert.queriedDomain === fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for siteResource alias: ${alias}`
|
||||
`No matching certificate found for siteResource alias: ${fullDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -1016,10 +1016,21 @@ export async function getTraefikConfig(
|
||||
],
|
||||
service: siteResourceServiceName,
|
||||
middlewares: [siteResourceRewriteMiddlewareName],
|
||||
rule: `Host(\`${alias}\`)`,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100,
|
||||
tls
|
||||
};
|
||||
|
||||
// Assets bypass router — lets Next.js static files load without rewrite
|
||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
priority: 101,
|
||||
tls
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,14 +24,8 @@ import {
|
||||
User,
|
||||
certificates,
|
||||
exitNodeOrgs,
|
||||
RemoteExitNode,
|
||||
olms,
|
||||
newts,
|
||||
clients,
|
||||
sites,
|
||||
domains,
|
||||
orgDomains,
|
||||
targets,
|
||||
loginPage,
|
||||
loginPageOrg,
|
||||
LoginPage,
|
||||
@@ -70,12 +64,9 @@ import {
|
||||
updateAndGenerateEndpointDestinations,
|
||||
updateSiteBandwidth
|
||||
} from "@server/routers/gerbil";
|
||||
import * as gerbil from "@server/routers/gerbil";
|
||||
import logger from "@server/logger";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import * as fs from "fs";
|
||||
import { exchangeSession } from "@server/routers/badger";
|
||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||
@@ -298,25 +289,11 @@ hybridRouter.get(
|
||||
}
|
||||
);
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
if (encryptionKey) {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyHex =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
// Get valid certificates for given domains (supports wildcard certs)
|
||||
hybridRouter.get(
|
||||
"/certificates/domains",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
loadEncryptData(); // Ensure encryption key is loaded
|
||||
|
||||
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
||||
req.query
|
||||
);
|
||||
@@ -447,13 +424,13 @@ hybridRouter.get(
|
||||
|
||||
const result = filtered.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decryptData(
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
encryptionKey
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
@@ -833,9 +810,12 @@ hybridRouter.get(
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
|
||||
logger.debug(
|
||||
`User ${userId} has roles in org ${orgId}:`,
|
||||
userOrgRoleRows
|
||||
);
|
||||
|
||||
return response<{ roleId: number, roleName: string }[]>(res, {
|
||||
return response<{ roleId: number; roleName: string }[]>(res, {
|
||||
data: userOrgRoleRows,
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
@@ -201,6 +201,21 @@ export async function createSiteResource(
|
||||
subdomain
|
||||
} = parsedBody.data;
|
||||
|
||||
if (mode == "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
.select()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import {
|
||||
clientSiteResources,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
@@ -13,7 +12,8 @@ import {
|
||||
Transaction,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
@@ -237,6 +237,21 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (mode == "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
existingSiteResource.orgId,
|
||||
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isLicensedSshPam = await isLicensedOrSubscribed(
|
||||
existingSiteResource.orgId,
|
||||
tierMatrix.sshPam
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function CreateInternalResourceDialog({
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
setIsSubmitting(true);
|
||||
@@ -159,6 +160,7 @@ export default function CreateInternalResourceDialog({
|
||||
orgId={orgId}
|
||||
formId="create-internal-resource-form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
@@ -174,7 +176,7 @@ export default function CreateInternalResourceDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function EditInternalResourceDialog({
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
try {
|
||||
@@ -177,6 +178,7 @@ export default function EditInternalResourceDialog({
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => handleSubmit(values))
|
||||
}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
@@ -192,7 +194,7 @@ export default function EditInternalResourceDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
|
||||
@@ -185,6 +185,7 @@ type InternalResourceFormProps = {
|
||||
siteResourceId?: number;
|
||||
formId: string;
|
||||
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
||||
onSubmitDisabledChange?: (disabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function InternalResourceForm({
|
||||
@@ -195,13 +196,15 @@ export function InternalResourceForm({
|
||||
orgId,
|
||||
siteResourceId,
|
||||
formId,
|
||||
onSubmit
|
||||
onSubmit,
|
||||
onSubmitDisabledChange
|
||||
}: InternalResourceFormProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
|
||||
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
|
||||
|
||||
const nameRequiredKey =
|
||||
variant === "create"
|
||||
@@ -647,6 +650,10 @@ export function InternalResourceForm({
|
||||
form
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
|
||||
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -853,6 +860,7 @@ export function InternalResourceForm({
|
||||
field.value ??
|
||||
"http"
|
||||
}
|
||||
disabled={httpSectionDisabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -893,6 +901,7 @@ export function InternalResourceForm({
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
disabled={isHttpMode && httpSectionDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -948,6 +957,7 @@ export function InternalResourceForm({
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
disabled={httpSectionDisabled}
|
||||
onChange={(e) => {
|
||||
const raw =
|
||||
e.target
|
||||
@@ -981,6 +991,10 @@ export function InternalResourceForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHttpMode && (
|
||||
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
|
||||
)}
|
||||
|
||||
{isHttpMode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="my-8">
|
||||
@@ -991,6 +1005,7 @@ export function InternalResourceForm({
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
|
||||
<DomainPicker
|
||||
key={
|
||||
variant === "edit" && siteResourceId
|
||||
@@ -1039,6 +1054,7 @@ export function InternalResourceForm({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssl"
|
||||
@@ -1055,6 +1071,7 @@ export function InternalResourceForm({
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
disabled={httpSectionDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
Reference in New Issue
Block a user