add reject endpoint

This commit is contained in:
miloschwartz
2026-07-12 17:48:48 -04:00
parent 933ca71c16
commit a8f3f71021
11 changed files with 309 additions and 41 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",
+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(
+10 -2
View File
@@ -251,11 +251,19 @@ authenticated.post(
authenticated.post( authenticated.post(
"/site/:siteId/approve", "/site/:siteId/approve",
verifySiteAccess, verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateSite), verifyUserHasAction(ActionsEnum.updateSiteApprovals),
logActionAudit(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSiteApprovals),
site.approveSite 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,
-7
View File
@@ -139,13 +139,6 @@ authenticated.post(
site.updateSite site.updateSite
); );
authenticated.post(
"/site/:siteId/approve",
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.updateSite),
logActionAudit(ActionsEnum.updateSite),
site.approveSite
);
authenticated.post( authenticated.post(
"/org/:orgId/reset-bandwidth", "/org/:orgId/reset-bandwidth",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
+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`,
+1
View File
@@ -4,6 +4,7 @@ export * from "./createSite";
export * from "./deleteSite"; export * from "./deleteSite";
export * from "./updateSite"; export * from "./updateSite";
export * from "./approveSite"; 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")
);
}
}
+13 -13
View File
@@ -65,7 +65,7 @@ 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 api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@@ -136,20 +136,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) => {
@@ -445,7 +445,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" />
@@ -493,23 +493,23 @@ export default function PendingSitesTable({
<> <>
{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: {