From 5acb2bf7dcfa7a2ca978efea43b52e4285ba3160 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 11 Nov 2026 13:59:17 +0530 Subject: [PATCH 1/4] feat(sdk): put the delete check behind enableDeleteCheck Servers before v0.119.0 do not serve CheckProjectDelete, and CheckCurrentUserDelete came in v0.120.0, so the check on click fails there. The new enableDeleteCheck option (off by default) turns on the check on click for organization, project and account deletes. When it is off, the delete button opens the confirm dialog directly, and the organization page checks on load and disables the button while blocked, as it did before. --- web/apps/client-demo/src/config/frontier.ts | 1 + .../delete-user-dialog/delete-user-dialog.tsx | 23 +++++--- web/sdk/client/hooks/useDeleteCheck.ts | 6 ++ web/sdk/client/views/general/general-view.tsx | 56 +++++++++++++++++-- web/sdk/shared/types.ts | 9 +++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/web/apps/client-demo/src/config/frontier.ts b/web/apps/client-demo/src/config/frontier.ts index dda949eb37..8139b0fbca 100644 --- a/web/apps/client-demo/src/config/frontier.ts +++ b/web/apps/client-demo/src/config/frontier.ts @@ -2,6 +2,7 @@ import type { FrontierClientOptions } from '@raystack/frontier/client'; const config: FrontierClientOptions = { endpoint: '/api', + enableDeleteCheck: true, billing: { basePlan: { title: 'Standard Plan' diff --git a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx index 6d35b8d1ab..38bdfccc2f 100644 --- a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx +++ b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx @@ -61,6 +61,7 @@ export function DeleteUserDialog({ handle, onDeleted }: DeleteUserDialogProps) { + const { config } = useFrontier(); const [dialogHandle] = useState(() => AlertDialog.createHandle() ); @@ -84,8 +85,12 @@ export function DeleteUserDialog({ }, [checkCurrentUserDelete, dialogHandle]); const startDelete = useCallback(() => { + if (!config.enableDeleteCheck) { + dialogHandle.openWithPayload({ step: 'confirm' }); + return; + } void checkAndOpen(); - }, [checkAndOpen]); + }, [config.enableDeleteCheck, dialogHandle, checkAndOpen]); useEffect(() => { if (!handle) return; @@ -96,13 +101,17 @@ export function DeleteUserDialog({ }, [handle, startDelete]); async function onDeleteRefused() { - if (await checkAndOpen()) { - toastManager.add({ - title: 'Unable to delete account', - description: GENERIC_DELETE_BLOCKED_MESSAGE, - type: 'error' - }); + if (config.enableDeleteCheck) { + // The check opens the blocked step. Toast only when it finds nothing. + if (!(await checkAndOpen())) return; + } else { + dialogHandle.close(); } + toastManager.add({ + title: 'Unable to delete account', + description: GENERIC_DELETE_BLOCKED_MESSAGE, + type: 'error' + }); } return ( diff --git a/web/sdk/client/hooks/useDeleteCheck.ts b/web/sdk/client/hooks/useDeleteCheck.ts index 692249775c..5a75478fcc 100644 --- a/web/sdk/client/hooks/useDeleteCheck.ts +++ b/web/sdk/client/hooks/useDeleteCheck.ts @@ -10,6 +10,7 @@ import { toDeleteBlockers } from '../utils/delete-blockers'; import { useTerminology } from './useTerminology'; +import { useFrontier } from '../contexts/FrontierContext'; import { handleConnectError } from '~/utils/error'; export interface UseDeleteCheckOptions { @@ -28,6 +29,7 @@ export function useDeleteCheck({ blockedDialogHandle }: UseDeleteCheckOptions) { const t = useTerminology(); + const { config } = useFrontier(); const [isChecking, setIsChecking] = useState(false); function showBlocked(blockers: DeleteBlocker[]) { @@ -79,6 +81,10 @@ export function useDeleteCheck({ } async function startDelete(payload: TPayload) { + if (!config.enableDeleteCheck) { + confirmDialogHandle.openWithPayload(payload); + return; + } setIsChecking(true); try { const { canDelete, blockers } = await check(payload); diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index 8deb293659..49a2cb703d 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -8,6 +8,7 @@ import { create } from '@bufbuild/protobuf'; import { createConnectQueryKey, useMutation, + useQuery, useTransport } from '@connectrpc/connect-query'; import { useQueryClient } from '@tanstack/react-query'; @@ -31,6 +32,7 @@ import { useFrontier } from '../../contexts/FrontierContext'; import { usePermissions } from '../../hooks/usePermissions'; import { useTerminology } from '../../hooks/useTerminology'; import { useDeleteCheck } from '../../hooks/useDeleteCheck'; +import { blockerLines, toDeleteBlockers } from '../../utils/delete-blockers'; import { PERMISSIONS, shouldShowComponent } from '../../../utils'; import { AuthTooltipMessage } from '../../utils'; import { ViewContainer } from '../../components/view-container'; @@ -62,7 +64,9 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { const { activeOrganization: organization, isActiveOrganizationLoading, - setActiveOrganization + setActiveOrganization, + config, + basePlan } = useFrontier(); const queryClient = useQueryClient(); const transport = useTransport(); @@ -131,6 +135,35 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { blockedDialogHandle: deleteBlockedDialogHandle }); + // Without enableDeleteCheck, check on load instead. While the answer is + // loading, the delete button stays disabled. If this check itself fails, + // the button stays enabled: the server still refuses a blocked delete. + const { data: loadDeleteCheck, isLoading: isLoadDeleteCheckLoading } = + useQuery( + FrontierServiceQueries.checkOrganizationDelete, + create(CheckOrganizationDeleteRequestSchema, { + id: organization?.id ?? '' + }), + { + enabled: + !config.enableDeleteCheck && canDeleteWorkspace && !!organization?.id, + retry: false + } + ); + const isDeleteBlocked = !!loadDeleteCheck && !loadDeleteCheck.canDelete; + const deleteBlockerLines = useMemo( + () => + blockerLines( + toDeleteBlockers(loadDeleteCheck?.blockers ?? []), + {}, + { + organizationRef: `this ${t.organization({ case: 'lower' })}`, + basePlanTitle: basePlan?.title + } + ), + [loadDeleteCheck, t, basePlan] + ); + // Update organization form const { mutateAsync: updateOrganization } = useMutation( FrontierServiceQueries.updateOrganization, @@ -312,14 +345,19 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { } > - {!canDeleteWorkspace && ( + {!canDeleteWorkspace ? ( {AuthTooltipMessage} - )} + ) : isDeleteBlocked ? ( + + + {deleteBlockerLines.map(line => ( + {line} + ))} + + + ) : null} )} diff --git a/web/sdk/shared/types.ts b/web/sdk/shared/types.ts index 709bca6bd4..237ca20f6e 100644 --- a/web/sdk/shared/types.ts +++ b/web/sdk/shared/types.ts @@ -58,6 +58,15 @@ export interface FrontierClientOptions { shortDateFormat?: string; billing?: FrontierClientBillingOptions; customization?: FrontierClientCustomizationOptions; + /** + * Check what blocks an organization, project or account delete when the + * delete button is clicked, and show the blockers in a dialog. The server + * must serve CheckOrganizationDelete, CheckProjectDelete and + * CheckCurrentUserDelete. Off by default: the delete button opens the + * confirm dialog directly, and the organization page checks on load and + * disables the button while blocked. + */ + enableDeleteCheck?: boolean; } export interface InitialState { From b5f28f0ff6de397f720159d4c289ebdbd933847b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 11 Nov 2026 14:04:44 +0530 Subject: [PATCH 2/4] fix(sdk): list the blockers when an account delete is refused With enableDeleteCheck off, a refused account delete showed the generic message. The toast now lists the blockers from the delete error with the current blocker lines, and adds a line for sole ownership. --- .../delete-user-dialog/delete-user-dialog.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx index 38bdfccc2f..e9bcf6080f 100644 --- a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx +++ b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import type { ConnectError } from '@connectrpc/connect'; import { useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries } from '@raystack/proton/frontier'; import { @@ -15,8 +16,12 @@ import { } from '@raystack/apsara'; import type { DeleteBlockedPayload, Slot } from '../../../shared/types'; import { useFrontier } from '../../contexts/FrontierContext'; +import { useTerminology } from '../../hooks/useTerminology'; import { GENERIC_DELETE_BLOCKED_MESSAGE, + SOLE_OWNER_WITH_MEMBERS, + blockerLines, + blockersFromError, toUserDeleteBlocked } from '../../utils/delete-blockers'; import { DeleteBlockedContent } from '../delete-blocked-dialog'; @@ -61,7 +66,8 @@ export function DeleteUserDialog({ handle, onDeleted }: DeleteUserDialogProps) { - const { config } = useFrontier(); + const { config, basePlan } = useFrontier(); + const t = useTerminology(); const [dialogHandle] = useState(() => AlertDialog.createHandle() ); @@ -100,16 +106,27 @@ export function DeleteUserDialog({ }; }, [handle, startDelete]); - async function onDeleteRefused() { + async function onDeleteRefused(err: ConnectError) { + let description = GENERIC_DELETE_BLOCKED_MESSAGE; if (config.enableDeleteCheck) { // The check opens the blocked step. Toast only when it finds nothing. if (!(await checkAndOpen())) return; } else { + // Without the check, list the blockers the refused delete returned. dialogHandle.close(); + const org = t.organization({ case: 'lower' }); + const lines = blockerLines( + blockersFromError(err), + { + [SOLE_OWNER_WITH_MEMBERS]: `Sole ownership: Transfer ownership of each ${org} where you are the only owner to another member before deleting your account.` + }, + { organizationRef: `your ${org}`, basePlanTitle: basePlan?.title } + ); + if (lines.length > 0) description = lines.join(' '); } toastManager.add({ title: 'Unable to delete account', - description: GENERIC_DELETE_BLOCKED_MESSAGE, + description, type: 'error' }); } @@ -146,7 +163,7 @@ DeleteUserDialog.createHandle = createDeleteUserDialogHandle; interface DeleteUserFormProps { onDeleted: () => void; - onDeleteRefused: () => void; + onDeleteRefused: (err: ConnectError) => void; } function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { From 04dfad733e805f73cc98a98b60f940675650270f Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 11 Nov 2026 14:07:15 +0530 Subject: [PATCH 3/4] Revert "fix(sdk): list the blockers when an account delete is refused" This reverts commit b5f28f0ff6de397f720159d4c289ebdbd933847b. --- .../delete-user-dialog/delete-user-dialog.tsx | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx index e9bcf6080f..38bdfccc2f 100644 --- a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx +++ b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx @@ -1,7 +1,6 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; -import type { ConnectError } from '@connectrpc/connect'; import { useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries } from '@raystack/proton/frontier'; import { @@ -16,12 +15,8 @@ import { } from '@raystack/apsara'; import type { DeleteBlockedPayload, Slot } from '../../../shared/types'; import { useFrontier } from '../../contexts/FrontierContext'; -import { useTerminology } from '../../hooks/useTerminology'; import { GENERIC_DELETE_BLOCKED_MESSAGE, - SOLE_OWNER_WITH_MEMBERS, - blockerLines, - blockersFromError, toUserDeleteBlocked } from '../../utils/delete-blockers'; import { DeleteBlockedContent } from '../delete-blocked-dialog'; @@ -66,8 +61,7 @@ export function DeleteUserDialog({ handle, onDeleted }: DeleteUserDialogProps) { - const { config, basePlan } = useFrontier(); - const t = useTerminology(); + const { config } = useFrontier(); const [dialogHandle] = useState(() => AlertDialog.createHandle() ); @@ -106,27 +100,16 @@ export function DeleteUserDialog({ }; }, [handle, startDelete]); - async function onDeleteRefused(err: ConnectError) { - let description = GENERIC_DELETE_BLOCKED_MESSAGE; + async function onDeleteRefused() { if (config.enableDeleteCheck) { // The check opens the blocked step. Toast only when it finds nothing. if (!(await checkAndOpen())) return; } else { - // Without the check, list the blockers the refused delete returned. dialogHandle.close(); - const org = t.organization({ case: 'lower' }); - const lines = blockerLines( - blockersFromError(err), - { - [SOLE_OWNER_WITH_MEMBERS]: `Sole ownership: Transfer ownership of each ${org} where you are the only owner to another member before deleting your account.` - }, - { organizationRef: `your ${org}`, basePlanTitle: basePlan?.title } - ); - if (lines.length > 0) description = lines.join(' '); } toastManager.add({ title: 'Unable to delete account', - description, + description: GENERIC_DELETE_BLOCKED_MESSAGE, type: 'error' }); } @@ -163,7 +146,7 @@ DeleteUserDialog.createHandle = createDeleteUserDialogHandle; interface DeleteUserFormProps { onDeleted: () => void; - onDeleteRefused: (err: ConnectError) => void; + onDeleteRefused: () => void; } function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { From 2c3e76edfb4bfd2b0ec3348eba7ac68a18e47beb Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 11 Nov 2026 14:14:18 +0530 Subject: [PATCH 4/4] fix(sdk): ignore the load check result when enableDeleteCheck is on A disabled query keeps its last data. When the option turns on after the organization page loaded, the old result kept the delete button disabled. --- web/sdk/client/views/general/general-view.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/sdk/client/views/general/general-view.tsx b/web/sdk/client/views/general/general-view.tsx index 49a2cb703d..76fc93fc07 100644 --- a/web/sdk/client/views/general/general-view.tsx +++ b/web/sdk/client/views/general/general-view.tsx @@ -150,7 +150,12 @@ export function GeneralView({ onDeleteSuccess, urlPrefix }: GeneralViewProps = { retry: false } ); - const isDeleteBlocked = !!loadDeleteCheck && !loadDeleteCheck.canDelete; + // A disabled query keeps its last data, so a result fetched before the + // option turned on must not block the click check. + const isDeleteBlocked = + !config.enableDeleteCheck && + !!loadDeleteCheck && + !loadDeleteCheck.canDelete; const deleteBlockerLines = useMemo( () => blockerLines(