diff --git a/messages/en-US.json b/messages/en-US.json index 5833647e1..23195eab0 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", @@ -466,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", @@ -480,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", @@ -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/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/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..44901e8c8 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"; @@ -201,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) { @@ -220,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 } @@ -243,8 +249,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: resourceEnabled, alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"] || @@ -263,7 +268,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( @@ -496,8 +502,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: resourceEnabled, alias: resourceData.alias || null, aliasAddress: aliasAddress, disableIcmp: @@ -517,7 +522,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..997d56e2a 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, @@ -230,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 @@ -406,7 +425,8 @@ export async function updatePublicResources( ? (resourceData["proxy-protocol-version"] ?? 1) : 1, - resourcePolicyId: sharedPolicy.resourcePolicyId + resourcePolicyId: sharedPolicy.resourcePolicyId, + status: resourceStatusFromSite }) .where( eq( @@ -590,7 +610,8 @@ export async function updatePublicResources( authDaemonPort: resourceData["auth-daemon"]?.port || 22123, resourcePolicyId: null, - defaultResourcePolicyId: inlinePolicyId + defaultResourcePolicyId: inlinePolicyId, + status: resourceStatusFromSite }) .where( eq( @@ -1131,6 +1152,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( 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), 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/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/external.ts b/server/routers/external.ts index 9e4c5b3f1..62ca15316 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -248,6 +248,22 @@ authenticated.post( site.updateSite ); +authenticated.post( + "/site/:siteId/approve", + verifySiteAccess, + 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 74ea20078..3abd664eb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -138,6 +138,7 @@ authenticated.post( logActionAudit(ActionsEnum.updateSite), site.updateSite ); + authenticated.post( "/org/:orgId/reset-bandwidth", verifyApiKeyOrgAccess, 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) { 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(); 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..98e73ad85 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -45,18 +45,19 @@ 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({ orgId: z.string() }); -const listUserResourceAliasesQuerySchema = z.strictObject({ +const listUserResourceAliasesQuerySchema = z.object({ pageSize: z.coerce .number() .int() @@ -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/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/deleteSite.ts b/server/routers/site/deleteSite.ts index 602032ffb..6cf584d18 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -159,15 +159,39 @@ 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 ); - } + 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 + ); + } + + await db.transaction(async (trx) => { if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); @@ -180,12 +204,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 292c57971..c0c399789 100644 --- a/server/routers/site/index.ts +++ b/server/routers/site/index.ts @@ -3,6 +3,8 @@ export * from "./getStatusHistory"; 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..c671dcd79 --- /dev/null +++ b/server/routers/site/rejectSite.ts @@ -0,0 +1,196 @@ +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 + ); + + 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); + + 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/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/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, 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" diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 173a15190..0ccf29908 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; @@ -411,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( @@ -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 @@ -516,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/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 6e1a9fdeb..8dbdb3809 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -16,19 +16,21 @@ 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"; 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(); @@ -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" + )} + + + + )} + /> + + >(new Set()); const [rejectingIds, setRejectingIds] = useState>(new Set()); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [resourcesDialogSite, setResourcesDialogSite] = + useState(null); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -111,7 +124,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"), @@ -136,20 +149,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) => { @@ -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"), @@ -445,7 +481,7 @@ export default function PendingSitesTable({ disabled={isApproving || isRejecting} onClick={() => { setSelectedSite(siteRow); - setIsDeleteModalOpen(true); + setIsRejectModalOpen(true); }} > @@ -491,25 +527,63 @@ export default function PendingSitesTable({ return ( <> + { + if (!open) setResourcesDialogSite(null); + }} + > + + + {t("siteResourcesTab")} + + {t("siteResourcesDialogDescription")} + + + + {resourcesDialogSite != null && ( + + )} + + + + + + + {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")} /> )} = { 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/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 8d6f1a014..6c2999624 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -23,6 +23,7 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; @@ -36,7 +37,9 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UpdateSiteResourceResponse } from "@server/routers/siteResource"; import type { PaginationState } from "@tanstack/react-table"; +import { AxiosResponse } from "axios"; import { ArrowDown01Icon, ArrowRight, @@ -49,8 +52,17 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; 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 z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; @@ -114,6 +126,11 @@ function isSafeUrlForLink(href: string): boolean { } } +const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); + type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -174,6 +191,30 @@ export default function PrivateResourcesTable({ }); }; + async function toggleInternalResourceEnabled( + val: boolean, + resourceId: number + ) { + try { + await api.post>( + `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/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index d861a870c..7563b6d22 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -174,8 +174,8 @@ type OverviewRow = { type OverviewColumnProps = { title: string; description: string; - viewAllHref: string; - viewAllLabel: string; + viewAllHref?: string; + viewAllLabel?: string; emptyLabel: string; isForbidden: boolean; isFetching: boolean; @@ -212,12 +212,14 @@ function OverviewColumn({ {description}

- - {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} 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/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index d9f6fd691..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, @@ -342,7 +341,8 @@ export function createGeneralFormSchema(t: TranslateFn) { .string() .min(1) .max(255) - .regex(/^[a-zA-Z0-9-]+$/) + .regex(/^[a-zA-Z0-9-]+$/), + enabled: z.boolean() }); } 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