From 8d018fe47da9ea50d5ab44e28d2679dfb74524bb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Jul 2026 21:28:48 -0400 Subject: [PATCH 01/18] Clean up --- server/lib/blueprints/applyBlueprint.ts | 6 ------ server/lib/blueprints/privateResources.ts | 3 --- server/routers/siteResource/createSiteResource.ts | 3 --- 3 files changed, 12 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index c62dd4735..44646e0bf 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -34,12 +34,6 @@ import { rebuildClientAssociationsFromSiteResource, waitForSiteResourceRebuildIdle } 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 = { orgId: string; diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 74a1f618f..aa146679f 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -26,9 +26,6 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; 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"; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index eaf4cf130..365053cf1 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -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 destinationPort: z.int().positive().optional(), destination: z.string().min(1).nullish(), - enabled: z.boolean().default(true), alias: z .string() .regex( @@ -275,7 +274,6 @@ export async function createSiteResource( scheme, destinationPort, destination, - enabled, ssl, alias, userIds, @@ -539,7 +537,6 @@ export async function createSiteResource( destination: destination, // the ssh can be null scheme, destinationPort, - enabled, alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString: tcpPortRangeStringAdjusted, From 3be2d928f6f4bd02467e6fe01fbb4d0ecca7e9d5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 9 Jul 2026 21:42:59 -0400 Subject: [PATCH 02/18] Filter out disabled --- server/lib/ip.ts | 9 +++++ server/lib/rebuildClientAssociations.ts | 46 +++++++++++++++-------- server/routers/newt/buildConfiguration.ts | 7 +++- server/routers/olm/buildConfiguration.ts | 10 ++++- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 56576282a..fb161df1c 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -496,6 +496,7 @@ export function generateRemoteSubnets( ): string[] { const remoteSubnets = allSiteResources .filter((sr) => { + if (!sr.enabled) return false; if (!sr.destination) return false; if (sr.mode === "cidr") { @@ -530,6 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources .filter( (sr) => + sr.enabled && sr.aliasAddress && ((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) || (sr.fullDomain && sr.mode == "http")) @@ -662,6 +664,13 @@ export async function generateSubnetProxyTargetV2( subnet: string | null; }[] ): Promise { + if (!siteResource.enabled) { + logger.debug( + `Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.` + ); + return; + } + if (clients.length === 0) { logger.debug( `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index efb856825..eae579634 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1561,9 +1561,19 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource.udpPortRangeString || existingSiteResource.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( - `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 @@ -1574,14 +1584,16 @@ export async function handleMessagingForUpdatedSiteResource( fullDomainChanged || sslChanged || portRangesChanged || - destinationPortChanged + destinationPortChanged || + enabledChanged ) { const shouldUpdateTargets = destinationChanged || sslChanged || portRangesChanged || fullDomainChanged || - destinationPortChanged; + destinationPortChanged || + enabledChanged; logger.debug( `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` @@ -1657,20 +1669,22 @@ export async function handleMessagingForUpdatedSiteResource( peerDataUpdateBatch.push({ clientId: client.clientId, siteId, - remoteSubnets: destinationChanged - ? { - oldRemoteSubnets: !oldDestinationStillInUseBySite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, + remoteSubnets: + destinationChanged || enabledChanged + ? { + oldRemoteSubnets: + !oldDestinationStillInUseBySite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, 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([ existingSiteResource diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 5b7f211ae..a12d035a5 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -148,7 +148,12 @@ export async function buildClientConfigurationForNewtClient( .from(siteResources) .innerJoin(networks, eq(siteResources.networkId, networks.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)); const targetsToSend: SubnetProxyTargetV2[] = []; diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index f72731c92..0266fa041 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -16,7 +16,7 @@ import { generateRemoteSubnets } from "@server/lib/ip"; 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 config from "@server/lib/config"; @@ -70,7 +70,13 @@ export async function buildSiteConfigurationForOlmClient( .innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .where( - eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) + and( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ), + eq(siteResources.enabled, true) + ) ); const siteResourcesBySiteId = new Map(); From 0b2693a317ba9dc788db3a44cc40e25423defc1b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 09:59:11 -0400 Subject: [PATCH 03/18] Add toggle to the ui for enabled --- .../private/[niceId]/general/page.tsx | 44 ++++++- src/components/PrivateResourcesTable.tsx | 109 +++++++++++++++++- src/lib/privateResourceForm.ts | 3 +- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index 6e1a9fdeb..d14b9c3d5 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -16,12 +16,14 @@ import { Button } from "@app/components/ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { SwitchInput } from "@app/components/SwitchInput"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslations } from "next-intl"; @@ -41,7 +43,8 @@ export default function PrivateResourceGeneralPage() { resolver: zodResolver(formSchema), defaultValues: { name: siteResource.name, - niceId: siteResource.niceId + niceId: siteResource.niceId, + enabled: siteResource.enabled } }); @@ -52,7 +55,8 @@ export default function PrivateResourceGeneralPage() { const data = form.getValues(); await save({ name: data.name, - niceId: data.niceId + niceId: data.niceId, + enabled: data.enabled }); }, null); @@ -76,6 +80,42 @@ export default function PrivateResourceGeneralPage() { id="private-resource-general-form" > + + ( + + + + form.setValue( + "enabled", + val + ) + } + /> + + + {t( + "disabledResourceDescription" + )} + + + + )} + /> + + >( + `site-resource/${resourceId}`, + { + enabled: val + } + ); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) + }); + } + } + const deleteInternalResource = async ( resourceId: number, siteId: number @@ -429,6 +470,36 @@ export default function PrivateResourcesTable({ ); } }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + ) + }, { id: "labels", accessorKey: "labels", @@ -643,3 +714,39 @@ function ClientResourceLabelCell({ /> ); } + +type InternalResourceEnabledFormProps = { + resource: InternalResourceRow; + onToggleInternalResourceEnabled: ( + val: boolean, + resourceId: number + ) => Promise; +}; + +function InternalResourceEnabledForm({ + resource, + onToggleInternalResourceEnabled +}: InternalResourceEnabledFormProps) { + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic( + resource.enabled + ); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleInternalResourceEnabled(newEnabled, resource.id); + } + + return ( +
+ formRef.current?.requestSubmit()} + /> + + ); +} diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index d9f6fd691..86c6228cd 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -342,7 +342,8 @@ export function createGeneralFormSchema(t: TranslateFn) { .string() .min(1) .max(255) - .regex(/^[a-zA-Z0-9-]+$/) + .regex(/^[a-zA-Z0-9-]+$/), + enabled: z.boolean() }); } From f4bee6406a23250fd5f64fb6cad44c5dca1959b3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 10:11:47 -0400 Subject: [PATCH 04/18] Support partial updates --- server/routers/siteResource/updateSiteResource.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 173a15190..e1e25f918 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -152,6 +152,11 @@ const updateSiteResourceSchema = z ) .refine( (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 if (data.mode === "ssh" && data.authDaemonMode === "native") { return true; From 538b57941c114307528098a2ef4c95e07c5a3b50 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 11:32:27 -0400 Subject: [PATCH 05/18] Makes sure patch works z# --- .../siteResource/updateSiteResource.ts | 30 +++++++++++++++---- src/lib/privateResourceForm.ts | 1 - 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index e1e25f918..0ccf29908 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -416,8 +416,10 @@ export async function updateSiteResource( : []; const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); - let fullDomain: string | null = null; - let finalSubdomain: string | null = null; + // undefined means "leave unchanged" (partial update); only nulled out + // when the mode is explicitly being changed away from http + let fullDomain: string | null | undefined = undefined; + let finalSubdomain: string | null | undefined = undefined; if (domainId) { // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -453,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 @@ -521,15 +528,28 @@ export async function updateSiteResource( destination: destination, destinationPort: destinationPort, enabled: enabled, - alias: alias ? alias.trim() : null, + alias: + alias !== undefined + ? alias + ? alias.trim() + : null + : mode !== undefined && + mode !== "host" && + mode !== "ssh" + ? null + : undefined, tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: mode == "http" || mode == "ssh" ? "" : udpPortRangeString, disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), + mode !== undefined + ? disableIcmp || + (mode == "http" || mode == "ssh" + ? true + : false) + : disableIcmp, domainId, subdomain: finalSubdomain, fullDomain, diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 86c6228cd..3edd436e4 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -165,7 +165,6 @@ export function buildCreateSiteResourcePayload( siteIds: data.siteIds, mode: data.mode, destination: isNativeSsh ? undefined : (data.destination ?? undefined), - enabled: true, ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, From cf9a17cc2ef6fb6f292e0f9379334d8afb12cec4 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:03:50 -0400 Subject: [PATCH 06/18] Add status filter --- server/db/pg/schema/schema.ts | 10 +++++-- server/db/sqlite/schema/schema.ts | 6 +++-- server/routers/resource/listResources.ts | 14 ++++++++++ .../resource/listUserResourceAliases.ts | 26 ++++++++++++++---- .../siteResource/listAllSiteResourcesByOrg.ts | 14 ++++++++++ .../routers/siteResource/listSiteResources.ts | 27 ++++++++++++++----- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b207b423b..f3375b0d9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -200,7 +200,10 @@ export const resources = pgTable( authDaemonMode: varchar("authDaemonMode", { length: 32 }) .$type<"site" | "remote" | "native">() .default("site"), - authDaemonPort: integer("authDaemonPort").default(22123) + authDaemonPort: integer("authDaemonPort").default(22123), + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") }, (t) => [ index("idx_resources_fulldomain") @@ -451,7 +454,10 @@ export const siteResources = pgTable( onDelete: "set null" }), 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)] ); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4ec4916b5..3fcf38a41 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", { authDaemonMode: text("authDaemonMode") .$type<"site" | "remote" | "native">() .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", { @@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", { onDelete: "set null" }), subdomain: text("subdomain"), - fullDomain: text("fullDomain") + fullDomain: text("fullDomain"), + status: text("status").$type<"pending" | "approved">().default("approved") }); export const networks = sqliteTable("networks", { diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 9567b5bcd..e0baa49b1 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({ description: "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 .preprocess((val) => { if (val === undefined || val === null || val === "") { @@ -451,6 +460,7 @@ export async function listResources( sort_by, order, siteId, + status, labels: labelFilter } = parsedQuery.data; @@ -660,6 +670,10 @@ export async function listResources( } } + if (typeof status !== "undefined") { + conditions.push(eq(resources.status, status)); + } + if (siteId != null) { const resourcesWithSite = db .select({ resourceId: targets.resourceId }) diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 4ec87b8bb..100b1624d 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -45,11 +45,12 @@ function userResourceAliasesCacheKey( page: number, pageSize: number, includeLabels: boolean, - labelFilter: string[] + labelFilter: string[], + status?: "pending" | "approved" ) { const labelsKey = 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({ @@ -96,7 +97,16 @@ const listUserResourceAliasesQuerySchema = z.strictObject({ type: "array", description: "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" + }) }); export type UserResourceAliasItem = { @@ -130,7 +140,8 @@ export async function listUserResourceAliases( page, pageSize, includeLabels, - labels: labelFilter + labels: labelFilter, + status } = parsedQuery.data; const parsedParams = listUserResourceAliasesParamsSchema.safeParse( @@ -172,7 +183,8 @@ export async function listUserResourceAliases( page, pageSize, includeLabels, - labelFilter ?? [] + labelFilter ?? [], + status ); const cachedData: ListUserResourceAliasesResponse | undefined = await cache.get(cacheKey); @@ -257,6 +269,10 @@ export async function listUserResourceAliases( inArray(siteResources.siteResourceId, accessibleSiteResourceIds) ]; + if (typeof status !== "undefined") { + whereConditions.push(eq(siteResources.status, status)); + } + if (labelFilter && labelFilter.length > 0) { whereConditions.push( inArray( diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 4515e033e..2cbbc2c4e 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ description: "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 .preprocess((val) => { if (val === undefined || val === null || val === "") { @@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg( sort_by, order, siteId, + status, labels: labelFilter } = parsedQuery.data; @@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg( conditions.push(eq(siteResources.mode, mode)); } + if (typeof status !== "undefined") { + conditions.push(eq(siteResources.status, status)); + } + if (labelFilter && labelFilter.length > 0) { conditions.push( inArray( diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index a9688a9c6..c97900bec 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({ enum: ["asc", "desc"], default: "asc", 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 { 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 const site = await db @@ -124,6 +133,15 @@ export async function listSiteResources( } // 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 .select() .from(siteNetworks) @@ -132,12 +150,7 @@ export async function listSiteResources( siteResources, eq(siteResources.networkId, networks.networkId) ) - .where( - and( - eq(siteNetworks.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) + .where(and(...conditions)) .orderBy( sort_by ? order === "asc" From 609fb357bb1e2ec26535c202181beb4112d70fa8 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:16:50 -0400 Subject: [PATCH 07/18] Support enable in the blueprints --- server/lib/blueprints/privateResources.ts | 6 ++---- server/lib/blueprints/types.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index aa146679f..7d5d9a874 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -240,8 +240,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: true, // hardcoded for now - // enabled: resourceData.enabled ?? true, + enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"] || @@ -493,8 +492,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: true, // hardcoded for now - // enabled: resourceData.enabled ?? true, + enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, aliasAddress: aliasAddress, disableIcmp: diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 5495a2d8e..cc9865533 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -470,7 +470,7 @@ export const PrivateResourceSchema = z // proxyPort: z.int().positive().optional(), "destination-port": z.int().positive().optional(), destination: z.string().min(1).optional(), - // enabled: z.boolean().default(true), + enabled: z.boolean().default(true), "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), From 5ef068c8dcce00d239b162a3c2da458d7c59af24 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:31:37 -0400 Subject: [PATCH 08/18] Set the resource from the site --- server/lib/blueprints/privateResources.ts | 10 +++-- server/lib/blueprints/publicResources.ts | 47 ++++++++++++++++------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 7d5d9a874..45b76f9f0 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -198,17 +198,19 @@ export async function updatePrivateResources( } } + let resourceStatusFromSite: "approved" | "pending" = "approved"; if (siteId && allSites.length === 0) { // only add if there are not provided sites // Use the provided siteId directly, but verify it belongs to the org const [siteSingle] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, status: sites.status }) .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); if (siteSingle) { allSites.push(siteSingle); } + resourceStatusFromSite = siteSingle.status ?? "approved"; } if (allSites.length === 0) { @@ -259,7 +261,8 @@ export async function updatePrivateResources( pamMode: resourceData["auth-daemon"]?.pam || "passthrough", authDaemonMode: resourceData["auth-daemon"]?.mode || "native", - authDaemonPort: resourceData["auth-daemon"]?.port || 22123 + authDaemonPort: resourceData["auth-daemon"]?.port || 22123, + status: resourceStatusFromSite }) .where( eq( @@ -512,7 +515,8 @@ export async function updatePrivateResources( pamMode: resourceData["auth-daemon"]?.pam || "passthrough", authDaemonMode: resourceData["auth-daemon"]?.mode || "native", - authDaemonPort: resourceData["auth-daemon"]?.port || 22123 + authDaemonPort: resourceData["auth-daemon"]?.port || 22123, + status: resourceStatusFromSite }) .returning(); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index df3b3799e..66ab130a8 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -25,6 +25,7 @@ import { rolePolicies, roleResources, roles, + Site, sites, Target, TargetHealthCheck, @@ -74,19 +75,40 @@ export async function updatePublicResources( )) { const targetsToUpdate: Target[] = []; const healthchecksToUpdate: TargetHealthCheck[] = []; + let resource: Resource; + let resourceStatusFromSite: "approved" | "pending" = "approved"; + let providedSite: Partial | 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 resourceId: number, targetData: TargetData ) { const targetSiteId = targetData.site; - let site; + let site: Partial | undefined; if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId, type: sites.type }) + .select({ + siteId: sites.siteId, + type: sites.type, + status: sites.status + }) .from(sites) .where( and( @@ -95,15 +117,9 @@ export async function updatePublicResources( ) ) .limit(1); - } else if (siteId) { + } else if (siteId && providedSite) { // Use the provided siteId directly, but verify it belongs to the org - [site] = await trx - .select({ siteId: sites.siteId, type: sites.type }) - .from(sites) - .where( - and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) - ) - .limit(1); + site = providedSite; } else { throw new Error(`Target site is required`); } @@ -139,7 +155,7 @@ export async function updatePublicResources( .insert(targets) .values({ resourceId: resourceId, - siteId: site.siteId, + siteId: site.siteId!, ip: targetData.hostname, mode: resourceData.mode as Target["mode"], method: targetData.method, @@ -172,7 +188,7 @@ export async function updatePublicResources( .insert(targetHealthCheck) .values({ name: `${targetData.hostname}:${targetData.port}`, - siteId: site.siteId, + siteId: site.siteId!, targetId: newTarget.targetId, orgId: orgId, hcEnabled: healthcheckData?.enabled || false, @@ -406,7 +422,8 @@ export async function updatePublicResources( ? (resourceData["proxy-protocol-version"] ?? 1) : 1, - resourcePolicyId: sharedPolicy.resourcePolicyId + resourcePolicyId: sharedPolicy.resourcePolicyId, + status: resourceStatusFromSite }) .where( eq( @@ -590,7 +607,8 @@ export async function updatePublicResources( authDaemonPort: resourceData["auth-daemon"]?.port || 22123, resourcePolicyId: null, - defaultResourcePolicyId: inlinePolicyId + defaultResourcePolicyId: inlinePolicyId, + status: resourceStatusFromSite }) .where( eq( @@ -1131,6 +1149,7 @@ export async function updatePublicResources( .values({ orgId, niceId: resourceNiceId, + status: resourceStatusFromSite, name: resourceData.name || "Unnamed Resource", mode: resourceData.mode, proxyPort: ["http", "ssh", "rdp", "vnc"].includes( From e1bc0b7efd26d4d6a7e74aed3051891dc4855bf6 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 10 Jul 2026 15:36:05 -0400 Subject: [PATCH 09/18] Make sure the enabled gets set to false --- server/lib/blueprints/privateResources.ts | 11 +++++++++-- server/lib/blueprints/publicResources.ts | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 45b76f9f0..44901e8c8 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -219,6 +219,13 @@ export async function updatePrivateResources( ); } + const resourceEnabled = + resourceData.enabled == undefined || resourceData.enabled == null + ? true + : resourceStatusFromSite === "pending" + ? false + : resourceData.enabled; + if (existingResource) { let domainInfo: | { subdomain: string | null; domainId: string } @@ -242,7 +249,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: resourceData.enabled ?? true, + enabled: resourceEnabled, alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"] || @@ -495,7 +502,7 @@ export async function updatePrivateResources( scheme: resourceData.scheme, destination: resourceData.destination, destinationPort: resourceData["destination-port"], - enabled: resourceData.enabled ?? true, + enabled: resourceEnabled, alias: resourceData.alias || null, aliasAddress: aliasAddress, disableIcmp: diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 66ab130a8..997d56e2a 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -246,7 +246,10 @@ export async function updatePublicResources( const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true - : resourceData.enabled; + : resourceStatusFromSite === "pending" + ? false + : resourceData.enabled; + const resourceSsl = resourceData.ssl == undefined || resourceData.ssl == null ? true From 39c35fa539dc59d969743e278eaa02e146e2c67d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 17:05:45 -0400 Subject: [PATCH 10/18] reorganize components nad hooks for consistency --- .../resources/private/[niceId]/access/page.tsx | 2 +- .../resources/private/[niceId]/cidr/page.tsx | 12 ++++++------ .../resources/private/[niceId]/general/page.tsx | 2 +- .../resources/private/[niceId]/host/page.tsx | 12 ++++++------ .../resources/private/[niceId]/http/page.tsx | 10 +++++----- .../resources/private/[niceId]/ssh/page.tsx | 10 +++++----- .../settings/resources/private/create/page.tsx | 16 ++++++++++------ .../PrivateResourceAccessFields.tsx | 0 .../PrivateResourceDestinationFields.tsx | 0 .../PrivateResourceHttpFields.tsx | 0 .../PrivateResourceMultiSiteRoutingHelp.tsx | 0 .../PrivateResourcePortRanges.tsx | 0 .../PrivateResourceSitesField.tsx | 2 +- .../PrivateResourceSshFields.tsx | 6 +++--- .../private => hooks}/useSaveSiteResource.ts | 0 .../private => lib}/formControlUtils.ts | 0 .../private => lib}/privateResourceUtils.ts | 0 17 files changed, 38 insertions(+), 34 deletions(-) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceAccessFields.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceDestinationFields.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceHttpFields.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceMultiSiteRoutingHelp.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourcePortRanges.tsx (100%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceSitesField.tsx (98%) rename src/{app/[orgId]/settings/resources/private => components}/PrivateResourceSshFields.tsx (97%) rename src/{app/[orgId]/settings/resources/private => hooks}/useSaveSiteResource.ts (100%) rename src/{app/[orgId]/settings/resources/private => lib}/formControlUtils.ts (100%) rename src/{app/[orgId]/settings/resources/private => lib}/privateResourceUtils.ts (100%) diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx index e09366329..70a6504c4 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx @@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useActionState, useEffect } from "react"; import { useForm } from "react-hook-form"; -import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields"; +import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields"; export default function PrivateResourceAccessPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx index 8064ec883..16e603c01 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx @@ -20,12 +20,12 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; -import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields"; -import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; -import { asAnyControl, asAnySetValue } from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; +import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceCidrPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx index d14b9c3d5..8dbdb3809 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -30,7 +30,7 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; export default function PrivateResourceGeneralPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx index 73d4207cd..bb9734ad0 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx @@ -20,16 +20,16 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; -import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields"; -import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, asAnySetValue, asAnyWatch -} from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceHostPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index 2bbd776e5..c1263c162 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -22,15 +22,15 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; -import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, asAnySetValue, asAnyWatch -} from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceHttpPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx index 6de4082b6..9a739a073 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -26,15 +26,15 @@ import { useTranslations } from "next-intl"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSshFields } from "../../PrivateResourceSshFields"; -import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; +import type { Selectedsite } from "@app/components/site-selector"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, asAnySetValue, asAnyWatch -} from "../../formControlUtils"; -import { useSaveSiteResource } from "../../useSaveSiteResource"; -import type { Selectedsite } from "@app/components/site-selector"; +} from "@app/lib/formControlUtils"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; export default function PrivateResourceSshPage() { const t = useTranslations(); diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 49b40e3c9..7e6144172 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { PrivateResourceSitesField } from "../PrivateResourceSitesField"; -import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields"; -import { PrivateResourceSshFields } from "../PrivateResourceSshFields"; -import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields"; +import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; +import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; import { PrivateResourceAliasField, PrivateResourceCidrDestinationField, PrivateResourceHostDestinationFields -} from "../PrivateResourceDestinationFields"; -import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils"; +} from "@app/components/PrivateResourceDestinationFields"; +import { + asAnyControl, + asAnySetValue, + asAnyWatch +} from "@app/lib/formControlUtils"; export default function CreatePrivateResourcePage() { const params = useParams(); diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceAccessFields.tsx b/src/components/PrivateResourceAccessFields.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceAccessFields.tsx rename to src/components/PrivateResourceAccessFields.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceDestinationFields.tsx b/src/components/PrivateResourceDestinationFields.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceDestinationFields.tsx rename to src/components/PrivateResourceDestinationFields.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx b/src/components/PrivateResourceHttpFields.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx rename to src/components/PrivateResourceHttpFields.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceMultiSiteRoutingHelp.tsx b/src/components/PrivateResourceMultiSiteRoutingHelp.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourceMultiSiteRoutingHelp.tsx rename to src/components/PrivateResourceMultiSiteRoutingHelp.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourcePortRanges.tsx b/src/components/PrivateResourcePortRanges.tsx similarity index 100% rename from src/app/[orgId]/settings/resources/private/PrivateResourcePortRanges.tsx rename to src/components/PrivateResourcePortRanges.tsx diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx b/src/components/PrivateResourceSitesField.tsx similarity index 98% rename from src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx rename to src/components/PrivateResourceSitesField.tsx index 1490db5af..0b0b930cb 100644 --- a/src/app/[orgId]/settings/resources/private/PrivateResourceSitesField.tsx +++ b/src/components/PrivateResourceSitesField.tsx @@ -23,7 +23,7 @@ import { import { ChevronsUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; import type { Control, FieldPath, FieldValues } from "react-hook-form"; -import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp"; +import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp"; type PrivateResourceSitesFieldProps = { control: Control; diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx b/src/components/PrivateResourceSshFields.tsx similarity index 97% rename from src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx rename to src/components/PrivateResourceSshFields.tsx index e61ed2292..edfdde5de 100644 --- a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx +++ b/src/components/PrivateResourceSshFields.tsx @@ -9,10 +9,10 @@ import { } from "@app/components/Settings"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; -import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields"; -import { PrivateResourceSitesField } from "./PrivateResourceSitesField"; -import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils"; +import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { inferSshPamMode } from "@app/lib/privateResourceForm"; +import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils"; import { FormControl, FormField, diff --git a/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts b/src/hooks/useSaveSiteResource.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts rename to src/hooks/useSaveSiteResource.ts diff --git a/src/app/[orgId]/settings/resources/private/formControlUtils.ts b/src/lib/formControlUtils.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/formControlUtils.ts rename to src/lib/formControlUtils.ts diff --git a/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts b/src/lib/privateResourceUtils.ts similarity index 100% rename from src/app/[orgId]/settings/resources/private/privateResourceUtils.ts rename to src/lib/privateResourceUtils.ts From 7d475f5e9186886c453975a6325aede1d9fe90e9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 17:18:33 -0400 Subject: [PATCH 11/18] filter out pending resources --- src/app/[orgId]/settings/resources/private/page.tsx | 1 + src/app/[orgId]/settings/resources/public/page.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 8206d14e8..348340aa9 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -27,6 +27,7 @@ export default async function ClientResourcesPage( const params = await props.params; const t = await getTranslations(); const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "approved"); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { diff --git a/src/app/[orgId]/settings/resources/public/page.tsx b/src/app/[orgId]/settings/resources/public/page.tsx index e0b9f6210..f8cc85dae 100644 --- a/src/app/[orgId]/settings/resources/public/page.tsx +++ b/src/app/[orgId]/settings/resources/public/page.tsx @@ -38,6 +38,7 @@ export default async function ProxyResourcesPage( const params = await props.params; const t = await getTranslations(); const searchParams = new URLSearchParams(await props.searchParams); + searchParams.set("status", "approved"); let resources: ListResourcesResponse["resources"] = []; let pagination: ListResourcesResponse["pagination"] = { From 34b18bdb53396807e0544dcb56249da75eef6cff Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 17:24:10 -0400 Subject: [PATCH 12/18] only show approved resources in launcher --- .../launcher/launcherResourceAccess.ts | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index ec4500262..ce94a7a50 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached( .where( and( eq(userResources.userId, userId), - eq(resources.orgId, orgId) + eq(resources.orgId, orgId), + eq(resources.status, "approved") ) ), userRoleIds.length > 0 @@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached( .where( and( inArray(roleResources.roleId, userRoleIds), - eq(resources.orgId, orgId) + eq(resources.orgId, orgId), + eq(resources.status, "approved") ) ) : Promise.resolve([]), @@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached( eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) ) .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 ? db @@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached( .where( and( inArray(rolePolicies.roleId, userRoleIds), - eq(resources.orgId, orgId) + eq(resources.orgId, orgId), + eq(resources.status, "approved") ) ) : Promise.resolve([]), db .select({ siteResourceId: userSiteResources.siteResourceId }) .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 ? db .select({ siteResourceId: roleSiteResources.siteResourceId }) .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([]) ]); @@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch( inArray(resources.resourceId, resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), + eq(resources.status, "approved"), textMatch ) ); @@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch( inArray(siteResources.siteResourceId, siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), + eq(siteResources.status, "approved"), textMatch ) ); @@ -503,7 +538,8 @@ async function listSiteGroups( const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; if (searchPublic) { publicConditions.push(searchPublic); @@ -558,7 +594,8 @@ async function listSiteGroups( const siteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; if (searchSite) { siteConditions.push(searchSite); @@ -621,7 +658,8 @@ async function listSiteGroups( const noSitePublicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; if (searchPublic) { noSitePublicConditions.push(searchPublic); @@ -655,7 +693,8 @@ async function listSiteGroups( const noSiteSiteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; if (searchSite) { noSiteSiteConditions.push(searchSite); @@ -746,7 +785,8 @@ async function listLabelGroups( const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; const searchPublic = buildSearchConditionForPublic(query.query); if (searchPublic) { @@ -810,7 +850,8 @@ async function listLabelGroups( const siteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; const searchSite = buildSearchConditionForSiteResource(query.query); if (searchSite) { @@ -997,6 +1038,7 @@ async function mapPublicResources( inArray(resources.resourceId, resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), + eq(resources.status, "approved"), siteIdFilter != null ? eq(sites.siteId, siteIdFilter) : undefined @@ -1088,6 +1130,7 @@ async function mapSiteResources( inArray(siteResources.siteResourceId, siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), + eq(siteResources.status, "approved"), siteIdFilter != null ? eq(sites.siteId, siteIdFilter) : undefined @@ -1382,7 +1425,8 @@ async function collectAccessibleSites( const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), - eq(resources.enabled, true) + eq(resources.enabled, true), + eq(resources.status, "approved") ]; if (siteNameSearch) { publicConditions.push(siteNameSearch); @@ -1422,7 +1466,8 @@ async function collectAccessibleSites( const siteConditions = [ inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), - eq(siteResources.enabled, true) + eq(siteResources.enabled, true), + eq(siteResources.status, "approved") ]; if (siteNameSearch) { siteConditions.push(siteNameSearch); @@ -1476,6 +1521,7 @@ async function collectAccessibleLabels( inArray(resources.resourceId, accessible.resourceIds), eq(resources.orgId, orgId), eq(resources.enabled, true), + eq(resources.status, "approved"), eq(labels.orgId, orgId) ]; if (labelNameSearch) { @@ -1511,6 +1557,7 @@ async function collectAccessibleLabels( inArray(siteResources.siteResourceId, accessible.siteResourceIds), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), + eq(siteResources.status, "approved"), eq(labels.orgId, orgId) ]; if (labelNameSearch) { From 591cb9cdc1eaf27dcaed424904f8204bcf058318 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 10 Jul 2026 18:05:16 -0400 Subject: [PATCH 13/18] dont use strict query params in list alises --- server/routers/resource/listUserResourceAliases.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 100b1624d..98e73ad85 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -57,7 +57,7 @@ const listUserResourceAliasesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserResourceAliasesQuerySchema = z.strictObject({ +const listUserResourceAliasesQuerySchema = z.object({ pageSize: z.coerce .number() .int() From 933ca71c16fa5c94856cf586c1b7aa38b3a1481b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Jul 2026 17:16:08 -0400 Subject: [PATCH 14/18] add approve site endpoint --- server/routers/external.ts | 8 + server/routers/integration.ts | 8 + server/routers/site/approveSite.ts | 251 +++++++++++++++++++++++++++ server/routers/site/index.ts | 1 + server/routers/site/updateSite.ts | 1 - src/components/PendingSitesTable.tsx | 2 +- 6 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 server/routers/site/approveSite.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 9e4c5b3f1..9b9c48293 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -248,6 +248,14 @@ authenticated.post( site.updateSite ); +authenticated.post( + "/site/:siteId/approve", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), + site.approveSite +); + authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 74ea20078..f6ab6bf6c 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -138,6 +138,14 @@ authenticated.post( logActionAudit(ActionsEnum.updateSite), site.updateSite ); + +authenticated.post( + "/site/:siteId/approve", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), + site.approveSite +); authenticated.post( "/org/:orgId/reset-bandwidth", verifyApiKeyOrgAccess, diff --git a/server/routers/site/approveSite.ts b/server/routers/site/approveSite.ts new file mode 100644 index 000000000..19e2795ad --- /dev/null +++ b/server/routers/site/approveSite.ts @@ -0,0 +1,251 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + resources, + siteNetworks, + siteResources, + sites, + type Site, + type SiteResource +} from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + getResourceIdsForSite, + getSiteResourceIdsForSite +} from "@server/lib/deleteSiteAssociatedResources"; +import { + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "@server/lib/rebuildClientAssociations"; + +const approveSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/approve", + description: + "Approve a pending site and approve (and enable when needed) its associated resources.", + tags: [OpenAPITags.Site], + request: { + params: approveSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +type SiteResourceEnableSideEffect = { + existing: SiteResource; + updated: SiteResource; + siteIds: number[]; +}; + +export async function approveSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = approveSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!existingSite.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with ID ${siteId} has no organization` + ) + ); + } + + const orgId = existingSite.orgId; + let updatedSite: Site | undefined; + const siteResourceEnableSideEffects: SiteResourceEnableSideEffect[] = + []; + + await db.transaction(async (trx) => { + [updatedSite] = await trx + .update(sites) + .set({ status: "approved" }) + .where(eq(sites.siteId, siteId)) + .returning(); + + const resourceIds = await getResourceIdsForSite(siteId, trx); + const siteResourceIds = await getSiteResourceIdsForSite( + siteId, + orgId, + trx + ); + + if (resourceIds.length > 0) { + const pendingDisabledResources = await trx + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.status, "pending"), + eq(resources.enabled, false) + ) + ); + + await trx + .update(resources) + .set({ status: "approved" }) + .where(inArray(resources.resourceId, resourceIds)); + + if (pendingDisabledResources.length > 0) { + await trx + .update(resources) + .set({ enabled: true }) + .where( + inArray( + resources.resourceId, + pendingDisabledResources.map( + (r) => r.resourceId + ) + ) + ); + } + } + + if (siteResourceIds.length > 0) { + const existingSiteResources = await trx + .select() + .from(siteResources) + .where( + inArray(siteResources.siteResourceId, siteResourceIds) + ); + + const pendingDisabledSiteResources = + existingSiteResources.filter( + (sr) => sr.status === "pending" && !sr.enabled + ); + + await trx + .update(siteResources) + .set({ status: "approved" }) + .where( + inArray(siteResources.siteResourceId, siteResourceIds) + ); + + if (pendingDisabledSiteResources.length > 0) { + const enableIds = pendingDisabledSiteResources.map( + (sr) => sr.siteResourceId + ); + + const updatedEnabledSiteResources = await trx + .update(siteResources) + .set({ enabled: true }) + .where(inArray(siteResources.siteResourceId, enableIds)) + .returning(); + + for (const updated of updatedEnabledSiteResources) { + const existing = pendingDisabledSiteResources.find( + (sr) => sr.siteResourceId === updated.siteResourceId + ); + if (!existing || !updated.networkId) { + continue; + } + + const networkSites = await trx + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where( + eq(siteNetworks.networkId, updated.networkId) + ); + + siteResourceEnableSideEffects.push({ + existing, + updated, + siteIds: networkSites.map((s) => s.siteId) + }); + } + } + } + }); + + for (const sideEffect of siteResourceEnableSideEffects) { + rebuildClientAssociationsFromSiteResource(sideEffect.updated) + .then(() => + waitForSiteResourceRebuildIdle( + sideEffect.updated.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + sideEffect.existing, + sideEffect.updated, + sideEffect.siteIds, + sideEffect.siteIds + ) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${sideEffect.updated.siteResourceId} after site approval:`, + e + ); + }); + } + + return response(res, { + data: updatedSite ?? null, + success: true, + error: false, + message: "Site approved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 292c57971..43b778f62 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -3,6 +3,7 @@ export * from "./getStatusHistory"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; +export * from "./approveSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index c6851a3c3..42ab8b246 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -21,7 +21,6 @@ const updateSiteBodySchema = z name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), - status: z.enum(["pending", "approved"]).optional(), autoUpdateEnabled: z.boolean().optional(), autoUpdateOverrideOrg: z.boolean().optional() }) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 8cdfc424d..e2af5ee4e 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -111,7 +111,7 @@ export default function PendingSitesTable({ async function approveSite(siteId: number) { setApprovingIds((prev) => new Set(prev).add(siteId)); try { - await api.post(`/site/${siteId}`, { status: "approved" }); + await api.post(`/site/${siteId}/approve`); toast({ title: t("success"), description: t("siteApproveSuccess"), From a8f3f710213ea16400cafd4d46410d2719b1d420 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 12 Jul 2026 17:48:48 -0400 Subject: [PATCH 15/18] add reject endpoint --- messages/en-US.json | 10 +- server/auth/actions.ts | 1 + server/lib/deleteResource.ts | 8 +- server/lib/deleteSiteAssociatedResources.ts | 85 +++++++++- server/routers/external.ts | 12 +- server/routers/integration.ts | 7 - server/routers/site/deleteSite.ts | 18 +- server/routers/site/index.ts | 1 + server/routers/site/rejectSite.ts | 178 ++++++++++++++++++++ src/components/PendingSitesTable.tsx | 26 +-- src/components/PermissionsSelectBox.tsx | 4 +- 11 files changed, 309 insertions(+), 41 deletions(-) create mode 100644 server/routers/site/rejectSite.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2a2c83b18..549865dcf 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -449,8 +449,14 @@ "provisioningManage": "Provisioning", "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", "pendingSites": "Pending Sites", - "siteApproveSuccess": "Site approved successfully", + "siteApproveSuccess": "Site and associated resources approved successfully", "siteApproveError": "Error approving site", + "siteReject": "Reject Site", + "siteQuestionReject": "Are you sure you want to reject this site?", + "siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.", + "siteConfirmReject": "Confirm Reject Site", + "siteRejectSuccess": "Site rejected successfully", + "siteRejectError": "Error rejecting site", "provisioningKeys": "Provisioning Keys", "searchProvisioningKeys": "Search provisioning keys...", "provisioningKeysAdd": "Generate Provisioning Key", @@ -1420,6 +1426,8 @@ "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", "actionUpdateSite": "Update Site", + "actionApproveSite": "Approve Site", + "actionRejectSite": "Reject Site", "actionResetSiteBandwidth": "Reset Organization Bandwidth", "actionListSiteRoles": "List Allowed Site Roles", "actionCreateResource": "Create Resource", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 50c98c528..741d7a057 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -21,6 +21,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + updateSiteApprovals = "updateSiteApprovals", restartSite = "restartSite", resetSiteBandwidth = "resetSiteBandwidth", reGenerateSecret = "reGenerateSecret", diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts index b2ffa0f0f..3f33c7400 100644 --- a/server/lib/deleteResource.ts +++ b/server/lib/deleteResource.ts @@ -14,8 +14,6 @@ import { } from "@server/db"; import logger from "@server/logger"; import { removeTargets } from "@server/routers/newt/targets"; -import createHttpError from "http-errors"; -import HttpCode from "@server/types/HttpCode"; export type DeleteResourceResult = { deletedResource: Resource; @@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects( .limit(1); if (!site) { - throw createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${target.siteId} not found` + logger.debug( + `Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal` ); + continue; } if (site.pubKey && site.type === "newt") { diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts index 61da82f58..e69585d1a 100644 --- a/server/lib/deleteSiteAssociatedResources.ts +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -1,6 +1,7 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { db, + resources, siteNetworks, siteResources, targets, @@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit( return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE; } +export async function getPendingResourceIdsForSite( + siteId: number, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getResourceIdsForSite(siteId, trx); + if (resourceIds.length === 0) { + return []; + } + + const rows = await trx + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.status, "pending") + ) + ); + + return rows.map((row) => row.resourceId); +} + +export async function getPendingSiteResourceIdsForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); + if (siteResourceIds.length === 0) { + return []; + } + + const rows = await trx + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .where( + and( + inArray(siteResources.siteResourceId, siteResourceIds), + eq(siteResources.status, "pending") + ) + ); + + return rows.map((row) => row.siteResourceId); +} + +export async function getPendingAssociatedResourceCountForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const [resourceIds, siteResourceIds] = await Promise.all([ + getPendingResourceIdsForSite(siteId, trx), + getPendingSiteResourceIdsForSite(siteId, orgId, trx) + ]); + + return resourceIds.length + siteResourceIds.length; +} + export async function deleteAssociatedResourcesForSite( siteId: number, orgId: string, @@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite( const resourceIds = await getResourceIdsForSite(siteId, trx); const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); - const [resources, siteResourcesDeleted] = await Promise.all([ + const [deletedResources, siteResourcesDeleted] = await Promise.all([ performDeleteResources(resourceIds, trx), performDeleteSiteResources(siteResourceIds, trx) ]); - return { resources, siteResources: siteResourcesDeleted }; + return { resources: deletedResources, siteResources: siteResourcesDeleted }; +} + +export async function deletePendingAssociatedResourcesForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getPendingResourceIdsForSite(siteId, trx); + const siteResourceIds = await getPendingSiteResourceIdsForSite( + siteId, + orgId, + trx + ); + + const [deletedResources, siteResourcesDeleted] = await Promise.all([ + performDeleteResources(resourceIds, trx), + performDeleteSiteResources(siteResourceIds, trx) + ]); + + return { resources: deletedResources, siteResources: siteResourcesDeleted }; } export async function runDeleteSiteAssociatedResourcesSideEffects( diff --git a/server/routers/external.ts b/server/routers/external.ts index 9b9c48293..62ca15316 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -251,11 +251,19 @@ authenticated.post( authenticated.post( "/site/:siteId/approve", verifySiteAccess, - verifyUserHasAction(ActionsEnum.updateSite), - logActionAudit(ActionsEnum.updateSite), + verifyUserHasAction(ActionsEnum.updateSiteApprovals), + logActionAudit(ActionsEnum.updateSiteApprovals), site.approveSite ); +authenticated.post( + "/site/:siteId/reject", + verifySiteAccess, + verifyUserHasAction(ActionsEnum.updateSiteApprovals), + logActionAudit(ActionsEnum.updateSiteApprovals), + site.rejectSite +); + authenticated.delete( "/site/:siteId", verifySiteAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f6ab6bf6c..3abd664eb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -139,13 +139,6 @@ authenticated.post( site.updateSite ); -authenticated.post( - "/site/:siteId/approve", - verifyApiKeySiteAccess, - verifyApiKeyHasAction(ActionsEnum.updateSite), - logActionAudit(ActionsEnum.updateSite), - site.approveSite -); authenticated.post( "/org/:orgId/reset-bandwidth", verifyApiKeyOrgAccess, diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 602032ffb..2126b69d8 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -159,15 +159,21 @@ export async function deleteSite( siteResources: [] }; - await db.transaction(async (trx) => { - if (deleteResources) { + if (deleteResources) { + await db.transaction(async (trx) => { resourceSideEffects = await deleteAssociatedResourcesForSite( siteId, site.orgId, trx ); - } + }); + await runDeleteSiteAssociatedResourcesSideEffects( + resourceSideEffects + ); + } + + await db.transaction(async (trx) => { if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); @@ -180,12 +186,6 @@ export async function deleteSite( await usageService.add(site.orgId, LimitId.SITES, -1, trx); }); - if (deleteResources) { - await runDeleteSiteAssociatedResourcesSideEffects( - resourceSideEffects - ); - } - if (deletedNewt) { const payload = { type: `newt/wg/terminate`, diff --git a/server/routers/site/index.ts b/server/routers/site/index.ts index 43b778f62..c0c399789 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -4,6 +4,7 @@ export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; export * from "./approveSite"; +export * from "./rejectSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; diff --git a/server/routers/site/rejectSite.ts b/server/routers/site/rejectSite.ts new file mode 100644 index 000000000..037879e82 --- /dev/null +++ b/server/routers/site/rejectSite.ts @@ -0,0 +1,178 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { deletePeer } from "../gerbil/peers"; +import { fromError } from "zod-validation-error"; +import { sendToClient } from "#dynamic/routers/ws"; +import { OpenAPITags, registry } from "@server/openApi"; +import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; +import { usageService } from "@server/lib/billing/usageService"; +import { LimitId } from "@server/lib/billing"; +import { + deletePendingAssociatedResourcesForSite, + exceedsSiteAssociatedResourceDeleteLimit, + getPendingAssociatedResourceCountForSite, + runDeleteSiteAssociatedResourcesSideEffects, + MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE, + type DeleteSiteAssociatedResourcesSideEffects +} from "@server/lib/deleteSiteAssociatedResources"; + +const rejectSiteParamsSchema = z.strictObject({ + siteId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/site/{siteId}/reject", + description: + "Reject a pending site by deleting it and any associated resources that are still pending.", + tags: [OpenAPITags.Site], + request: { + params: rejectSiteParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function rejectSite( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = rejectSiteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteId } = parsedParams.data; + + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!site.orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Site with ID ${siteId} has no organization` + ) + ); + } + + const pendingAssociatedResourceCount = + await getPendingAssociatedResourceCountForSite(siteId, site.orgId); + + if ( + exceedsSiteAssociatedResourceDeleteLimit( + pendingAssociatedResourceCount + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot reject site and associated pending resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} pending resources` + ) + ); + } + + const [deletedNewt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + + let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = { + resources: [], + siteResources: [] + }; + + await db.transaction(async (trx) => { + resourceSideEffects = await deletePendingAssociatedResourcesForSite( + siteId, + site.orgId, + trx + ); + }); + + await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects); + + await db.transaction(async (trx) => { + if (site.type == "wireguard") { + if (site.pubKey) { + await deletePeer(site.exitNodeId!, site.pubKey); + } + } else if (site.type == "newt") { + await cleanupSiteAssociations(site, trx); + } + + await trx.delete(sites).where(eq(sites.siteId, siteId)); + await usageService.add(site.orgId, LimitId.SITES, -1, trx); + }); + + if (deletedNewt) { + const payload = { + type: `newt/wg/terminate`, + data: {} + }; + sendToClient(deletedNewt.newtId, payload).catch((error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); + }); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Site rejected successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index e2af5ee4e..5981bf387 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -65,7 +65,7 @@ export default function PendingSitesTable({ const [isRefreshing, startTransition] = useTransition(); const [approvingIds, setApprovingIds] = useState>(new Set()); const [rejectingIds, setRejectingIds] = useState>(new Set()); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const api = createApiClient(useEnvContext()); @@ -136,20 +136,20 @@ export default function PendingSitesTable({ async function rejectSite(siteId: number) { setRejectingIds((prev) => new Set(prev).add(siteId)); try { - await api.delete(`/site/${siteId}`); + await api.post(`/site/${siteId}/reject`); toast({ title: t("success"), - description: t("siteDeleted"), + description: t("siteRejectSuccess"), variant: "default" }); - setIsDeleteModalOpen(false); + setIsRejectModalOpen(false); setSelectedSite(null); router.refresh(); } catch (e) { toast({ variant: "destructive", - title: t("siteErrorDelete"), - description: formatAxiosError(e, t("siteErrorDelete")) + title: t("siteRejectError"), + description: formatAxiosError(e, t("siteRejectError")) }); } finally { setRejectingIds((prev) => { @@ -445,7 +445,7 @@ export default function PendingSitesTable({ disabled={isApproving || isRejecting} onClick={() => { setSelectedSite(siteRow); - setIsDeleteModalOpen(true); + setIsRejectModalOpen(true); }} > @@ -493,23 +493,23 @@ export default function PendingSitesTable({ <> {selectedSite && ( { - setIsDeleteModalOpen(val); + setIsRejectModalOpen(val); if (!val) { setSelectedSite(null); } }} dialog={
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

+

{t("siteQuestionReject")}

+

{t("siteMessageReject")}

} - buttonText={t("siteConfirmDelete")} + buttonText={t("siteConfirmReject")} onConfirm={async () => rejectSite(selectedSite.id)} string={selectedSite.name} - title={t("siteDelete")} + title={t("siteReject")} /> )} Date: Sun, 12 Jul 2026 17:53:57 -0400 Subject: [PATCH 16/18] add resources overview column to pending sites table --- src/components/PendingSitesTable.tsx | 74 ++++++++++++++++++++++++ src/components/SiteResourcesOverview.tsx | 35 ++++++----- 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/components/PendingSitesTable.tsx b/src/components/PendingSitesTable.tsx index 5981bf387..9e0fa0ad8 100644 --- a/src/components/PendingSitesTable.tsx +++ b/src/components/PendingSitesTable.tsx @@ -1,6 +1,16 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { @@ -24,6 +34,7 @@ import { ArrowUp10Icon, ArrowUpRight, Check, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal, X @@ -67,6 +78,8 @@ export default function PendingSitesTable({ const [rejectingIds, setRejectingIds] = useState>(new Set()); const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [resourcesDialogSite, setResourcesDialogSite] = + useState(null); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -342,6 +355,29 @@ export default function PendingSitesTable({ } } }, + { + id: "resources", + accessorKey: "resourceCount", + friendlyName: t("resources"), + header: () => {t("resources")}, + cell: ({ row }) => { + const siteRow = row.original; + return ( + + ); + } + }, { accessorKey: "exitNode", friendlyName: t("exitNode"), @@ -491,6 +527,44 @@ export default function PendingSitesTable({ return ( <> + { + if (!open) setResourcesDialogSite(null); + }} + > + + + {t("siteResourcesTab")} + + {t("siteResourcesDialogDescription")} + + + + {resourcesDialogSite != null && ( + + )} + + + + + + + {selectedSite && ( - - {viewAllLabel} - + {viewAllHref && viewAllLabel ? ( + + {viewAllLabel} + + ) : null} ); @@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = { initialPrivateForbidden: boolean; /** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */ orgIdOverride?: string; + /** When false, hides links to the org resources tables filtered by this site. */ + showViewAllLinks?: boolean; }; export default function SiteResourcesOverview({ @@ -327,7 +331,8 @@ export default function SiteResourcesOverview({ initialPrivateData, initialPublicForbidden, initialPrivateForbidden, - orgIdOverride + orgIdOverride, + showViewAllLinks = true }: SiteResourcesOverviewProps) { const t = useTranslations(); const params = useParams<{ orgId: string }>(); @@ -467,8 +472,10 @@ export default function SiteResourcesOverview({ key="public" title={t("siteResourcesSectionPublic")} description={t("siteResourcesSectionPublicDescription")} - viewAllHref={publicViewAllHref} - viewAllLabel={t("siteResourcesViewAllPublic")} + viewAllHref={showViewAllLinks ? publicViewAllHref : undefined} + viewAllLabel={ + showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined + } emptyLabel={t("siteResourcesEmptyPublic")} isForbidden={publicForbidden} isFetching={publicQuery.isFetching} @@ -484,8 +491,10 @@ export default function SiteResourcesOverview({ key="private" title={t("siteResourcesSectionPrivate")} description={t("siteResourcesSectionPrivateDescription")} - viewAllHref={privateViewAllHref} - viewAllLabel={t("siteResourcesViewAllPrivate")} + viewAllHref={showViewAllLinks ? privateViewAllHref : undefined} + viewAllLabel={ + showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined + } emptyLabel={t("siteResourcesEmptyPrivate")} isForbidden={privateForbidden} isFetching={privateQuery.isFetching} From d1b0bbb6d98f6721bdcd8bd52721e5acdd2b1fb1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 15 Jul 2026 16:05:53 -0400 Subject: [PATCH 17/18] update translations --- messages/en-US.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 549865dcf..9e2175d53 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -472,12 +472,12 @@ "provisioningKeysSave": "Save the provisioning key", "provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.", "provisioningKeysErrorCreate": "Error creating provisioning key", - "provisioningKeysList": "New provisioning key", - "provisioningKeysMaxBatchSize": "Max batch size", + "provisioningKeysList": "New Provisioning Key", + "provisioningKeysMaxBatchSize": "Max Batch Size", "provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)", "provisioningKeysMaxBatchUnlimited": "Unlimited", "provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).", - "provisioningKeysValidUntil": "Valid until", + "provisioningKeysValidUntil": "Valid Until", "provisioningKeysValidUntilHint": "Leave empty for no expiration.", "provisioningKeysValidUntilInvalid": "Enter a valid date and time.", "provisioningKeysNumUsed": "Times used", @@ -486,7 +486,7 @@ "provisioningKeysNeverUsed": "Never", "provisioningKeysEdit": "Edit Provisioning Key", "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", - "provisioningKeysApproveNewSites": "Approve new sites", + "provisioningKeysApproveNewSites": "Approve New Sites", "provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.", "provisioningKeysUpdateError": "Error updating provisioning key", "provisioningKeysUpdated": "Provisioning key updated", From d4c602cf917bae495d29a5120515dd8dd6e44d99 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 15 Jul 2026 17:21:19 -0400 Subject: [PATCH 18/18] Update the counts when deleting resources --- server/routers/site/deleteSite.ts | 18 ++++++++++++++++++ server/routers/site/rejectSite.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 2126b69d8..6cf584d18 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -166,6 +166,24 @@ export async function deleteSite( site.orgId, trx ); + + if (resourceSideEffects.resources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PUBLIC_RESOURCES, + -resourceSideEffects.resources.length, + trx + ); + } + + if (resourceSideEffects.siteResources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PRIVATE_RESOURCES, + -resourceSideEffects.siteResources.length, + trx + ); + } }); await runDeleteSiteAssociatedResourcesSideEffects( diff --git a/server/routers/site/rejectSite.ts b/server/routers/site/rejectSite.ts index 037879e82..c671dcd79 100644 --- a/server/routers/site/rejectSite.ts +++ b/server/routers/site/rejectSite.ts @@ -129,6 +129,24 @@ export async function rejectSite( site.orgId, trx ); + + if (resourceSideEffects.resources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PUBLIC_RESOURCES, + -resourceSideEffects.resources.length, + trx + ); + } + + if (resourceSideEffects.siteResources.length > 0) { + await usageService.add( + site.orgId, + LimitId.PRIVATE_RESOURCES, + -resourceSideEffects.siteResources.length, + trx + ); + } }); await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects);