Compare commits

...

15 Commits

Author SHA1 Message Date
miloschwartz 591cb9cdc1 dont use strict query params in list alises 2026-07-10 18:05:16 -04:00
miloschwartz 34b18bdb53 only show approved resources in launcher 2026-07-10 17:24:10 -04:00
miloschwartz 7d475f5e91 filter out pending resources 2026-07-10 17:18:33 -04:00
miloschwartz 39c35fa539 reorganize components nad hooks for consistency 2026-07-10 17:05:45 -04:00
Owen e1bc0b7efd Make sure the enabled gets set to false 2026-07-10 15:36:05 -04:00
Owen 5ef068c8dc Set the resource from the site 2026-07-10 15:31:37 -04:00
Owen 94c01a23a9 Merge branch 'private-resource-enable' into provisioning-resources 2026-07-10 15:17:06 -04:00
Owen 609fb357bb Support enable in the blueprints 2026-07-10 15:16:50 -04:00
Owen cf9a17cc2e Add status filter 2026-07-10 15:04:28 -04:00
Owen 538b57941c Makes sure patch works
z#
2026-07-10 11:32:27 -04:00
Owen f4bee6406a Support partial updates 2026-07-10 10:11:47 -04:00
Owen 0b2693a317 Add toggle to the ui for enabled 2026-07-10 09:59:11 -04:00
Owen 3be2d928f6 Filter out disabled 2026-07-09 21:42:59 -04:00
Owen 8d018fe47d Clean up 2026-07-09 21:28:48 -04:00
Owen dc7eb630c3 Add missing action 2026-07-09 11:16:15 -04:00
39 changed files with 473 additions and 129 deletions
+1
View File
@@ -1397,6 +1397,7 @@
"createOrgUser": "Create Org User", "createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionRemoveInvitation": "Remove Invitation", "actionRemoveInvitation": "Remove Invitation",
"actionRemoveUserRole": "Remove User Role",
"actionUpdateUser": "Update User", "actionUpdateUser": "Update User",
"actionGetUser": "Get User", "actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User", "actionGetOrgUser": "Get Organization User",
+8 -2
View File
@@ -200,7 +200,10 @@ export const resources = pgTable(
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123) authDaemonPort: integer("authDaemonPort").default(22123),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}, },
(t) => [ (t) => [
index("idx_resources_fulldomain") index("idx_resources_fulldomain")
@@ -451,7 +454,10 @@ export const siteResources = pgTable(
onDelete: "set null" onDelete: "set null"
}), }),
subdomain: varchar("subdomain"), subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain") fullDomain: varchar("fullDomain"),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}, },
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
); );
+4 -2
View File
@@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", {
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123) authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const labels = sqliteTable("labels", { export const labels = sqliteTable("labels", {
@@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", {
onDelete: "set null" onDelete: "set null"
}), }),
subdomain: text("subdomain"), subdomain: text("subdomain"),
fullDomain: text("fullDomain") fullDomain: text("fullDomain"),
status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const networks = sqliteTable("networks", { export const networks = sqliteTable("networks", {
-6
View File
@@ -34,12 +34,6 @@ import {
rebuildClientAssociationsFromSiteResource, rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations"; } from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
+16 -10
View File
@@ -26,9 +26,6 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
@@ -201,17 +198,19 @@ export async function updatePrivateResources(
} }
} }
let resourceStatusFromSite: "approved" | "pending" = "approved";
if (siteId && allSites.length === 0) { if (siteId && allSites.length === 0) {
// only add if there are not provided sites // only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx const [siteSingle] = await trx
.select({ siteId: sites.siteId }) .select({ siteId: sites.siteId, status: sites.status })
.from(sites) .from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1); .limit(1);
if (siteSingle) { if (siteSingle) {
allSites.push(siteSingle); allSites.push(siteSingle);
} }
resourceStatusFromSite = siteSingle.status ?? "approved";
} }
if (allSites.length === 0) { if (allSites.length === 0) {
@@ -220,6 +219,13 @@ export async function updatePrivateResources(
); );
} }
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
if (existingResource) { if (existingResource) {
let domainInfo: let domainInfo:
| { subdomain: string | null; domainId: string } | { subdomain: string | null; domainId: string }
@@ -243,8 +249,7 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: resourceEnabled,
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: disableIcmp:
resourceData["disable-icmp"] || resourceData["disable-icmp"] ||
@@ -263,7 +268,8 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough", pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode: authDaemonMode:
resourceData["auth-daemon"]?.mode || "native", resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123 authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -496,8 +502,7 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: resourceEnabled,
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: disableIcmp:
@@ -517,7 +522,8 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough", pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode: authDaemonMode:
resourceData["auth-daemon"]?.mode || "native", resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123 authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
}) })
.returning(); .returning();
+36 -14
View File
@@ -25,6 +25,7 @@ import {
rolePolicies, rolePolicies,
roleResources, roleResources,
roles, roles,
Site,
sites, sites,
Target, Target,
TargetHealthCheck, TargetHealthCheck,
@@ -74,19 +75,40 @@ export async function updatePublicResources(
)) { )) {
const targetsToUpdate: Target[] = []; const targetsToUpdate: Target[] = [];
const healthchecksToUpdate: TargetHealthCheck[] = []; const healthchecksToUpdate: TargetHealthCheck[] = [];
let resource: Resource; let resource: Resource;
let resourceStatusFromSite: "approved" | "pending" = "approved";
let providedSite: Partial<Site> | undefined;
if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[providedSite] = await trx
.select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
resourceStatusFromSite = providedSite?.status ?? "approved";
}
async function createTarget( // reusable function to create a target async function createTarget( // reusable function to create a target
resourceId: number, resourceId: number,
targetData: TargetData targetData: TargetData
) { ) {
const targetSiteId = targetData.site; const targetSiteId = targetData.site;
let site; let site: Partial<Site> | undefined;
if (targetSiteId) { if (targetSiteId) {
// Look up site by niceId // Look up site by niceId
[site] = await trx [site] = await trx
.select({ siteId: sites.siteId, type: sites.type }) .select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites) .from(sites)
.where( .where(
and( and(
@@ -95,15 +117,9 @@ export async function updatePublicResources(
) )
) )
.limit(1); .limit(1);
} else if (siteId) { } else if (siteId && providedSite) {
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
[site] = await trx site = providedSite;
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else { } else {
throw new Error(`Target site is required`); throw new Error(`Target site is required`);
} }
@@ -139,7 +155,7 @@ export async function updatePublicResources(
.insert(targets) .insert(targets)
.values({ .values({
resourceId: resourceId, resourceId: resourceId,
siteId: site.siteId, siteId: site.siteId!,
ip: targetData.hostname, ip: targetData.hostname,
mode: resourceData.mode as Target["mode"], mode: resourceData.mode as Target["mode"],
method: targetData.method, method: targetData.method,
@@ -172,7 +188,7 @@ export async function updatePublicResources(
.insert(targetHealthCheck) .insert(targetHealthCheck)
.values({ .values({
name: `${targetData.hostname}:${targetData.port}`, name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId, siteId: site.siteId!,
targetId: newTarget.targetId, targetId: newTarget.targetId,
orgId: orgId, orgId: orgId,
hcEnabled: healthcheckData?.enabled || false, hcEnabled: healthcheckData?.enabled || false,
@@ -230,7 +246,10 @@ export async function updatePublicResources(
const resourceEnabled = const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null resourceData.enabled == undefined || resourceData.enabled == null
? true ? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled; : resourceData.enabled;
const resourceSsl = const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null resourceData.ssl == undefined || resourceData.ssl == null
? true ? true
@@ -406,7 +425,8 @@ export async function updatePublicResources(
? (resourceData["proxy-protocol-version"] ?? ? (resourceData["proxy-protocol-version"] ??
1) 1)
: 1, : 1,
resourcePolicyId: sharedPolicy.resourcePolicyId resourcePolicyId: sharedPolicy.resourcePolicyId,
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -590,7 +610,8 @@ export async function updatePublicResources(
authDaemonPort: authDaemonPort:
resourceData["auth-daemon"]?.port || 22123, resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null, resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId defaultResourcePolicyId: inlinePolicyId,
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -1131,6 +1152,7 @@ export async function updatePublicResources(
.values({ .values({
orgId, orgId,
niceId: resourceNiceId, niceId: resourceNiceId,
status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode, mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes( proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
+1 -1
View File
@@ -470,7 +470,7 @@ export const PrivateResourceSchema = z
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(), "destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
// enabled: z.boolean().default(true), enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false), "disable-icmp": z.boolean().optional().default(false),
+9
View File
@@ -496,6 +496,7 @@ export function generateRemoteSubnets(
): string[] { ): string[] {
const remoteSubnets = allSiteResources const remoteSubnets = allSiteResources
.filter((sr) => { .filter((sr) => {
if (!sr.enabled) return false;
if (!sr.destination) return false; if (!sr.destination) return false;
if (sr.mode === "cidr") { if (sr.mode === "cidr") {
@@ -530,6 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter( .filter(
(sr) => (sr) =>
sr.enabled &&
sr.aliasAddress && sr.aliasAddress &&
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) || ((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http")) (sr.fullDomain && sr.mode == "http"))
@@ -662,6 +664,13 @@ export async function generateSubnetProxyTargetV2(
subnet: string | null; subnet: string | null;
}[] }[]
): Promise<SubnetProxyTargetV2[] | undefined> { ): Promise<SubnetProxyTargetV2[] | undefined> {
if (!siteResource.enabled) {
logger.debug(
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
);
return;
}
if (clients.length === 0) { if (clients.length === 0) {
logger.debug( logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
+20 -6
View File
@@ -1561,9 +1561,19 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource.udpPortRangeString || updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !== existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp); updatedSiteResource.disableIcmp);
// Toggling enabled on/off doesn't change any of the fields above, but it
// does change whether targets/peer data should exist at all, so it needs
// to drive the same old->new diff machinery: going enabled->disabled
// diffs "real data" against "nothing" (a remove), and disabled->enabled
// diffs "nothing" against "real data" (an add). generateSubnetProxyTargetV2/
// generateRemoteSubnets/generateAliasConfig already return nothing for a
// disabled resource, so no other changes are needed here.
const enabledChanged =
existingSiteResource &&
existingSiteResource.enabled !== updatedSiteResource.enabled;
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}` `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}`
); );
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
@@ -1574,14 +1584,16 @@ export async function handleMessagingForUpdatedSiteResource(
fullDomainChanged || fullDomainChanged ||
sslChanged || sslChanged ||
portRangesChanged || portRangesChanged ||
destinationPortChanged destinationPortChanged ||
enabledChanged
) { ) {
const shouldUpdateTargets = const shouldUpdateTargets =
destinationChanged || destinationChanged ||
sslChanged || sslChanged ||
portRangesChanged || portRangesChanged ||
fullDomainChanged || fullDomainChanged ||
destinationPortChanged; destinationPortChanged ||
enabledChanged;
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
@@ -1657,9 +1669,11 @@ export async function handleMessagingForUpdatedSiteResource(
peerDataUpdateBatch.push({ peerDataUpdateBatch.push({
clientId: client.clientId, clientId: client.clientId,
siteId, siteId,
remoteSubnets: destinationChanged remoteSubnets:
destinationChanged || enabledChanged
? { ? {
oldRemoteSubnets: !oldDestinationStillInUseBySite oldRemoteSubnets:
!oldDestinationStillInUseBySite
? generateRemoteSubnets([ ? generateRemoteSubnets([
existingSiteResource existingSiteResource
]) ])
@@ -1670,7 +1684,7 @@ export async function handleMessagingForUpdatedSiteResource(
} }
: undefined, : undefined,
aliases: aliases:
aliasChanged || fullDomainChanged // the full domain is sent down as an alias aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias
? { ? {
oldAliases: generateAliasConfig([ oldAliases: generateAliasConfig([
existingSiteResource existingSiteResource
@@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
eq(userResources.userId, userId), eq(userResources.userId, userId),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
eq(resources.status, "approved")
) )
), ),
userRoleIds.length > 0 userRoleIds.length > 0
@@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
inArray(roleResources.roleId, userRoleIds), inArray(roleResources.roleId, userRoleIds),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
eq(resources.status, "approved")
) )
) )
: Promise.resolve([]), : Promise.resolve([]),
@@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached(
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
) )
.where( .where(
and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId)) and(
eq(userPolicies.userId, userId),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
), ),
userRoleIds.length > 0 userRoleIds.length > 0
? db ? db
@@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
inArray(rolePolicies.roleId, userRoleIds), inArray(rolePolicies.roleId, userRoleIds),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
eq(resources.status, "approved")
) )
) )
: Promise.resolve([]), : Promise.resolve([]),
db db
.select({ siteResourceId: userSiteResources.siteResourceId }) .select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources) .from(userSiteResources)
.where(eq(userSiteResources.userId, userId)), .innerJoin(
siteResources,
eq(
userSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, userId),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
),
userRoleIds.length > 0 userRoleIds.length > 0
? db ? db
.select({ .select({
siteResourceId: roleSiteResources.siteResourceId siteResourceId: roleSiteResources.siteResourceId
}) })
.from(roleSiteResources) .from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds)) .innerJoin(
siteResources,
eq(
roleSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, userRoleIds),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
)
: Promise.resolve([]) : Promise.resolve([])
]); ]);
@@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch(
inArray(resources.resourceId, resourceIds), inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
textMatch textMatch
) )
); );
@@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch(
inArray(siteResources.siteResourceId, siteResourceIds), inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
textMatch textMatch
) )
); );
@@ -503,7 +538,8 @@ async function listSiteGroups(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
if (searchPublic) { if (searchPublic) {
publicConditions.push(searchPublic); publicConditions.push(searchPublic);
@@ -558,7 +594,8 @@ async function listSiteGroups(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
if (searchSite) { if (searchSite) {
siteConditions.push(searchSite); siteConditions.push(searchSite);
@@ -621,7 +658,8 @@ async function listSiteGroups(
const noSitePublicConditions = [ const noSitePublicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
if (searchPublic) { if (searchPublic) {
noSitePublicConditions.push(searchPublic); noSitePublicConditions.push(searchPublic);
@@ -655,7 +693,8 @@ async function listSiteGroups(
const noSiteSiteConditions = [ const noSiteSiteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
if (searchSite) { if (searchSite) {
noSiteSiteConditions.push(searchSite); noSiteSiteConditions.push(searchSite);
@@ -746,7 +785,8 @@ async function listLabelGroups(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
const searchPublic = buildSearchConditionForPublic(query.query); const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) { if (searchPublic) {
@@ -810,7 +850,8 @@ async function listLabelGroups(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
const searchSite = buildSearchConditionForSiteResource(query.query); const searchSite = buildSearchConditionForSiteResource(query.query);
if (searchSite) { if (searchSite) {
@@ -997,6 +1038,7 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds), inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
siteIdFilter != null siteIdFilter != null
? eq(sites.siteId, siteIdFilter) ? eq(sites.siteId, siteIdFilter)
: undefined : undefined
@@ -1088,6 +1130,7 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds), inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
siteIdFilter != null siteIdFilter != null
? eq(sites.siteId, siteIdFilter) ? eq(sites.siteId, siteIdFilter)
: undefined : undefined
@@ -1382,7 +1425,8 @@ async function collectAccessibleSites(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
if (siteNameSearch) { if (siteNameSearch) {
publicConditions.push(siteNameSearch); publicConditions.push(siteNameSearch);
@@ -1422,7 +1466,8 @@ async function collectAccessibleSites(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
if (siteNameSearch) { if (siteNameSearch) {
siteConditions.push(siteNameSearch); siteConditions.push(siteNameSearch);
@@ -1476,6 +1521,7 @@ async function collectAccessibleLabels(
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
eq(labels.orgId, orgId) eq(labels.orgId, orgId)
]; ];
if (labelNameSearch) { if (labelNameSearch) {
@@ -1511,6 +1557,7 @@ async function collectAccessibleLabels(
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
eq(labels.orgId, orgId) eq(labels.orgId, orgId)
]; ];
if (labelNameSearch) { if (labelNameSearch) {
+6 -1
View File
@@ -148,7 +148,12 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources) .from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(eq(siteNetworks.siteId, siteId)) .where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.enabled, true)
)
)
.then((rows) => rows.map((r) => r.siteResources)); .then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];
+8 -2
View File
@@ -16,7 +16,7 @@ import {
generateRemoteSubnets generateRemoteSubnets
} from "@server/lib/ip"; } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -70,7 +70,13 @@ export async function buildSiteConfigurationForOlmClient(
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.enabled, true)
)
); );
const siteResourcesBySiteId = new Map<number, SiteResource[]>(); const siteResourcesBySiteId = new Map<number, SiteResource[]>();
+14
View File
@@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({
description: description:
"When set, only resources that have at least one target on this site are returned" "When set, only resources that have at least one target on this site are returned"
}), }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by resource status"
}),
labels: z labels: z
.preprocess((val) => { .preprocess((val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
@@ -451,6 +460,7 @@ export async function listResources(
sort_by, sort_by,
order, order,
siteId, siteId,
status,
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
@@ -660,6 +670,10 @@ export async function listResources(
} }
} }
if (typeof status !== "undefined") {
conditions.push(eq(resources.status, status));
}
if (siteId != null) { if (siteId != null) {
const resourcesWithSite = db const resourcesWithSite = db
.select({ resourceId: targets.resourceId }) .select({ resourceId: targets.resourceId })
@@ -45,18 +45,19 @@ function userResourceAliasesCacheKey(
page: number, page: number,
pageSize: number, pageSize: number,
includeLabels: boolean, includeLabels: boolean,
labelFilter: string[] labelFilter: string[],
status?: "pending" | "approved"
) { ) {
const labelsKey = const labelsKey =
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`; return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
} }
const listUserResourceAliasesParamsSchema = z.strictObject({ const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const listUserResourceAliasesQuerySchema = z.strictObject({ const listUserResourceAliasesQuerySchema = z.object({
pageSize: z.coerce pageSize: z.coerce
.number<string>() .number<string>()
.int() .int()
@@ -96,6 +97,15 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
type: "array", type: "array",
description: description:
"Filter by resource labels. A resource matches when it has any of the given labels (OR)." "Filter by resource labels. A resource matches when it has any of the given labels (OR)."
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}) })
}); });
@@ -130,7 +140,8 @@ export async function listUserResourceAliases(
page, page,
pageSize, pageSize,
includeLabels, includeLabels,
labels: labelFilter labels: labelFilter,
status
} = parsedQuery.data; } = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse( const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
@@ -172,7 +183,8 @@ export async function listUserResourceAliases(
page, page,
pageSize, pageSize,
includeLabels, includeLabels,
labelFilter ?? [] labelFilter ?? [],
status
); );
const cachedData: ListUserResourceAliasesResponse | undefined = const cachedData: ListUserResourceAliasesResponse | undefined =
await cache.get(cacheKey); await cache.get(cacheKey);
@@ -257,6 +269,10 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds) inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
]; ];
if (typeof status !== "undefined") {
whereConditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
whereConditions.push( whereConditions.push(
inArray( inArray(
@@ -56,7 +56,6 @@ const createSiteResourceSchema = z
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1).nullish(), destination: z.string().min(1).nullish(),
enabled: z.boolean().default(true),
alias: z alias: z
.string() .string()
.regex( .regex(
@@ -275,7 +274,6 @@ export async function createSiteResource(
scheme, scheme,
destinationPort, destinationPort,
destination, destination,
enabled,
ssl, ssl,
alias, alias,
userIds, userIds,
@@ -539,7 +537,6 @@ export async function createSiteResource(
destination: destination, // the ssh can be null destination: destination, // the ssh can be null
scheme, scheme,
destinationPort, destinationPort,
enabled,
alias: alias ? alias.trim() : null, alias: alias ? alias.trim() : null,
aliasAddress, aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted, tcpPortRangeString: tcpPortRangeStringAdjusted,
@@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
description: description:
"When set, only site resources associated with this site (via network) are returned" "When set, only site resources associated with this site (via network) are returned"
}), }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}),
labels: z labels: z
.preprocess((val) => { .preprocess((val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
@@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg(
sort_by, sort_by,
order, order,
siteId, siteId,
status,
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
@@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
@@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({
enum: ["asc", "desc"], enum: ["asc", "desc"],
default: "asc", default: "asc",
description: "Sort order" description: "Sort order"
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}) })
}); });
@@ -110,7 +119,7 @@ export async function listSiteResources(
} }
const { siteId, orgId } = parsedParams.data; const { siteId, orgId } = parsedParams.data;
const { limit, offset, sort_by, order } = parsedQuery.data; const { limit, offset, sort_by, order, status } = parsedQuery.data;
// 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
@@ -124,6 +133,15 @@ export async function listSiteResources(
} }
// Get site resources by joining networks to siteResources via siteNetworks // Get site resources by joining networks to siteResources via siteNetworks
const conditions = [
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
];
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
const siteResourcesList = await db const siteResourcesList = await db
.select() .select()
.from(siteNetworks) .from(siteNetworks)
@@ -132,12 +150,7 @@ export async function listSiteResources(
siteResources, siteResources,
eq(siteResources.networkId, networks.networkId) eq(siteResources.networkId, networks.networkId)
) )
.where( .where(and(...conditions))
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.orderBy( .orderBy(
sort_by sort_by
? order === "asc" ? order === "asc"
@@ -152,6 +152,11 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
// this is a partial update; only enforce destination when the
// caller is actually changing mode or destination
if (data.mode === undefined && data.destination === undefined) {
return true;
}
// destination is only optional for ssh mode with native authDaemonMode // destination is only optional for ssh mode with native authDaemonMode
if (data.mode === "ssh" && data.authDaemonMode === "native") { if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true; return true;
@@ -411,8 +416,10 @@ export async function updateSiteResource(
: []; : [];
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
let fullDomain: string | null = null; // undefined means "leave unchanged" (partial update); only nulled out
let finalSubdomain: string | null = null; // when the mode is explicitly being changed away from http
let fullDomain: string | null | undefined = undefined;
let finalSubdomain: string | null | undefined = undefined;
if (domainId) { if (domainId) {
// Validate domain and construct full domain // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
@@ -448,6 +455,11 @@ export async function updateSiteResource(
) )
); );
} }
} else if (mode !== undefined && mode !== "http") {
// mode is explicitly changing away from http, so the resource
// can no longer have a domain associated with it
fullDomain = null;
finalSubdomain = null;
} }
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
@@ -516,15 +528,28 @@ export async function updateSiteResource(
destination: destination, destination: destination,
destinationPort: destinationPort, destinationPort: destinationPort,
enabled: enabled, enabled: enabled,
alias: alias ? alias.trim() : null, alias:
alias !== undefined
? alias
? alias.trim()
: null
: mode !== undefined &&
mode !== "host" &&
mode !== "ssh"
? null
: undefined,
tcpPortRangeString: tcpPortRangeStringAdjusted, tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString: udpPortRangeString:
mode == "http" || mode == "ssh" mode == "http" || mode == "ssh"
? "" ? ""
: udpPortRangeString, : udpPortRangeString,
disableIcmp: disableIcmp:
disableIcmp || mode !== undefined
(mode == "http" || mode == "ssh" ? true : false), ? disableIcmp ||
(mode == "http" || mode == "ssh"
? true
: false)
: disableIcmp,
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain, fullDomain,
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields"; import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() { export default function PrivateResourceAccessPage() {
const t = useTranslations(); const t = useTranslations();
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields"; import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { asAnyControl, asAnySetValue } from "../../formControlUtils"; import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceCidrPage() { export default function PrivateResourceCidrPage() {
const t = useTranslations(); const t = useTranslations();
@@ -16,19 +16,21 @@ import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react"; import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
export default function PrivateResourceGeneralPage() { export default function PrivateResourceGeneralPage() {
const t = useTranslations(); const t = useTranslations();
@@ -41,7 +43,8 @@ export default function PrivateResourceGeneralPage() {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: siteResource.name, name: siteResource.name,
niceId: siteResource.niceId niceId: siteResource.niceId,
enabled: siteResource.enabled
} }
}); });
@@ -52,7 +55,8 @@ export default function PrivateResourceGeneralPage() {
const data = form.getValues(); const data = form.getValues();
await save({ await save({
name: data.name, name: data.name,
niceId: data.niceId niceId: data.niceId,
enabled: data.enabled
}); });
}, null); }, null);
@@ -76,6 +80,42 @@ export default function PrivateResourceGeneralPage() {
id="private-resource-general-form" id="private-resource-general-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
siteResource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields"; import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "../../formControlUtils"; } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHostPage() { export default function PrivateResourceHostPage() {
const t = useTranslations(); const t = useTranslations();
@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields"; import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "../../formControlUtils"; } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHttpPage() { export default function PrivateResourceHttpPage() {
const t = useTranslations(); const t = useTranslations();
@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields"; import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import type { Selectedsite } from "@app/components/site-selector";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "../../formControlUtils"; } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
import type { Selectedsite } from "@app/components/site-selector";
export default function PrivateResourceSshPage() { export default function PrivateResourceSshPage() {
const t = useTranslations(); const t = useTranslations();
@@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields"; import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "../PrivateResourceSshFields"; import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { import {
PrivateResourceAliasField, PrivateResourceAliasField,
PrivateResourceCidrDestinationField, PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields PrivateResourceHostDestinationFields
} from "../PrivateResourceDestinationFields"; } from "@app/components/PrivateResourceDestinationFields";
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils"; import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
export default function CreatePrivateResourcePage() { export default function CreatePrivateResourcePage() {
const params = useParams(); const params = useParams();
@@ -27,6 +27,7 @@ export default async function ClientResourcesPage(
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
@@ -38,6 +38,7 @@ export default async function ProxyResourcesPage(
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = { let pagination: ListResourcesResponse["pagination"] = {
@@ -23,7 +23,7 @@ import {
import { ChevronsUpDown } from "lucide-react"; import { ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Control, FieldPath, FieldValues } from "react-hook-form"; import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp"; import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
type PrivateResourceSitesFieldProps<T extends FieldValues> = { type PrivateResourceSitesFieldProps<T extends FieldValues> = {
control: Control<T>; control: Control<T>;
@@ -9,10 +9,10 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields"; import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "./PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
import { inferSshPamMode } from "@app/lib/privateResourceForm"; import { inferSshPamMode } from "@app/lib/privateResourceForm";
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
import { import {
FormControl, FormControl,
FormField, FormField,
+108 -1
View File
@@ -23,6 +23,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
@@ -36,7 +37,9 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import { import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowRight, ArrowRight,
@@ -49,8 +52,17 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startTransition, useMemo, useState, useTransition } from "react"; import {
startTransition,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell"; import { LabelsTableCell } from "./LabelsTableCell";
@@ -114,6 +126,11 @@ function isSafeUrlForLink(href: string): boolean {
} }
} }
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
@@ -174,6 +191,30 @@ export default function PrivateResourcesTable({
}); });
}; };
async function toggleInternalResourceEnabled(
val: boolean,
resourceId: number
) {
try {
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
`site-resource/${resourceId}`,
{
enabled: val
}
);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
}
}
const deleteInternalResource = async ( const deleteInternalResource = async (
resourceId: number, resourceId: number,
siteId: number siteId: number
@@ -429,6 +470,36 @@ export default function PrivateResourcesTable({
); );
} }
}, },
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<InternalResourceEnabledForm
resource={row.original}
onToggleInternalResourceEnabled={
toggleInternalResourceEnabled
}
/>
)
},
{ {
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
@@ -643,3 +714,39 @@ function ClientResourceLabelCell({
/> />
); );
} }
type InternalResourceEnabledFormProps = {
resource: InternalResourceRow;
onToggleInternalResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function InternalResourceEnabledForm({
resource,
onToggleInternalResourceEnabled
}: InternalResourceEnabledFormProps) {
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
resource.enabled
);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleInternalResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={optimisticEnabled !== resource.enabled}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
+2 -2
View File
@@ -165,7 +165,6 @@ export function buildCreateSiteResourcePayload(
siteIds: data.siteIds, siteIds: data.siteIds,
mode: data.mode, mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined), destination: isNativeSsh ? undefined : (data.destination ?? undefined),
enabled: true,
...(data.mode === "http" && { ...(data.mode === "http" && {
scheme: data.scheme, scheme: data.scheme,
ssl: data.ssl ?? false, ssl: data.ssl ?? false,
@@ -342,7 +341,8 @@ export function createGeneralFormSchema(t: TranslateFn) {
.string() .string()
.min(1) .min(1)
.max(255) .max(255)
.regex(/^[a-zA-Z0-9-]+$/) .regex(/^[a-zA-Z0-9-]+$/),
enabled: z.boolean()
}); });
} }