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..76fc93fc07 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,40 @@ 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 + } + ); + // 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( + 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 +350,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 {