Compare commits

..

11 Commits

Author SHA1 Message Date
miloschwartz ae0be3a4bc add resources overview column to pending sites table 2026-07-12 17:53:57 -04:00
miloschwartz a8f3f71021 add reject endpoint 2026-07-12 17:48:48 -04:00
miloschwartz 933ca71c16 add approve site endpoint 2026-07-12 17:16:08 -04:00
miloschwartz 591cb9cdc1 dont use strict query params in list alises 2026-07-10 18:05:16 -04:00
miloschwartz 34b18bdb53 only show approved resources in launcher 2026-07-10 17:24:10 -04:00
miloschwartz 7d475f5e91 filter out pending resources 2026-07-10 17:18:33 -04:00
miloschwartz 39c35fa539 reorganize components nad hooks for consistency 2026-07-10 17:05:45 -04:00
Owen e1bc0b7efd Make sure the enabled gets set to false 2026-07-10 15:36:05 -04:00
Owen 5ef068c8dc Set the resource from the site 2026-07-10 15:31:37 -04:00
Owen 94c01a23a9 Merge branch 'private-resource-enable' into provisioning-resources 2026-07-10 15:17:06 -04:00
Owen cf9a17cc2e Add status filter 2026-07-10 15:04:28 -04:00
42 changed files with 901 additions and 132 deletions
+9 -1
View File
@@ -449,8 +449,14 @@
"provisioningManage": "Provisioning", "provisioningManage": "Provisioning",
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
"pendingSites": "Pending Sites", "pendingSites": "Pending Sites",
"siteApproveSuccess": "Site approved successfully", "siteApproveSuccess": "Site and associated resources approved successfully",
"siteApproveError": "Error approving site", "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", "provisioningKeys": "Provisioning Keys",
"searchProvisioningKeys": "Search provisioning keys...", "searchProvisioningKeys": "Search provisioning keys...",
"provisioningKeysAdd": "Generate Provisioning Key", "provisioningKeysAdd": "Generate Provisioning Key",
@@ -1420,6 +1426,8 @@
"setupTokenDescription": "Enter the setup token from the server console.", "setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required", "setupTokenRequired": "Setup token is required",
"actionUpdateSite": "Update Site", "actionUpdateSite": "Update Site",
"actionApproveSite": "Approve Site",
"actionRejectSite": "Reject Site",
"actionResetSiteBandwidth": "Reset Organization Bandwidth", "actionResetSiteBandwidth": "Reset Organization Bandwidth",
"actionListSiteRoles": "List Allowed Site Roles", "actionListSiteRoles": "List Allowed Site Roles",
"actionCreateResource": "Create Resource", "actionCreateResource": "Create Resource",
+1
View File
@@ -21,6 +21,7 @@ export enum ActionsEnum {
getSite = "getSite", getSite = "getSite",
listSites = "listSites", listSites = "listSites",
updateSite = "updateSite", updateSite = "updateSite",
updateSiteApprovals = "updateSiteApprovals",
restartSite = "restartSite", restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth", resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret", reGenerateSecret = "reGenerateSecret",
+8 -2
View File
@@ -200,7 +200,10 @@ export const resources = pgTable(
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123) authDaemonPort: integer("authDaemonPort").default(22123),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}, },
(t) => [ (t) => [
index("idx_resources_fulldomain") index("idx_resources_fulldomain")
@@ -451,7 +454,10 @@ export const siteResources = pgTable(
onDelete: "set null" onDelete: "set null"
}), }),
subdomain: varchar("subdomain"), subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain") fullDomain: varchar("fullDomain"),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}, },
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
); );
+4 -2
View File
@@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", {
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123) authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const labels = sqliteTable("labels", { export const labels = sqliteTable("labels", {
@@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", {
onDelete: "set null" onDelete: "set null"
}), }),
subdomain: text("subdomain"), subdomain: text("subdomain"),
fullDomain: text("fullDomain") fullDomain: text("fullDomain"),
status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const networks = sqliteTable("networks", { export const networks = sqliteTable("networks", {
+16 -5
View File
@@ -198,17 +198,19 @@ export async function updatePrivateResources(
} }
} }
let resourceStatusFromSite: "approved" | "pending" = "approved";
if (siteId && allSites.length === 0) { if (siteId && allSites.length === 0) {
// only add if there are not provided sites // only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx const [siteSingle] = await trx
.select({ siteId: sites.siteId }) .select({ siteId: sites.siteId, status: sites.status })
.from(sites) .from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1); .limit(1);
if (siteSingle) { if (siteSingle) {
allSites.push(siteSingle); allSites.push(siteSingle);
} }
resourceStatusFromSite = siteSingle.status ?? "approved";
} }
if (allSites.length === 0) { if (allSites.length === 0) {
@@ -217,6 +219,13 @@ export async function updatePrivateResources(
); );
} }
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
if (existingResource) { if (existingResource) {
let domainInfo: let domainInfo:
| { subdomain: string | null; domainId: string } | { subdomain: string | null; domainId: string }
@@ -240,7 +249,7 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: resourceData.enabled ?? true, enabled: resourceEnabled,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: disableIcmp:
resourceData["disable-icmp"] || resourceData["disable-icmp"] ||
@@ -259,7 +268,8 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough", pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode: authDaemonMode:
resourceData["auth-daemon"]?.mode || "native", resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123 authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -492,7 +502,7 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: resourceData.enabled ?? true, enabled: resourceEnabled,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: disableIcmp:
@@ -512,7 +522,8 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough", pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode: authDaemonMode:
resourceData["auth-daemon"]?.mode || "native", resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123 authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
}) })
.returning(); .returning();
+37 -15
View File
@@ -25,6 +25,7 @@ import {
rolePolicies, rolePolicies,
roleResources, roleResources,
roles, roles,
Site,
sites, sites,
Target, Target,
TargetHealthCheck, TargetHealthCheck,
@@ -74,19 +75,40 @@ export async function updatePublicResources(
)) { )) {
const targetsToUpdate: Target[] = []; const targetsToUpdate: Target[] = [];
const healthchecksToUpdate: TargetHealthCheck[] = []; const healthchecksToUpdate: TargetHealthCheck[] = [];
let resource: Resource; let resource: Resource;
let resourceStatusFromSite: "approved" | "pending" = "approved";
let providedSite: Partial<Site> | undefined;
if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[providedSite] = await trx
.select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
resourceStatusFromSite = providedSite?.status ?? "approved";
}
async function createTarget( // reusable function to create a target async function createTarget( // reusable function to create a target
resourceId: number, resourceId: number,
targetData: TargetData targetData: TargetData
) { ) {
const targetSiteId = targetData.site; const targetSiteId = targetData.site;
let site; let site: Partial<Site> | undefined;
if (targetSiteId) { if (targetSiteId) {
// Look up site by niceId // Look up site by niceId
[site] = await trx [site] = await trx
.select({ siteId: sites.siteId, type: sites.type }) .select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites) .from(sites)
.where( .where(
and( and(
@@ -95,15 +117,9 @@ export async function updatePublicResources(
) )
) )
.limit(1); .limit(1);
} else if (siteId) { } else if (siteId && providedSite) {
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
[site] = await trx site = providedSite;
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else { } else {
throw new Error(`Target site is required`); throw new Error(`Target site is required`);
} }
@@ -139,7 +155,7 @@ export async function updatePublicResources(
.insert(targets) .insert(targets)
.values({ .values({
resourceId: resourceId, resourceId: resourceId,
siteId: site.siteId, siteId: site.siteId!,
ip: targetData.hostname, ip: targetData.hostname,
mode: resourceData.mode as Target["mode"], mode: resourceData.mode as Target["mode"],
method: targetData.method, method: targetData.method,
@@ -172,7 +188,7 @@ export async function updatePublicResources(
.insert(targetHealthCheck) .insert(targetHealthCheck)
.values({ .values({
name: `${targetData.hostname}:${targetData.port}`, name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId, siteId: site.siteId!,
targetId: newTarget.targetId, targetId: newTarget.targetId,
orgId: orgId, orgId: orgId,
hcEnabled: healthcheckData?.enabled || false, hcEnabled: healthcheckData?.enabled || false,
@@ -230,7 +246,10 @@ export async function updatePublicResources(
const resourceEnabled = const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null resourceData.enabled == undefined || resourceData.enabled == null
? true ? true
: resourceData.enabled; : resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
const resourceSsl = const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null resourceData.ssl == undefined || resourceData.ssl == null
? true ? true
@@ -406,7 +425,8 @@ export async function updatePublicResources(
? (resourceData["proxy-protocol-version"] ?? ? (resourceData["proxy-protocol-version"] ??
1) 1)
: 1, : 1,
resourcePolicyId: sharedPolicy.resourcePolicyId resourcePolicyId: sharedPolicy.resourcePolicyId,
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -590,7 +610,8 @@ export async function updatePublicResources(
authDaemonPort: authDaemonPort:
resourceData["auth-daemon"]?.port || 22123, resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null, resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId defaultResourcePolicyId: inlinePolicyId,
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -1131,6 +1152,7 @@ export async function updatePublicResources(
.values({ .values({
orgId, orgId,
niceId: resourceNiceId, niceId: resourceNiceId,
status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode, mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes( proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
+3 -5
View File
@@ -14,8 +14,6 @@ import {
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets"; import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = { export type DeleteResourceResult = {
deletedResource: Resource; deletedResource: Resource;
@@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects(
.limit(1); .limit(1);
if (!site) { if (!site) {
throw createHttpError( logger.debug(
HttpCode.NOT_FOUND, `Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
`Site with ID ${target.siteId} not found`
); );
continue;
} }
if (site.pubKey && site.type === "newt") { if (site.pubKey && site.type === "newt") {
+82 -3
View File
@@ -1,6 +1,7 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { import {
db, db,
resources,
siteNetworks, siteNetworks,
siteResources, siteResources,
targets, targets,
@@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit(
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE; return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
} }
export async function getPendingResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
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<number[]> {
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<number> {
const [resourceIds, siteResourceIds] = await Promise.all([
getPendingResourceIdsForSite(siteId, trx),
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
]);
return resourceIds.length + siteResourceIds.length;
}
export async function deleteAssociatedResourcesForSite( export async function deleteAssociatedResourcesForSite(
siteId: number, siteId: number,
orgId: string, orgId: string,
@@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite(
const resourceIds = await getResourceIdsForSite(siteId, trx); const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([ const [deletedResources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx), performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, 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<DeleteSiteAssociatedResourcesSideEffects> {
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( export async function runDeleteSiteAssociatedResourcesSideEffects(
+16
View File
@@ -248,6 +248,22 @@ authenticated.post(
site.updateSite 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( authenticated.delete(
"/site/:siteId", "/site/:siteId",
verifySiteAccess, verifySiteAccess,
+1
View File
@@ -138,6 +138,7 @@ authenticated.post(
logActionAudit(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite),
site.updateSite site.updateSite
); );
authenticated.post( authenticated.post(
"/org/:orgId/reset-bandwidth", "/org/:orgId/reset-bandwidth",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
@@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
eq(userResources.userId, userId), eq(userResources.userId, userId),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
eq(resources.status, "approved")
) )
), ),
userRoleIds.length > 0 userRoleIds.length > 0
@@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
inArray(roleResources.roleId, userRoleIds), inArray(roleResources.roleId, userRoleIds),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
eq(resources.status, "approved")
) )
) )
: Promise.resolve([]), : Promise.resolve([]),
@@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached(
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
) )
.where( .where(
and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId)) and(
eq(userPolicies.userId, userId),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
), ),
userRoleIds.length > 0 userRoleIds.length > 0
? db ? db
@@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
inArray(rolePolicies.roleId, userRoleIds), inArray(rolePolicies.roleId, userRoleIds),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
eq(resources.status, "approved")
) )
) )
: Promise.resolve([]), : Promise.resolve([]),
db db
.select({ siteResourceId: userSiteResources.siteResourceId }) .select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources) .from(userSiteResources)
.where(eq(userSiteResources.userId, userId)), .innerJoin(
siteResources,
eq(
userSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, userId),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
),
userRoleIds.length > 0 userRoleIds.length > 0
? db ? db
.select({ .select({
siteResourceId: roleSiteResources.siteResourceId siteResourceId: roleSiteResources.siteResourceId
}) })
.from(roleSiteResources) .from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds)) .innerJoin(
siteResources,
eq(
roleSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, userRoleIds),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
)
: Promise.resolve([]) : Promise.resolve([])
]); ]);
@@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch(
inArray(resources.resourceId, resourceIds), inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
textMatch textMatch
) )
); );
@@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch(
inArray(siteResources.siteResourceId, siteResourceIds), inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
textMatch textMatch
) )
); );
@@ -503,7 +538,8 @@ async function listSiteGroups(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
if (searchPublic) { if (searchPublic) {
publicConditions.push(searchPublic); publicConditions.push(searchPublic);
@@ -558,7 +594,8 @@ async function listSiteGroups(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
if (searchSite) { if (searchSite) {
siteConditions.push(searchSite); siteConditions.push(searchSite);
@@ -621,7 +658,8 @@ async function listSiteGroups(
const noSitePublicConditions = [ const noSitePublicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
if (searchPublic) { if (searchPublic) {
noSitePublicConditions.push(searchPublic); noSitePublicConditions.push(searchPublic);
@@ -655,7 +693,8 @@ async function listSiteGroups(
const noSiteSiteConditions = [ const noSiteSiteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
if (searchSite) { if (searchSite) {
noSiteSiteConditions.push(searchSite); noSiteSiteConditions.push(searchSite);
@@ -746,7 +785,8 @@ async function listLabelGroups(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
const searchPublic = buildSearchConditionForPublic(query.query); const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) { if (searchPublic) {
@@ -810,7 +850,8 @@ async function listLabelGroups(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
const searchSite = buildSearchConditionForSiteResource(query.query); const searchSite = buildSearchConditionForSiteResource(query.query);
if (searchSite) { if (searchSite) {
@@ -997,6 +1038,7 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds), inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
siteIdFilter != null siteIdFilter != null
? eq(sites.siteId, siteIdFilter) ? eq(sites.siteId, siteIdFilter)
: undefined : undefined
@@ -1088,6 +1130,7 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds), inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
siteIdFilter != null siteIdFilter != null
? eq(sites.siteId, siteIdFilter) ? eq(sites.siteId, siteIdFilter)
: undefined : undefined
@@ -1382,7 +1425,8 @@ async function collectAccessibleSites(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true),
eq(resources.status, "approved")
]; ];
if (siteNameSearch) { if (siteNameSearch) {
publicConditions.push(siteNameSearch); publicConditions.push(siteNameSearch);
@@ -1422,7 +1466,8 @@ async function collectAccessibleSites(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
]; ];
if (siteNameSearch) { if (siteNameSearch) {
siteConditions.push(siteNameSearch); siteConditions.push(siteNameSearch);
@@ -1476,6 +1521,7 @@ async function collectAccessibleLabels(
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
eq(labels.orgId, orgId) eq(labels.orgId, orgId)
]; ];
if (labelNameSearch) { if (labelNameSearch) {
@@ -1511,6 +1557,7 @@ async function collectAccessibleLabels(
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
eq(labels.orgId, orgId) eq(labels.orgId, orgId)
]; ];
if (labelNameSearch) { if (labelNameSearch) {
+14
View File
@@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({
description: description:
"When set, only resources that have at least one target on this site are returned" "When set, only resources that have at least one target on this site are returned"
}), }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by resource status"
}),
labels: z labels: z
.preprocess((val) => { .preprocess((val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
@@ -451,6 +460,7 @@ export async function listResources(
sort_by, sort_by,
order, order,
siteId, siteId,
status,
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
@@ -660,6 +670,10 @@ export async function listResources(
} }
} }
if (typeof status !== "undefined") {
conditions.push(eq(resources.status, status));
}
if (siteId != null) { if (siteId != null) {
const resourcesWithSite = db const resourcesWithSite = db
.select({ resourceId: targets.resourceId }) .select({ resourceId: targets.resourceId })
@@ -45,18 +45,19 @@ function userResourceAliasesCacheKey(
page: number, page: number,
pageSize: number, pageSize: number,
includeLabels: boolean, includeLabels: boolean,
labelFilter: string[] labelFilter: string[],
status?: "pending" | "approved"
) { ) {
const labelsKey = const labelsKey =
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`; return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
} }
const listUserResourceAliasesParamsSchema = z.strictObject({ const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const listUserResourceAliasesQuerySchema = z.strictObject({ const listUserResourceAliasesQuerySchema = z.object({
pageSize: z.coerce pageSize: z.coerce
.number<string>() .number<string>()
.int() .int()
@@ -96,7 +97,16 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
type: "array", type: "array",
description: description:
"Filter by resource labels. A resource matches when it has any of the given labels (OR)." "Filter by resource labels. A resource matches when it has any of the given labels (OR)."
}) }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
})
}); });
export type UserResourceAliasItem = { export type UserResourceAliasItem = {
@@ -130,7 +140,8 @@ export async function listUserResourceAliases(
page, page,
pageSize, pageSize,
includeLabels, includeLabels,
labels: labelFilter labels: labelFilter,
status
} = parsedQuery.data; } = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse( const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
@@ -172,7 +183,8 @@ export async function listUserResourceAliases(
page, page,
pageSize, pageSize,
includeLabels, includeLabels,
labelFilter ?? [] labelFilter ?? [],
status
); );
const cachedData: ListUserResourceAliasesResponse | undefined = const cachedData: ListUserResourceAliasesResponse | undefined =
await cache.get(cacheKey); await cache.get(cacheKey);
@@ -257,6 +269,10 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds) inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
]; ];
if (typeof status !== "undefined") {
whereConditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
whereConditions.push( whereConditions.push(
inArray( inArray(
+251
View File
@@ -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<any> {
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")
);
}
}
+9 -9
View File
@@ -159,15 +159,21 @@ export async function deleteSite(
siteResources: [] siteResources: []
}; };
await db.transaction(async (trx) => { if (deleteResources) {
if (deleteResources) { await db.transaction(async (trx) => {
resourceSideEffects = await deleteAssociatedResourcesForSite( resourceSideEffects = await deleteAssociatedResourcesForSite(
siteId, siteId,
site.orgId, site.orgId,
trx trx
); );
} });
await runDeleteSiteAssociatedResourcesSideEffects(
resourceSideEffects
);
}
await db.transaction(async (trx) => {
if (site.type == "wireguard") { if (site.type == "wireguard") {
if (site.pubKey) { if (site.pubKey) {
await deletePeer(site.exitNodeId!, 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); await usageService.add(site.orgId, LimitId.SITES, -1, trx);
}); });
if (deleteResources) {
await runDeleteSiteAssociatedResourcesSideEffects(
resourceSideEffects
);
}
if (deletedNewt) { if (deletedNewt) {
const payload = { const payload = {
type: `newt/wg/terminate`, type: `newt/wg/terminate`,
+2
View File
@@ -3,6 +3,8 @@ export * from "./getStatusHistory";
export * from "./createSite"; export * from "./createSite";
export * from "./deleteSite"; export * from "./deleteSite";
export * from "./updateSite"; export * from "./updateSite";
export * from "./approveSite";
export * from "./rejectSite";
export * from "./listSites"; export * from "./listSites";
export * from "./listSiteRoles"; export * from "./listSiteRoles";
export * from "./pickSiteDefaults"; export * from "./pickSiteDefaults";
+178
View File
@@ -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<any> {
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")
);
}
}
-1
View File
@@ -21,7 +21,6 @@ const updateSiteBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(), dockerSocketEnabled: z.boolean().optional(),
status: z.enum(["pending", "approved"]).optional(),
autoUpdateEnabled: z.boolean().optional(), autoUpdateEnabled: z.boolean().optional(),
autoUpdateOverrideOrg: z.boolean().optional() autoUpdateOverrideOrg: z.boolean().optional()
}) })
@@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
description: description:
"When set, only site resources associated with this site (via network) are returned" "When set, only site resources associated with this site (via network) are returned"
}), }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}),
labels: z labels: z
.preprocess((val) => { .preprocess((val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
@@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg(
sort_by, sort_by,
order, order,
siteId, siteId,
status,
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
@@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
@@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({
enum: ["asc", "desc"], enum: ["asc", "desc"],
default: "asc", default: "asc",
description: "Sort order" description: "Sort order"
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}) })
}); });
@@ -110,7 +119,7 @@ export async function listSiteResources(
} }
const { siteId, orgId } = parsedParams.data; const { siteId, orgId } = parsedParams.data;
const { limit, offset, sort_by, order } = parsedQuery.data; const { limit, offset, sort_by, order, status } = parsedQuery.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const site = await db const site = await db
@@ -124,6 +133,15 @@ export async function listSiteResources(
} }
// Get site resources by joining networks to siteResources via siteNetworks // Get site resources by joining networks to siteResources via siteNetworks
const conditions = [
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
];
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
const siteResourcesList = await db const siteResourcesList = await db
.select() .select()
.from(siteNetworks) .from(siteNetworks)
@@ -132,12 +150,7 @@ export async function listSiteResources(
siteResources, siteResources,
eq(siteResources.networkId, networks.networkId) eq(siteResources.networkId, networks.networkId)
) )
.where( .where(and(...conditions))
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.orderBy( .orderBy(
sort_by sort_by
? order === "asc" ? order === "asc"
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields"; import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() { export default function PrivateResourceAccessPage() {
const t = useTranslations(); const t = useTranslations();
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields"; import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { asAnyControl, asAnySetValue } from "../../formControlUtils"; import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceCidrPage() { export default function PrivateResourceCidrPage() {
const t = useTranslations(); const t = useTranslations();
@@ -30,7 +30,7 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react"; import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
export default function PrivateResourceGeneralPage() { export default function PrivateResourceGeneralPage() {
const t = useTranslations(); const t = useTranslations();
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields"; import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "../../formControlUtils"; } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHostPage() { export default function PrivateResourceHostPage() {
const t = useTranslations(); const t = useTranslations();
@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields"; import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "../../formControlUtils"; } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceHttpPage() { export default function PrivateResourceHttpPage() {
const t = useTranslations(); const t = useTranslations();
@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields"; import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils"; import type { Selectedsite } from "@app/components/site-selector";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "../../formControlUtils"; } from "@app/lib/formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource"; import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
import type { Selectedsite } from "@app/components/site-selector";
export default function PrivateResourceSshPage() { export default function PrivateResourceSshPage() {
const t = useTranslations(); const t = useTranslations();
@@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "../PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields"; import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "../PrivateResourceSshFields"; import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { import {
PrivateResourceAliasField, PrivateResourceAliasField,
PrivateResourceCidrDestinationField, PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields PrivateResourceHostDestinationFields
} from "../PrivateResourceDestinationFields"; } from "@app/components/PrivateResourceDestinationFields";
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils"; import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
export default function CreatePrivateResourcePage() { export default function CreatePrivateResourcePage() {
const params = useParams(); const params = useParams();
@@ -27,6 +27,7 @@ export default async function ClientResourcesPage(
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
@@ -38,6 +38,7 @@ export default async function ProxyResourcesPage(
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = { let pagination: ListResourcesResponse["pagination"] = {
+88 -14
View File
@@ -1,6 +1,16 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; 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 { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
@@ -24,6 +34,7 @@ import {
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpRight, ArrowUpRight,
Check, Check,
ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal, MoreHorizontal,
X X
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set()); const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set()); const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null);
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
async function approveSite(siteId: number) { async function approveSite(siteId: number) {
setApprovingIds((prev) => new Set(prev).add(siteId)); setApprovingIds((prev) => new Set(prev).add(siteId));
try { try {
await api.post(`/site/${siteId}`, { status: "approved" }); await api.post(`/site/${siteId}/approve`);
toast({ toast({
title: t("success"), title: t("success"),
description: t("siteApproveSuccess"), description: t("siteApproveSuccess"),
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
async function rejectSite(siteId: number) { async function rejectSite(siteId: number) {
setRejectingIds((prev) => new Set(prev).add(siteId)); setRejectingIds((prev) => new Set(prev).add(siteId));
try { try {
await api.delete(`/site/${siteId}`); await api.post(`/site/${siteId}/reject`);
toast({ toast({
title: t("success"), title: t("success"),
description: t("siteDeleted"), description: t("siteRejectSuccess"),
variant: "default" variant: "default"
}); });
setIsDeleteModalOpen(false); setIsRejectModalOpen(false);
setSelectedSite(null); setSelectedSite(null);
router.refresh(); router.refresh();
} catch (e) { } catch (e) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("siteErrorDelete"), title: t("siteRejectError"),
description: formatAxiosError(e, t("siteErrorDelete")) description: formatAxiosError(e, t("siteRejectError"))
}); });
} finally { } finally {
setRejectingIds((prev) => { setRejectingIds((prev) => {
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
} }
} }
}, },
{
id: "resources",
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
);
}
},
{ {
accessorKey: "exitNode", accessorKey: "exitNode",
friendlyName: t("exitNode"), friendlyName: t("exitNode"),
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
disabled={isApproving || isRejecting} disabled={isApproving || isRejecting}
onClick={() => { onClick={() => {
setSelectedSite(siteRow); setSelectedSite(siteRow);
setIsDeleteModalOpen(true); setIsRejectModalOpen(true);
}} }}
> >
<X className="mr-2 w-4 h-4" /> <X className="mr-2 w-4 h-4" />
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
return ( return (
<> <>
<Credenza
open={Boolean(resourcesDialogSite)}
onOpenChange={(open) => {
if (!open) setResourcesDialogSite(null);
}}
>
<CredenzaContent className="md:max-w-7xl">
<CredenzaHeader>
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
<CredenzaDescription>
{t("siteResourcesDialogDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{resourcesDialogSite != null && (
<SiteResourcesOverview
orgIdOverride={orgId}
siteId={resourcesDialogSite.id}
initialPublicData={null}
initialPrivateData={null}
initialPublicForbidden={false}
initialPrivateForbidden={false}
showViewAllLinks={false}
/>
)}
</CredenzaBody>
<CredenzaFooter>
<Button
type="button"
variant="outline"
onClick={() => setResourcesDialogSite(null)}
>
{t("close")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedSite && ( {selectedSite && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={isDeleteModalOpen} open={isRejectModalOpen}
setOpen={(val) => { setOpen={(val) => {
setIsDeleteModalOpen(val); setIsRejectModalOpen(val);
if (!val) { if (!val) {
setSelectedSite(null); setSelectedSite(null);
} }
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("siteQuestionRemove")}</p> <p>{t("siteQuestionReject")}</p>
<p>{t("siteMessageRemove")}</p> <p>{t("siteMessageReject")}</p>
</div> </div>
} }
buttonText={t("siteConfirmDelete")} buttonText={t("siteConfirmReject")}
onConfirm={async () => rejectSite(selectedSite.id)} onConfirm={async () => rejectSite(selectedSite.id)}
string={selectedSite.name} string={selectedSite.name}
title={t("siteDelete")} title={t("siteReject")}
/> />
)} )}
<ControlledDataTable <ControlledDataTable
+3 -1
View File
@@ -55,6 +55,7 @@ function getActionsCategories(root: boolean) {
[t("actionGetSite")]: "getSite", [t("actionGetSite")]: "getSite",
[t("actionListSites")]: "listSites", [t("actionListSites")]: "listSites",
[t("actionUpdateSite")]: "updateSite", [t("actionUpdateSite")]: "updateSite",
[t("actionUpdateSiteApprovals")]: "updateSiteApprovals",
[t("actionListSiteRoles")]: "listSiteRoles" [t("actionListSiteRoles")]: "listSiteRoles"
}, },
@@ -78,7 +79,8 @@ function getActionsCategories(root: boolean) {
[t("actionGetSiteResource")]: "getSiteResource", [t("actionGetSiteResource")]: "getSiteResource",
[t("actionListSiteResources")]: "listSiteResources", [t("actionListSiteResources")]: "listSiteResources",
[t("actionUpdateSiteResource")]: "updateSiteResource", [t("actionUpdateSiteResource")]: "updateSiteResource",
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken" [t("actionCreateResourceSessionToken")]:
"createResourceSessionToken"
}, },
Target: { Target: {
@@ -23,7 +23,7 @@ import {
import { ChevronsUpDown } from "lucide-react"; import { ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Control, FieldPath, FieldValues } from "react-hook-form"; import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp"; import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
type PrivateResourceSitesFieldProps<T extends FieldValues> = { type PrivateResourceSitesFieldProps<T extends FieldValues> = {
control: Control<T>; control: Control<T>;
@@ -9,10 +9,10 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields"; import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "./PrivateResourceSitesField"; import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
import { inferSshPamMode } from "@app/lib/privateResourceForm"; import { inferSshPamMode } from "@app/lib/privateResourceForm";
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
import { import {
FormControl, FormControl,
FormField, FormField,
+22 -13
View File
@@ -174,8 +174,8 @@ type OverviewRow = {
type OverviewColumnProps = { type OverviewColumnProps = {
title: string; title: string;
description: string; description: string;
viewAllHref: string; viewAllHref?: string;
viewAllLabel: string; viewAllLabel?: string;
emptyLabel: string; emptyLabel: string;
isForbidden: boolean; isForbidden: boolean;
isFetching: boolean; isFetching: boolean;
@@ -212,12 +212,14 @@ function OverviewColumn({
{description} {description}
</p> </p>
</div> </div>
<Link {viewAllHref && viewAllLabel ? (
href={viewAllHref} <Link
className="shrink-0 text-muted-foreground text-sm hover:underline" href={viewAllHref}
> className="shrink-0 text-muted-foreground text-sm hover:underline"
{viewAllLabel} >
</Link> {viewAllLabel}
</Link>
) : null}
</div> </div>
</div> </div>
); );
@@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = {
initialPrivateForbidden: boolean; initialPrivateForbidden: boolean;
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */ /** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
orgIdOverride?: string; orgIdOverride?: string;
/** When false, hides links to the org resources tables filtered by this site. */
showViewAllLinks?: boolean;
}; };
export default function SiteResourcesOverview({ export default function SiteResourcesOverview({
@@ -327,7 +331,8 @@ export default function SiteResourcesOverview({
initialPrivateData, initialPrivateData,
initialPublicForbidden, initialPublicForbidden,
initialPrivateForbidden, initialPrivateForbidden,
orgIdOverride orgIdOverride,
showViewAllLinks = true
}: SiteResourcesOverviewProps) { }: SiteResourcesOverviewProps) {
const t = useTranslations(); const t = useTranslations();
const params = useParams<{ orgId: string }>(); const params = useParams<{ orgId: string }>();
@@ -467,8 +472,10 @@ export default function SiteResourcesOverview({
key="public" key="public"
title={t("siteResourcesSectionPublic")} title={t("siteResourcesSectionPublic")}
description={t("siteResourcesSectionPublicDescription")} description={t("siteResourcesSectionPublicDescription")}
viewAllHref={publicViewAllHref} viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
viewAllLabel={t("siteResourcesViewAllPublic")} viewAllLabel={
showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
}
emptyLabel={t("siteResourcesEmptyPublic")} emptyLabel={t("siteResourcesEmptyPublic")}
isForbidden={publicForbidden} isForbidden={publicForbidden}
isFetching={publicQuery.isFetching} isFetching={publicQuery.isFetching}
@@ -484,8 +491,10 @@ export default function SiteResourcesOverview({
key="private" key="private"
title={t("siteResourcesSectionPrivate")} title={t("siteResourcesSectionPrivate")}
description={t("siteResourcesSectionPrivateDescription")} description={t("siteResourcesSectionPrivateDescription")}
viewAllHref={privateViewAllHref} viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
viewAllLabel={t("siteResourcesViewAllPrivate")} viewAllLabel={
showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
}
emptyLabel={t("siteResourcesEmptyPrivate")} emptyLabel={t("siteResourcesEmptyPrivate")}
isForbidden={privateForbidden} isForbidden={privateForbidden}
isFetching={privateQuery.isFetching} isFetching={privateQuery.isFetching}