From 4eba51de72e55a63fd7605430c449a848745bc7d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 17:45:44 -0400 Subject: [PATCH] support delete resources associated with site --- messages/en-US.json | 10 +- server/lib/deleteResource.ts | 144 ++++++++++++++++++ server/lib/deleteSiteAssociatedResources.ts | 126 +++++++++++++++ server/lib/deleteSiteResource.ts | 53 +++++++ server/middlewares/verifyOrgAccess.ts | 3 - server/routers/resource/deleteResource.ts | 93 ++--------- server/routers/site/deleteSite.ts | 98 +++++++++++- .../siteResource/deleteSiteResource.ts | 45 +++--- src/components/SitesTable.tsx | 56 ++++++- 9 files changed, 507 insertions(+), 121 deletions(-) create mode 100644 server/lib/deleteResource.ts create mode 100644 server/lib/deleteSiteAssociatedResources.ts create mode 100644 server/lib/deleteSiteResource.ts diff --git a/messages/en-US.json b/messages/en-US.json index 30173d651..2a3379516 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Edit", "siteConfirmDelete": "Confirm Delete Site", + "siteConfirmDeleteAndResources": "Confirm Delete Site and Resources", "siteDelete": "Delete Site", - "siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", + "siteDeleteAndResources": "Delete Site and Resources", + "siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.", + "siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", + "siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?", + "sitesTableDeleteSite": "Delete Site", + "sitesTableDeleteSiteAndResources": "Delete Site and Resources", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", "sitesBannerTitle": "Connect Any Network", @@ -204,7 +210,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "publicResourcesBannerTitle": "Web-based Public Access", - "publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts new file mode 100644 index 000000000..b2ffa0f0f --- /dev/null +++ b/server/lib/deleteResource.ts @@ -0,0 +1,144 @@ +import { eq, inArray } from "drizzle-orm"; +import { + db, + newts, + resourcePolicies, + resources, + sites, + targetHealthCheck, + targets, + type Resource, + type Target, + type TargetHealthCheck, + type Transaction +} 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; + targetsToBeRemoved: Target[]; + healthChecksToBeRemoved: TargetHealthCheck[]; +}; + +export async function performDeleteResources( + resourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + if (resourceIds.length === 0) { + return []; + } + + const targetsToBeRemoved = await trx + .select() + .from(targets) + .where(inArray(targets.resourceId, resourceIds)); + + const targetIds = targetsToBeRemoved.map((t) => t.targetId); + const healthChecksToBeRemoved = + targetIds.length > 0 + ? await trx + .select() + .from(targetHealthCheck) + .where(inArray(targetHealthCheck.targetId, targetIds)) + : []; + + const deletedResources = await trx + .delete(resources) + .where(inArray(resources.resourceId, resourceIds)) + .returning(); + + const policyIds = deletedResources + .map((resource) => resource.defaultResourcePolicyId) + .filter((id): id is number => id != null); + + if (policyIds.length > 0) { + await trx + .delete(resourcePolicies) + .where(inArray(resourcePolicies.resourcePolicyId, policyIds)); + } + + if (deletedResources.length > 0) { + logger.debug(`Deleted ${deletedResources.length} resources`); + } + + const targetsByResourceId = new Map(); + for (const target of targetsToBeRemoved) { + const existing = targetsByResourceId.get(target.resourceId) ?? []; + existing.push(target); + targetsByResourceId.set(target.resourceId, existing); + } + + const targetIdToResourceId = new Map( + targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) + ); + + const healthChecksByResourceId = new Map(); + for (const healthCheck of healthChecksToBeRemoved) { + const resourceId = targetIdToResourceId.get(healthCheck.targetId!); + if (resourceId == null) { + continue; + } + const existing = healthChecksByResourceId.get(resourceId) ?? []; + existing.push(healthCheck); + healthChecksByResourceId.set(resourceId, existing); + } + + return deletedResources.map((deletedResource) => ({ + deletedResource, + targetsToBeRemoved: + targetsByResourceId.get(deletedResource.resourceId) ?? [], + healthChecksToBeRemoved: + healthChecksByResourceId.get(deletedResource.resourceId) ?? [] + })); +} + +export async function performDeleteResource( + resourceId: number, + trx: Transaction | typeof db = db +): Promise { + const [result] = await performDeleteResources([resourceId], trx); + return result ?? null; +} + +export async function runResourceDeleteSideEffects( + result: DeleteResourceResult +): Promise { + const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } = + result; + + for (const target of targetsToBeRemoved) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, target.siteId)) + .limit(1); + + if (!site) { + throw createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${target.siteId} not found` + ); + } + + if (site.pubKey && site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + await removeTargets( + newt.newtId, + [], + healthChecksToBeRemoved, + deletedResource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } + } + } +} diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts new file mode 100644 index 000000000..61da82f58 --- /dev/null +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -0,0 +1,126 @@ +import { and, eq, sql } from "drizzle-orm"; +import { + db, + siteNetworks, + siteResources, + targets, + type SiteResource, + type Transaction +} from "@server/db"; +import { + performDeleteResources, + runResourceDeleteSideEffects, + type DeleteResourceResult +} from "@server/lib/deleteResource"; +import { + performDeleteSiteResources, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; +import logger from "@server/logger"; + +export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250; + +export type DeleteSiteAssociatedResourcesSideEffects = { + resources: DeleteResourceResult[]; + siteResources: SiteResource[]; +}; + +export async function getResourceIdsForSite( + siteId: number, + trx: Transaction | typeof db = db +): Promise { + const rows = await trx + .selectDistinct({ resourceId: targets.resourceId }) + .from(targets) + .where(eq(targets.siteId, siteId)); + + return rows.map((row) => row.resourceId); +} + +export async function getSiteResourceIdsForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const rows = await trx + .selectDistinct({ siteResourceId: siteResources.siteResourceId }) + .from(siteNetworks) + .innerJoin( + siteResources, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId)) + ); + + return rows.map((row) => row.siteResourceId); +} + +export async function getAssociatedResourceCountForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const [publicCountResult, privateCountResult] = await Promise.all([ + trx + .select({ + count: sql`count(distinct ${targets.resourceId})` + }) + .from(targets) + .where(eq(targets.siteId, siteId)), + trx + .select({ + count: sql`count(distinct ${siteResources.siteResourceId})` + }) + .from(siteNetworks) + .innerJoin( + siteResources, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and( + eq(siteNetworks.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) + ]); + + return ( + Number(publicCountResult[0]?.count ?? 0) + + Number(privateCountResult[0]?.count ?? 0) + ); +} + +export function exceedsSiteAssociatedResourceDeleteLimit( + resourceCount: number +): boolean { + return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE; +} + +export async function deleteAssociatedResourcesForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getResourceIdsForSite(siteId, trx); + const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); + + const [resources, siteResourcesDeleted] = await Promise.all([ + performDeleteResources(resourceIds, trx), + performDeleteSiteResources(siteResourceIds, trx) + ]); + + return { resources, siteResources: siteResourcesDeleted }; +} + +export async function runDeleteSiteAssociatedResourcesSideEffects( + sideEffects: DeleteSiteAssociatedResourcesSideEffects +): Promise { + for (const result of sideEffects.resources) { + await runResourceDeleteSideEffects(result); + } + + for (const removed of sideEffects.siteResources) { + runSiteResourceDeleteSideEffects(removed); + } +} diff --git a/server/lib/deleteSiteResource.ts b/server/lib/deleteSiteResource.ts new file mode 100644 index 000000000..9db5bd902 --- /dev/null +++ b/server/lib/deleteSiteResource.ts @@ -0,0 +1,53 @@ +import { inArray } from "drizzle-orm"; +import { + db, + siteResources, + type SiteResource, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +export async function performDeleteSiteResources( + siteResourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + if (siteResourceIds.length === 0) { + return []; + } + + const removedSiteResources = await trx + .delete(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)) + .returning(); + + if (removedSiteResources.length > 0) { + logger.debug(`Deleted ${removedSiteResources.length} site resources`); + } + + return removedSiteResources; +} + +export async function performDeleteSiteResource( + siteResourceId: number, + trx: Transaction | typeof db = db +): Promise { + const [removedSiteResource] = await performDeleteSiteResources( + [siteResourceId], + trx + ); + return removedSiteResource ?? null; +} + +export function runSiteResourceDeleteSideEffects( + removedSiteResource: SiteResource +): void { + rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`, + err + ); + } + ); +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 030b5f702..be6242f6d 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -55,9 +55,6 @@ export async function verifyOrgAccess( userId, session: req.session }); - logger.debug("failed policy check", { - policyCheck - }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index a959611ec..766b25b04 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,13 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; -import { - db, - newts, - resourcePolicies, - resources, - sites, - targetHealthCheck, - targets -} from "@server/db"; +import { db } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -16,9 +7,11 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { removeTargets } from "../newt/targets"; +import { + performDeleteResource, + runResourceDeleteSideEffects +} from "@server/lib/deleteResource"; -// Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); @@ -67,27 +60,13 @@ export async function deleteResource( const { resourceId } = parsedParams.data; - const targetsToBeRemoved = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); + let deleteResult = null; - const healthChecksToBeRemoved = await db - .select() - .from(targetHealthCheck) - .where( - inArray( - targetHealthCheck.targetId, - targetsToBeRemoved.map((t) => t.targetId) - ) - ); + await db.transaction(async (trx) => { + deleteResult = await performDeleteResource(resourceId, trx); + }); - const [deletedResource] = await db - .delete(resources) - .where(eq(resources.resourceId, resourceId)) - .returning(); - - if (!deletedResource) { + if (!deleteResult) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -96,54 +75,7 @@ export async function deleteResource( ); } - for (const target of targetsToBeRemoved) { - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, target.siteId)) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${target.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - await removeTargets( - newt.newtId, - // [target], - [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - healthChecksToBeRemoved, - deletedResource.mode === "udp" ? "udp" : "tcp", - newt.version - ); - } - } - } - - // Also delete default resource policy - if (deletedResource.defaultResourcePolicyId) { - await db - .delete(resourcePolicies) - .where( - eq( - resourcePolicies.resourcePolicyId, - deletedResource.defaultResourcePolicyId - ) - ); - } + await runResourceDeleteSideEffects(deleteResult); return response(res, { data: null, @@ -154,6 +86,9 @@ export async function deleteResource( }); } 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/deleteSite.ts b/server/routers/site/deleteSite.ts index 077376211..300c570d8 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -14,18 +14,41 @@ import { OpenAPITags, registry } from "@server/openApi"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAssociatedResourcesForSite, + exceedsSiteAssociatedResourceDeleteLimit, + getAssociatedResourceCountForSite, + runDeleteSiteAssociatedResourcesSideEffects, + MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE, + type DeleteSiteAssociatedResourcesSideEffects +} from "@server/lib/deleteSiteAssociatedResources"; const deleteSiteSchema = z.strictObject({ siteId: z.coerce.number().int().positive() }); +const deleteSiteQuerySchema = z.strictObject({ + deleteResources: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(false) + .openapi({ + type: "boolean", + description: + "When true, also deletes all public and private resources associated with this site" + }) +}); + registry.registerPath({ method: "delete", path: "/site/{siteId}", description: "Delete a site and all its associated data.", tags: [OpenAPITags.Site], request: { - params: deleteSiteSchema + params: deleteSiteSchema, + query: deleteSiteQuerySchema }, responses: { 200: { @@ -61,7 +84,18 @@ export async function deleteSite( ); } + const parsedQuery = deleteSiteQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { siteId } = parsedParams.data; + const { deleteResources } = parsedQuery.data; const [site] = await db .select() @@ -78,20 +112,67 @@ export async function deleteSite( ); } + if (deleteResources) { + const canDeletePublic = await checkUserActionPermission( + ActionsEnum.deleteResource, + req + ); + const canDeletePrivate = await checkUserActionPermission( + ActionsEnum.deleteSiteResource, + req + ); + + if (!canDeletePublic || !canDeletePrivate) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to delete associated resources" + ) + ); + } + + const associatedResourceCount = + await getAssociatedResourceCountForSite(siteId, site.orgId); + + if ( + exceedsSiteAssociatedResourceDeleteLimit( + associatedResourceCount + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete site and associated resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} 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) => { + if (deleteResources) { + resourceSideEffects = await deleteAssociatedResourcesForSite( + siteId, + site.orgId, + trx + ); + } + if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // Clean up all client associations and send peer/proxy removal - // messages in a single efficient pass before deleting the row. await cleanupSiteAssociations(site, trx); } @@ -99,13 +180,17 @@ export async function deleteSite( await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); - // Send termination message outside of transaction to prevent blocking + if (deleteResources) { + await runDeleteSiteAssociatedResourcesSideEffects( + resourceSideEffects + ); + } + if (deletedNewt) { const payload = { type: `newt/wg/terminate`, data: {} }; - // Don't await this to prevent blocking the response sendToClient(deletedNewt.newtId, payload).catch((error) => { logger.error( "Failed to send termination message to newt:", @@ -123,6 +208,9 @@ export async function deleteSite( }); } 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/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index b9efc5ba8..cddeb490b 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -1,15 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts, primaryDb, sites } from "@server/db"; -import { siteResources } from "@server/db"; +import { db, siteResources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + performDeleteSiteResource, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -65,11 +67,10 @@ export async function deleteSiteResource( const { siteResourceId } = parsedParams.data; - // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) - .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!existingSiteResource) { @@ -78,26 +79,22 @@ export async function deleteSiteResource( ); } - // Delete the site resource - const [removedSiteResource] = await db - .delete(siteResources) - .where(eq(siteResources.siteResourceId, siteResourceId)) - .returning(); + let removedSiteResource = null; - // Run in the background after the response is sent. Wrapped in its - // own transaction so it always executes on the primary — avoiding any - // replica-lag issues while still allowing the HTTP response to return - // early. - rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( - (err) => { - logger.error( - `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, - err - ); - } - ); + await db.transaction(async (trx) => { + removedSiteResource = await performDeleteSiteResource( + siteResourceId, + trx + ); + }); - logger.info(`Deleted site resource ${siteResourceId}`); + if (!removedSiteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + runSiteResourceDeleteSideEffects(removedSiteResource); return response(res, { data: { message: "Site resource deleted successfully" }, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5b5ac1db1..3dc7a56da 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; @@ -104,6 +105,7 @@ export default function SitesTable({ } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteWithResources, setDeleteWithResources] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); @@ -157,10 +159,12 @@ export default function SitesTable({ }); } - function deleteSite(siteId: number) { + function deleteSite(siteId: number, withResources: boolean) { startTransition(async () => { await api - .delete(`/site/${siteId}`) + .delete(`/site/${siteId}`, { + params: { deleteResources: withResources } + }) .catch((e) => { console.error(t("siteErrorDelete"), e); toast({ @@ -521,16 +525,33 @@ export default function SitesTable({ )} + { setSelectedSite(siteRow); + setDeleteWithResources(false); setIsDeleteModalOpen(true); }} > - {t("delete")} + {t("sitesTableDeleteSite")} + {siteRow.resourceCount <= 250 && ( + { + setSelectedSite(siteRow); + setDeleteWithResources(true); + setIsDeleteModalOpen(true); + }} + > + + {t( + "sitesTableDeleteSiteAndResources" + )} + + + )} { setIsDeleteModalOpen(val); setSelectedSite(null); + setDeleteWithResources(false); }} dialog={
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

+

+ {deleteWithResources + ? t("siteQuestionRemoveAndResources") + : t("siteQuestionRemove")} +

+

+ {deleteWithResources + ? t("siteMessageRemoveAndResources") + : t("siteMessageRemove")} +

} - buttonText={t("siteConfirmDelete")} + buttonText={ + deleteWithResources + ? t("siteConfirmDeleteAndResources") + : t("siteConfirmDelete") + } onConfirm={async () => - startTransition(() => deleteSite(selectedSite!.id)) + startTransition(() => + deleteSite(selectedSite!.id, deleteWithResources) + ) } string={selectedSite.name} - title={t("siteDelete")} + title={ + deleteWithResources + ? t("siteDeleteAndResources") + : t("siteDelete") + } /> )}