From dd2081b6c0f3b5d12747a560d8598772c37238ea Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 13 Aug 2026 17:03:27 +0530 Subject: [PATCH 1/4] feat(sdk): guard the delete organization flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete button greys out while the org has open invoices with a non-zero amount, and its hover tooltip says to pay them from the billing page first — the server refuses the delete in that state anyway, this stops the user before the failed call. The delete dialog warns when tokens remain on the billing account: deleting forfeits them, and support can transfer the amount to the user's bank account. Confirming the dialog is the user's consent. The confirm button also disables while the request is in flight, so repeated clicks cannot fire the delete twice, and a failed_precondition response shows the server's reasons instead of a generic error. --- .../components/delete-organization-dialog.tsx | 12 ++++- web/sdk/client/views/general/general-view.tsx | 49 +++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/web/sdk/client/views/general/components/delete-organization-dialog.tsx b/web/sdk/client/views/general/components/delete-organization-dialog.tsx index 52f9bffddc..d01131dc61 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -20,6 +20,7 @@ import { } from '@raystack/apsara'; import { useFrontier } from '../../../contexts/FrontierContext'; import { useTerminology } from '../../../hooks/useTerminology'; +import { useTokens } from '../../../hooks/useTokens'; import { handleConnectError } from '~/utils/error'; const deleteOrgSchema = yup @@ -44,6 +45,7 @@ export const DeleteOrganizationDialog = ({ const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); const [isAcknowledged, setIsAcknowledged] = useState(false); + const { tokenBalance } = useTokens(); const { mutateAsync: deleteOrganization } = useMutation( FrontierServiceQueries.deleteOrganization @@ -83,6 +85,7 @@ export const DeleteOrganizationDialog = ({ } catch (error) { handleConnectError(error, { PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", type: 'error' }), + FailedPrecondition: (err) => toastManager.add({ title: `Cannot delete this ${orgLabelLower} yet`, description: err.rawMessage, type: 'error' }), NotFound: (err) => toastManager.add({ title: 'Not found', description: err.message, type: 'error' }), Default: (err) => toastManager.add({ title: 'Something went wrong', description: err.message, type: 'error' }), }); @@ -102,6 +105,13 @@ export const DeleteOrganizationDialog = ({ This action can not be undone. This will permanently delete all the projects and resources in {organization?.title}. + {tokenBalance > 0 ? ( + + You have {tokenBalance.toString()} tokens remaining. Deleting + the {orgLabelLower} forfeits them. Contact support to get the + amount transferred to your bank account. + + ) : null} ; +// Open invoices with a non-zero amount. The server refuses the delete while +// any exist, so the delete button greys out and explains why. +const OPEN_INVOICES_QUERY = create(RQLRequestSchema, { + filters: [ + create(RQLFilterSchema, { + name: 'state', + operator: 'eq', + value: { case: 'stringValue', value: INVOICE_STATES.OPEN } + }), + create(RQLFilterSchema, { + name: 'amount', + operator: 'gt', + value: { case: 'numberValue', value: 0 } + }) + ], + sort: [create(RQLSortSchema, { name: 'created_at', order: 'desc' })], + offset: 0, + limit: DEFAULT_PAGE_SIZE +}); + export interface GeneralViewProps { onDeleteSuccess?: () => void; urlPrefix?: string; @@ -97,6 +123,14 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; + const { invoices } = useOrganizationInvoices({ + query: OPEN_INVOICES_QUERY, + enabled: canDeleteWorkspace && !!organization?.id + }); + const hasUnpaidInvoices = invoices.some( + inv => inv.state === INVOICE_STATES.OPEN + ); + // Update organization form const { mutateAsync: updateOrganization } = useMutation( FrontierServiceQueries.updateOrganization, @@ -280,22 +314,27 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } > - {!canDeleteWorkspace && ( + {!canDeleteWorkspace ? ( {AuthTooltipMessage} - )} + ) : hasUnpaidInvoices ? ( + + There are unpaid invoices. Pay them from the billing page + before deleting the {orgLabelLower}. + + ) : null} )} From d8b1b733c17d5793888f4d83302873435d685bdd Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Mon, 24 Aug 2026 10:19:43 +0530 Subject: [PATCH 2/4] fix(sdk): review fixes for the delete organization guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token balance is only fetched while the delete dialog is open, so the General page stops firing a balance request (and a failure toast on accounts without balance support) on every visit. The delete button now stays disabled while the unpaid-invoice answer is still loading instead of briefly allowing a delete the server would refuse, and it trusts the server-filtered result instead of re-checking only the state. The token warning stops promising a bank transfer for the whole balance — only a purchased amount is transferable, matching the notice email. The failed_precondition toast uses err.message like the other handlers, and the open-invoice filters live in one shared helper used by both the delete guard (one row, no sort — existence is all it needs) and the billing page's payment-issue banner. --- web/sdk/client/hooks/useTokens.ts | 10 +++- web/sdk/client/utils/invoice-queries.ts | 23 +++++++++ .../billing/components/payment-issue.tsx | 15 +----- .../components/delete-organization-dialog.tsx | 18 +++++-- web/sdk/client/views/general/general-view.tsx | 47 ++++++++----------- 5 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 web/sdk/client/utils/invoice-queries.ts diff --git a/web/sdk/client/hooks/useTokens.ts b/web/sdk/client/hooks/useTokens.ts index cfbdf6aebd..2aadf058f8 100644 --- a/web/sdk/client/hooks/useTokens.ts +++ b/web/sdk/client/hooks/useTokens.ts @@ -11,7 +11,13 @@ interface UseTokensReturn { fetchTokenBalance: () => Promise; } -export const useTokens = (): UseTokensReturn => { +export interface UseTokensOptions { + // when false the balance is not fetched; callers that only need the + // balance in a rarely-opened surface (like a dialog) gate it on that + enabled?: boolean; +} + +export const useTokens = (options: UseTokensOptions = {}): UseTokensReturn => { const { billingAccount } = useFrontier(); const { @@ -25,7 +31,7 @@ export const useTokens = (): UseTokensReturn => { id: billingAccount?.id ?? '' }), { - enabled: !!billingAccount?.id, + enabled: !!billingAccount?.id && (options.enabled ?? true), retry: false } ); diff --git a/web/sdk/client/utils/invoice-queries.ts b/web/sdk/client/utils/invoice-queries.ts new file mode 100644 index 0000000000..7692403678 --- /dev/null +++ b/web/sdk/client/utils/invoice-queries.ts @@ -0,0 +1,23 @@ +import { create } from '@bufbuild/protobuf'; +import { RQLFilterSchema } from '@raystack/proton/frontier'; +import { INVOICE_STATES } from './constants'; + +// The one definition of "an invoice the customer still has to pay": open +// state with a non-zero amount. The server refuses an organization delete +// while any exist, and the billing page's payment-issue banner keys off the +// same set — both build their queries from these filters so the two can not +// drift apart. +export function openInvoiceFilters() { + return [ + create(RQLFilterSchema, { + name: 'state', + operator: 'eq', + value: { case: 'stringValue', value: INVOICE_STATES.OPEN } + }), + create(RQLFilterSchema, { + name: 'amount', + operator: 'gt', + value: { case: 'numberValue', value: 0 } + }) + ]; +} diff --git a/web/sdk/client/views/billing/components/payment-issue.tsx b/web/sdk/client/views/billing/components/payment-issue.tsx index 93dfc1e3da..a2dc72d69c 100644 --- a/web/sdk/client/views/billing/components/payment-issue.tsx +++ b/web/sdk/client/views/billing/components/payment-issue.tsx @@ -6,11 +6,11 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Subscription, RQLRequestSchema, - RQLFilterSchema, RQLSortSchema } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import { INVOICE_STATES, SUBSCRIPTION_STATES } from '../../../utils/constants'; +import { openInvoiceFilters } from '../../../utils/invoice-queries'; import { DEFAULT_PAGE_SIZE } from '../../../utils/connect-pagination'; import { useOrganizationInvoices } from '../../../hooks/useOrganizationInvoices'; import styles from '../billing-view.module.css'; @@ -18,18 +18,7 @@ import styles from '../billing-view.module.css'; // Open invoices with a non-zero amount, newest first — the invoice that needs // payment when a subscription is past due. const OPEN_INVOICES_QUERY = create(RQLRequestSchema, { - filters: [ - create(RQLFilterSchema, { - name: 'state', - operator: 'eq', - value: { case: 'stringValue', value: INVOICE_STATES.OPEN } - }), - create(RQLFilterSchema, { - name: 'amount', - operator: 'gt', - value: { case: 'numberValue', value: 0 } - }) - ], + filters: openInvoiceFilters(), sort: [create(RQLSortSchema, { name: 'created_at', order: 'desc' })], offset: 0, limit: DEFAULT_PAGE_SIZE diff --git a/web/sdk/client/views/general/components/delete-organization-dialog.tsx b/web/sdk/client/views/general/components/delete-organization-dialog.tsx index d01131dc61..87a04ae02b 100644 --- a/web/sdk/client/views/general/components/delete-organization-dialog.tsx +++ b/web/sdk/client/views/general/components/delete-organization-dialog.tsx @@ -45,7 +45,9 @@ export const DeleteOrganizationDialog = ({ const orgLabel = t.organization({ case: 'capital' }); const orgLabelLower = t.organization({ case: 'lower' }); const [isAcknowledged, setIsAcknowledged] = useState(false); - const { tokenBalance } = useTokens(); + // fetched only while the dialog is open; the confirm button waits for the + // answer so the forfeit warning cannot be skipped by a slow response + const { tokenBalance, isTokensLoading } = useTokens({ enabled: open }); const { mutateAsync: deleteOrganization } = useMutation( FrontierServiceQueries.deleteOrganization @@ -85,7 +87,7 @@ export const DeleteOrganizationDialog = ({ } catch (error) { handleConnectError(error, { PermissionDenied: () => toastManager.add({ title: "You don't have permission to perform this action", type: 'error' }), - FailedPrecondition: (err) => toastManager.add({ title: `Cannot delete this ${orgLabelLower} yet`, description: err.rawMessage, type: 'error' }), + FailedPrecondition: (err) => toastManager.add({ title: `Cannot delete this ${orgLabelLower} yet`, description: err.message, type: 'error' }), NotFound: (err) => toastManager.add({ title: 'Not found', description: err.message, type: 'error' }), Default: (err) => toastManager.add({ title: 'Something went wrong', description: err.message, type: 'error' }), }); @@ -108,8 +110,9 @@ export const DeleteOrganizationDialog = ({ {tokenBalance > 0 ? ( You have {tokenBalance.toString()} tokens remaining. Deleting - the {orgLabelLower} forfeits them. Contact support to get the - amount transferred to your bank account. + the {orgLabelLower} forfeits them. Contact support about + these tokens: any amount that was purchased can be + transferred to your bank account. ) : null} ; -// Open invoices with a non-zero amount. The server refuses the delete while -// any exist, so the delete button greys out and explains why. -const OPEN_INVOICES_QUERY = create(RQLRequestSchema, { - filters: [ - create(RQLFilterSchema, { - name: 'state', - operator: 'eq', - value: { case: 'stringValue', value: INVOICE_STATES.OPEN } - }), - create(RQLFilterSchema, { - name: 'amount', - operator: 'gt', - value: { case: 'numberValue', value: 0 } - }) - ], - sort: [create(RQLSortSchema, { name: 'created_at', order: 'desc' })], +// The server refuses the delete while an unpaid invoice exists, so the +// delete button greys out and explains why. Only existence matters here, +// hence one row and no sort. +const HAS_UNPAID_INVOICES_QUERY = create(RQLRequestSchema, { + filters: openInvoiceFilters(), offset: 0, - limit: DEFAULT_PAGE_SIZE + limit: 1 }); export interface GeneralViewProps { @@ -123,13 +109,14 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; - const { invoices } = useOrganizationInvoices({ - query: OPEN_INVOICES_QUERY, + const { invoices, isLoading: isInvoicesLoading } = useOrganizationInvoices({ + query: HAS_UNPAID_INVOICES_QUERY, enabled: canDeleteWorkspace && !!organization?.id }); - const hasUnpaidInvoices = invoices.some( - inv => inv.state === INVOICE_STATES.OPEN - ); + // the query already filters to unpaid invoices; while the answer is still + // loading the button stays disabled rather than briefly allowing a delete + // the server would refuse + const hasUnpaidInvoices = invoices.length > 0; // Update organization form const { mutateAsync: updateOrganization } = useMutation( @@ -321,7 +308,11 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { variant="solid" color="danger" onClick={() => setShowDeleteDialog(true)} - disabled={!canDeleteWorkspace || hasUnpaidInvoices} + disabled={ + !canDeleteWorkspace || + hasUnpaidInvoices || + isInvoicesLoading + } data-test-id="frontier-sdk-delete-organization-btn" > Delete {orgLabelLower} From cfd992e69c7b9711e953256a399ecf9bbca64e39 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Mon, 24 Aug 2026 14:19:19 +0530 Subject: [PATCH 3/4] feat(sdk): drive the delete button from CheckOrganizationDelete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete button now asks the server directly whether a delete would go through (CheckOrganizationDelete, added in raystack/frontier#1894) instead of inferring one blocker from the invoice list. The button greys out for every blocker the real delete would refuse with — a paid subscription to downgrade, unpaid invoices, a token debt — and the tooltip shows one short instruction per kind of blocker, with counts for invoices and a fallback to the server's message for kinds this version does not know. While the check loads the button stays disabled; a failed check fails open since the server refuses independently. @raystack/proton moves to the proton main commit that ships the RPC. The shared open-invoice filters stay for the billing page's payment-issue banner. --- web/pnpm-lock.yaml | 19 +++-- web/sdk/client/views/general/general-view.tsx | 76 +++++++++++++------ web/sdk/package.json | 2 +- 3 files changed, 63 insertions(+), 34 deletions(-) diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5975c67e41..e16481dbfd 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -222,8 +222,8 @@ importers: specifier: ^3.10.0 version: 3.10.0(react-hook-form@7.71.2(react@19.2.4)) '@raystack/proton': - specifier: 0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839 - version: 0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 0.1.0-194685ed0280d282261bc16f708266465dd1deb1 + version: 0.1.0-194685ed0280d282261bc16f708266465dd1deb1(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.21(react@19.2.4) @@ -1587,16 +1587,16 @@ packages: '@types/react': optional: true - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': - resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} + '@raystack/proton@0.1.0-194685ed0280d282261bc16f708266465dd1deb1': + resolution: {integrity: sha512-e1u+Tky38WpufUe9JO4KzIdJ49BbQDOFMayL5UiS/KPnvWR0xVgFleECbQ/xGqiwisTERYMXqjUbKvfpfyctgw==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: '@tanstack/react-query': optional: true - '@raystack/proton@0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839': - resolution: {integrity: sha512-YCzbMMyYaf5naOZdHYe01x5Ln0FFmABWwHDD3Oi6ANtQIZn1UmNsl8s3zcuhJ7dgQfGT87rFDeRBLm1ihxL2QQ==} + '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e': + resolution: {integrity: sha512-82QvGoJjcp5zGt/QIkdSJEn2iX8vn/g2H4jm8O3QH3YIZuZp0bXFXo+ddD5S49tSUtWiegi7qWJargHgBceLPg==} peerDependencies: '@tanstack/react-query': ^5.0.0 peerDependenciesMeta: @@ -2224,6 +2224,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -2584,6 +2585,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@2.2.3: resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==} @@ -6309,6 +6311,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -8073,7 +8076,7 @@ snapshots: - '@date-fns/tz' - date-fns - '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@raystack/proton@0.1.0-194685ed0280d282261bc16f708266465dd1deb1(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@bufbuild/protobuf': 2.11.0 '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.11.0) @@ -8087,7 +8090,7 @@ snapshots: - react - react-dom - '@raystack/proton@0.1.0-f0b06e61f1985a9052f32744976a2b73d8c56839(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@raystack/proton@0.1.0-859ba765e6cfd44736ddcf42664b742fe7fd916e(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.21(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@bufbuild/protobuf': 2.11.0 '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.11.0) diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index 76f63807ba..1db48495d9 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -8,13 +8,14 @@ import { create } from '@bufbuild/protobuf'; import { createConnectQueryKey, useMutation, + useQuery, useTransport } from '@connectrpc/connect-query'; import { useQueryClient } from '@tanstack/react-query'; import { FrontierServiceQueries, UpdateOrganizationRequestSchema, - RQLRequestSchema + CheckOrganizationDeleteRequestSchema } from '@raystack/proton/frontier'; import { Button, @@ -29,8 +30,6 @@ import { import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; import { useTerminology } from '../../hooks/useTerminology'; -import { useOrganizationInvoices } from '../../hooks/useOrganizationInvoices'; -import { openInvoiceFilters } from '../../utils/invoice-queries'; import { PERMISSIONS, shouldShowComponent } from '../../../utils'; import { AuthTooltipMessage } from '../../utils'; import { ViewContainer } from '../../components/view-container'; @@ -50,14 +49,18 @@ const generalSchema = yup type FormData = yup.InferType; -// The server refuses the delete while an unpaid invoice exists, so the -// delete button greys out and explains why. Only existence matters here, -// hence one row and no sort. -const HAS_UNPAID_INVOICES_QUERY = create(RQLRequestSchema, { - filters: openInvoiceFilters(), - offset: 0, - limit: 1 -}); +// One short instruction per kind of delete blocker the server can report. +// An unknown kind falls back to the server's own message. +const BLOCKER_INSTRUCTIONS: Record string> = { + ACTIVE_SUBSCRIPTION: () => + 'Downgrade the subscription to the standard plan', + UNPAID_INVOICE: count => + count > 1 + ? `Pay the ${count} open invoices from the billing page` + : 'Pay the open invoice from the billing page', + NEGATIVE_TOKEN_BALANCE: () => + 'Contact support to settle the token balance' +}; export interface GeneralViewProps { onDeleteSuccess?: () => void; @@ -109,14 +112,34 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const isLoading = !organization?.id || isActiveOrganizationLoading || isPermissionsFetching; - const { invoices, isLoading: isInvoicesLoading } = useOrganizationInvoices({ - query: HAS_UNPAID_INVOICES_QUERY, - enabled: canDeleteWorkspace && !!organization?.id - }); - // the query already filters to unpaid invoices; while the answer is still - // loading the button stays disabled rather than briefly allowing a delete - // the server would refuse - const hasUnpaidInvoices = invoices.length > 0; + // the server's own answer to "would a delete go through right now" — the + // same blockers a real delete would refuse with. While it loads the button + // stays disabled rather than briefly allowing a delete the server would + // refuse; a failed check fails open since the server refuses independently + const { data: deleteCheck, isLoading: isDeleteCheckLoading } = useQuery( + FrontierServiceQueries.checkOrganizationDelete, + create(CheckOrganizationDeleteRequestSchema, { + id: organization?.id ?? '' + }), + { + enabled: canDeleteWorkspace && !!organization?.id, + retry: false + } + ); + const isDeleteBlocked = !!deleteCheck && !deleteCheck.canDelete; + const blockerLines = useMemo(() => { + const blockers = deleteCheck?.blockers ?? []; + const counts = new Map(); + for (const blocker of blockers) { + counts.set(blocker.type, (counts.get(blocker.type) ?? 0) + 1); + } + return [...counts.entries()].map( + ([type, count]) => + BLOCKER_INSTRUCTIONS[type]?.(count) ?? + blockers.find(blocker => blocker.type === type)?.message ?? + type + ); + }, [deleteCheck]); // Update organization form const { mutateAsync: updateOrganization } = useMutation( @@ -301,7 +324,7 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } >