Placeholder screen and certs are working

This commit is contained in:
Owen
2026-04-12 16:49:49 -07:00
parent 789b991c56
commit 89b6b1fb56
13 changed files with 127 additions and 146 deletions

View File

@@ -19,7 +19,8 @@ export enum TierFeature {
SshPam = "sshPam", SshPam = "sshPam",
FullRbac = "fullRbac", FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed 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[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"] [TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"]
}; };

View File

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

View File

@@ -5,7 +5,7 @@ import config from "@server/lib/config";
import z from "zod"; import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { getValidCertificatesForDomains } from "#private/lib/certificates"; import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
interface IPRange { interface IPRange {
start: bigint; start: bigint;

View File

@@ -15,9 +15,10 @@ import fs from "fs";
import crypto from "crypto"; import crypto from "crypto";
import { certificates, domains, db } from "@server/db"; import { certificates, domains, db } from "@server/db";
import { and, eq } from "drizzle-orm"; 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 logger from "@server/logger";
import config from "#private/lib/config"; import privateConfig from "#private/lib/config";
import config from "@server/lib/config";
interface AcmeCert { interface AcmeCert {
domain: { main: string; sans?: string[] }; 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> { async function findDomainId(certDomain: string): Promise<string | null> {
// Strip wildcard prefix before lookup (*.example.com -> example.com) // Strip wildcard prefix before lookup (*.example.com -> example.com)
const lookupDomain = certDomain.startsWith("*.") const lookupDomain = certDomain.startsWith("*.")
@@ -99,9 +92,7 @@ async function syncAcmeCerts(
try { try {
raw = fs.readFileSync(acmeJsonPath, "utf8"); raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) { } catch (err) {
logger.debug( logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
);
return; return;
} }
@@ -121,15 +112,13 @@ async function syncAcmeCerts(
return; return;
} }
const encryptionKey = getEncryptionKey();
for (const cert of resolverData.Certificates) { for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main; const domain = cert.domain?.main;
if (!domain) { if (!domain) {
logger.debug( logger.debug(`acmeCertSync: skipping cert with missing domain`);
`acmeCertSync: skipping cert with missing domain`
);
continue; continue;
} }
@@ -161,9 +150,9 @@ async function syncAcmeCerts(
if (existing.length > 0 && existing[0].certFile) { if (existing.length > 0 && existing[0].certFile) {
try { try {
const storedCertPem = decryptData( const storedCertPem = decrypt(
existing[0].certFile, existing[0].certFile,
encryptionKey config.getRawConfig().server.secret!
); );
if (storedCertPem === certPem) { if (storedCertPem === certPem) {
logger.debug( logger.debug(
@@ -185,9 +174,7 @@ async function syncAcmeCerts(
if (firstCertPem) { if (firstCertPem) {
try { try {
const x509 = new crypto.X509Certificate(firstCertPem); const x509 = new crypto.X509Certificate(firstCertPem);
expiresAt = Math.floor( expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
new Date(x509.validTo).getTime() / 1000
);
} catch (err) { } catch (err) {
logger.debug( logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}` `acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
@@ -196,8 +183,8 @@ async function syncAcmeCerts(
} }
const wildcard = domain.startsWith("*."); const wildcard = domain.startsWith("*.");
const encryptedCert = encryptData(certPem, encryptionKey); const encryptedCert = encrypt(certPem, config.getRawConfig().server.secret!);
const encryptedKey = encryptData(keyPem, encryptionKey); const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain); const domainId = await findDomainId(domain);
@@ -249,16 +236,16 @@ async function syncAcmeCerts(
} }
export function initAcmeCertSync(): void { 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; return;
} }
const acmeJsonPath = const acmeJsonPath =
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
const resolver = privateConfig.acme?.resolver ?? "letsencrypt"; const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
logger.info( logger.info(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`

View File

@@ -11,23 +11,15 @@
* This file is not licensed under the AGPLv3. * 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 { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; 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 logger from "@server/logger";
import cache from "#private/lib/cache"; 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 // Define the return type for clarity and type safety
export type CertificateResult = { export type CertificateResult = {
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise<Array<CertificateResult>> { ): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = []; const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>(); const domainsToQuery = new Set<string>();
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
// 2. If all domains were resolved from the cache, return early // 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) { if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults); const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
return decryptedResults; return decryptedResults;
} }
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
} }
} }
const decryptedResults = decryptFinalResults(finalResults); const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
return decryptedResults; return decryptedResults;
} }
function decryptFinalResults( function decryptFinalResults(
finalResults: CertificateResult[] finalResults: CertificateResult[],
secret: string
): CertificateResult[] { ): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => { const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decryptData( const decryptedCert = decrypt(
cert.certFile!, // is not null from query cert.certFile!, // is not null from query
encryptionKey secret
); );
// Decrypt and save key file // 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 only the certificate data without org information
return { return {

View File

@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
}), }),
server: z server: z
.object({ .object({
encryption_key: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
reo_client_id: z reo_client_id: z
.string() .string()
.optional() .optional()
@@ -96,7 +92,7 @@ export const privateConfigSchema = z.object({
enable_redis: z.boolean().optional().default(false), enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(), 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() .optional()
.prefault({}), .prefault({}),

View File

@@ -268,10 +268,10 @@ export async function getTraefikConfig(
}); });
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge // Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
const siteResourcesWithAliases = await db const siteResourcesWithFullDomain = await db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
alias: siteResources.alias, fullDomain: siteResources.fullDomain,
mode: siteResources.mode mode: siteResources.mode
}) })
.from(siteResources) .from(siteResources)
@@ -279,7 +279,7 @@ export async function getTraefikConfig(
.where( .where(
and( and(
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
isNotNull(siteResources.alias), isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"), eq(siteResources.mode, "http"),
eq(siteResources.ssl, true), eq(siteResources.ssl, true),
or( or(
@@ -305,9 +305,9 @@ export async function getTraefikConfig(
} }
} }
// Include siteResource aliases so pangolin-dns also fetches certs for them // Include siteResource aliases so pangolin-dns also fetches certs for them
for (const sr of siteResourcesWithAliases) { for (const sr of siteResourcesWithFullDomain) {
if (sr.alias) { if (sr.fullDomain) {
domains.add(sr.alias); domains.add(sr.fullDomain);
} }
} }
// get the valid certs for these domains // 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 // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no // Traefik generates TLS certificates for those domains even when no
// matching resource exists yet. // matching resource exists yet.
if (siteResourcesWithAliases.length > 0) { if (siteResourcesWithFullDomain.length > 0) {
// Build a set of domains already covered by normal resources // Build a set of domains already covered by normal resources
const existingFullDomains = new Set<string>(); const existingFullDomains = new Set<string>();
for (const resource of resourcesMap.values()) { for (const resource of resourcesMap.values()) {
@@ -913,13 +913,13 @@ export async function getTraefikConfig(
} }
} }
for (const sr of siteResourcesWithAliases) { for (const sr of siteResourcesWithFullDomain) {
if (!sr.alias) continue; if (!sr.fullDomain) continue;
// Skip if this alias is already handled by a resource router // 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 srKey = `site-resource-cert-${sr.siteResourceId}`;
const siteResourceServiceName = `${srKey}-service`; const siteResourceServiceName = `${srKey}-service`;
const siteResourceRouterName = `${srKey}-router`; const siteResourceRouterName = `${srKey}-router`;
@@ -970,7 +970,7 @@ export async function getTraefikConfig(
], ],
middlewares: [redirectHttpsMiddlewareName], middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName, service: siteResourceServiceName,
rule: `Host(\`${alias}\`)`, rule: `Host(\`${fullDomain}\`)`,
priority: 100 priority: 100
}; };
@@ -979,7 +979,7 @@ export async function getTraefikConfig(
if ( if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) { ) {
const domainParts = alias.split("."); const domainParts = fullDomain.split(".");
const wildCard = const wildCard =
domainParts.length <= 2 domainParts.length <= 2
? `*.${domainParts.join(".")}` ? `*.${domainParts.join(".")}`
@@ -999,11 +999,11 @@ export async function getTraefikConfig(
} else { } else {
// pangolin-dns: only add route if we already have a valid cert // pangolin-dns: only add route if we already have a valid cert
const matchingCert = validCerts.find( const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === alias (cert) => cert.queriedDomain === fullDomain
); );
if (!matchingCert) { if (!matchingCert) {
logger.debug( logger.debug(
`No matching certificate found for siteResource alias: ${alias}` `No matching certificate found for siteResource alias: ${fullDomain}`
); );
continue; continue;
} }
@@ -1016,10 +1016,21 @@ export async function getTraefikConfig(
], ],
service: siteResourceServiceName, service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName], middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${alias}\`)`, rule: `Host(\`${fullDomain}\`)`,
priority: 100, priority: 100,
tls 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
};
} }
} }

View File

@@ -24,14 +24,8 @@ import {
User, User,
certificates, certificates,
exitNodeOrgs, exitNodeOrgs,
RemoteExitNode,
olms,
newts,
clients,
sites,
domains, domains,
orgDomains, orgDomains,
targets,
loginPage, loginPage,
loginPageOrg, loginPageOrg,
LoginPage, LoginPage,
@@ -70,12 +64,9 @@ import {
updateAndGenerateEndpointDestinations, updateAndGenerateEndpointDestinations,
updateSiteBandwidth updateSiteBandwidth
} from "@server/routers/gerbil"; } from "@server/routers/gerbil";
import * as gerbil from "@server/routers/gerbil";
import logger from "@server/logger"; import logger from "@server/logger";
import { decryptData } from "@server/lib/encryption"; import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config"; import config from "@server/lib/config";
import privateConfig from "#private/lib/config";
import * as fs from "fs";
import { exchangeSession } from "@server/routers/badger"; import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; 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) // Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get( hybridRouter.get(
"/certificates/domains", "/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse( const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query req.query
); );
@@ -447,13 +424,13 @@ hybridRouter.get(
const result = filtered.map((cert) => { const result = filtered.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decryptData( const decryptedCert = decrypt(
cert.certFile!, // is not null from query cert.certFile!, // is not null from query
encryptionKey config.getRawConfig().server.secret!
); );
// Decrypt and save key file // 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 only the certificate data without org information
return { 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, data: userOrgRoleRows,
success: true, success: true,
error: false, error: false,

View File

@@ -17,7 +17,7 @@ import {
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; 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 { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -201,6 +201,21 @@ export async function createSiteResource(
subdomain subdomain
} = parsedBody.data; } = 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 // Verify the site exists and belongs to the org
const [site] = await db const [site] = await db
.select() .select()

View File

@@ -1,4 +1,3 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { import {
clientSiteResources, clientSiteResources,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
@@ -13,7 +12,8 @@ import {
Transaction, Transaction,
userSiteResources userSiteResources
} from "@server/db"; } 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 { validateAndConstructDomain } from "@server/lib/domainUtils";
import { import {
generateAliasConfig, 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( const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId, existingSiteResource.orgId,
tierMatrix.sshPam tierMatrix.sshPam

View File

@@ -45,6 +45,7 @@ export default function CreateInternalResourceDialog({
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) { async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true); setIsSubmitting(true);
@@ -159,6 +160,7 @@ export default function CreateInternalResourceDialog({
orgId={orgId} orgId={orgId}
formId="create-internal-resource-form" formId="create-internal-resource-form"
onSubmit={handleSubmit} onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
/> />
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
@@ -174,7 +176,7 @@ export default function CreateInternalResourceDialog({
<Button <Button
type="submit" type="submit"
form="create-internal-resource-form" form="create-internal-resource-form"
disabled={isSubmitting} disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting} loading={isSubmitting}
> >
{t("createInternalResourceDialogCreateResource")} {t("createInternalResourceDialogCreateResource")}

View File

@@ -50,6 +50,7 @@ export default function EditInternalResourceDialog({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) { async function handleSubmit(values: InternalResourceFormValues) {
try { try {
@@ -177,6 +178,7 @@ export default function EditInternalResourceDialog({
onSubmit={(values) => onSubmit={(values) =>
startTransition(() => handleSubmit(values)) startTransition(() => handleSubmit(values))
} }
onSubmitDisabledChange={setIsHttpModeDisabled}
/> />
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
@@ -192,7 +194,7 @@ export default function EditInternalResourceDialog({
<Button <Button
type="submit" type="submit"
form="edit-internal-resource-form" form="edit-internal-resource-form"
disabled={isSubmitting} disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting} loading={isSubmitting}
> >
{t("editInternalResourceDialogSaveResource")} {t("editInternalResourceDialogSaveResource")}

View File

@@ -185,6 +185,7 @@ type InternalResourceFormProps = {
siteResourceId?: number; siteResourceId?: number;
formId: string; formId: string;
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>; onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
onSubmitDisabledChange?: (disabled: boolean) => void;
}; };
export function InternalResourceForm({ export function InternalResourceForm({
@@ -195,13 +196,15 @@ export function InternalResourceForm({
orgId, orgId,
siteResourceId, siteResourceId,
formId, formId,
onSubmit onSubmit,
onSubmitDisabledChange
}: InternalResourceFormProps) { }: InternalResourceFormProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
const nameRequiredKey = const nameRequiredKey =
variant === "create" variant === "create"
@@ -647,6 +650,10 @@ export function InternalResourceForm({
form form
]); ]);
useEffect(() => {
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -853,6 +860,7 @@ export function InternalResourceForm({
field.value ?? field.value ??
"http" "http"
} }
disabled={httpSectionDisabled}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
@@ -893,6 +901,7 @@ export function InternalResourceForm({
<Input <Input
{...field} {...field}
className="w-full" className="w-full"
disabled={isHttpMode && httpSectionDisabled}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -948,6 +957,7 @@ export function InternalResourceForm({
field.value ?? field.value ??
"" ""
} }
disabled={httpSectionDisabled}
onChange={(e) => { onChange={(e) => {
const raw = const raw =
e.target e.target
@@ -981,6 +991,10 @@ export function InternalResourceForm({
</div> </div>
</div> </div>
{isHttpMode && (
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
)}
{isHttpMode ? ( {isHttpMode ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="my-8"> <div className="my-8">
@@ -991,6 +1005,7 @@ export function InternalResourceForm({
{t(httpConfigurationDescriptionKey)} {t(httpConfigurationDescriptionKey)}
</div> </div>
</div> </div>
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
<DomainPicker <DomainPicker
key={ key={
variant === "edit" && siteResourceId variant === "edit" && siteResourceId
@@ -1039,6 +1054,7 @@ export function InternalResourceForm({
); );
}} }}
/> />
</div>
<FormField <FormField
control={form.control} control={form.control}
name="ssl" name="ssl"
@@ -1055,6 +1071,7 @@ export function InternalResourceForm({
onCheckedChange={ onCheckedChange={
field.onChange field.onChange
} }
disabled={httpSectionDisabled}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>