From 91fc3bb6727a56bb32620377d70556c876578c6d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 3 Nov 2026 20:51:54 +0530 Subject: [PATCH 01/12] feat(sdk): add the account delete flow DeleteUserDialog runs the whole account delete. It opens first and runs CheckCurrentUserDelete inside, so it works from any trigger, including a menu item that has already closed. Open it with a trigger slot, or with a handle from DeleteUserDialog.createHandle(). When the user is the only owner of an organization with other members, it asks for an ownership transfer. When an organization that would be deleted with the account is blocked, it lists the blockers for each organization. Both go through the deleteBlockedDialog slot. Otherwise it shows the Delete Account confirmation, which needs the user's email and an acknowledgement. A refused delete checks again to show what blocks it. ProfileView takes onAccountDeleted. When it is set, the view shows a Delete account section. The SDK does not navigate after the delete. --- .../delete-blocked-dialog.tsx | 121 ++++++--- .../components/delete-blocked-dialog/index.ts | 1 + .../delete-user-dialog.module.css | 3 + .../delete-user-dialog/delete-user-dialog.tsx | 247 ++++++++++++++++++ .../components/delete-user-dialog/index.ts | 5 + web/sdk/client/hooks/useDeleteCheck.ts | 4 +- web/sdk/client/index.ts | 5 + web/sdk/client/utils/delete-blockers.ts | 30 ++- web/sdk/client/views/profile/index.ts | 1 + web/sdk/client/views/profile/profile-view.tsx | 41 ++- web/sdk/shared/types.ts | 15 +- 11 files changed, 433 insertions(+), 40 deletions(-) create mode 100644 web/sdk/client/components/delete-user-dialog/delete-user-dialog.module.css create mode 100644 web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx create mode 100644 web/sdk/client/components/delete-user-dialog/index.ts diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx index 213f717dd1..ed825eb607 100644 --- a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx @@ -8,27 +8,72 @@ import type { DeleteBlockedPayload } from '../../../shared/types'; import { useFrontier } from '../../contexts/FrontierContext'; -import { useTerminology } from '../../hooks/useTerminology'; -import { blockerLines } from '../../utils/delete-blockers'; +import { + useTerminology, + type TerminologyMap +} from '../../hooks/useTerminology'; +import { + GENERIC_DELETE_BLOCKED_MESSAGE, + blockerLines, + type BlockerWording +} from '../../utils/delete-blockers'; import styles from './delete-blocked-dialog.module.css'; export type DeleteBlockedDialogHandle = ReturnType< typeof AlertDialog.createHandle >; -const DEFAULT_COPY: Record< - DeleteBlockedPayload['entity'], - (entityLabel: string) => { title: string; description: string } -> = { - organization: entityLabel => ({ - title: `Unable to delete ${entityLabel}`, - description: `Please resolve the following issues before deleting this ${entityLabel}:` - }), - project: entityLabel => ({ - title: `Unable to delete ${entityLabel}`, - description: `This ${entityLabel} cannot be deleted because it contains resources that need to be addressed first.` - }) -}; +interface BulletGroup { + title?: string; + lines: string[]; +} + +// The SDK's own title, intro and bullets for the open dialog. An account +// delete asks for the ownership transfer first, then for the blockers of +// the organizations that would be deleted with it. +function defaultContent( + context: DeleteBlockedContext, + t: TerminologyMap, + messages: Record, + wording: BlockerWording +): { title: string; description: string; groups: BulletGroup[] } { + const { entityLabel } = context; + switch (context.entity) { + case 'organization': + return { + title: `Unable to delete ${entityLabel}`, + description: `Please resolve the following issues before deleting this ${entityLabel}:`, + groups: [{ lines: blockerLines(context.blockers, messages, wording) }] + }; + case 'project': + return { + title: `Unable to delete ${entityLabel}`, + description: `This ${entityLabel} cannot be deleted because it contains resources that need to be addressed first.`, + groups: [{ lines: blockerLines(context.blockers, messages, wording) }] + }; + case 'user': { + const org = t.organization({ case: 'lower' }); + if (context.soleOwnerOf.length > 0) { + return { + title: `Transfer ${org} ownership`, + description: `You're the only owner of one or more ${t.organization({ case: 'lower', plural: true })}. Before deleting your account, transfer ownership of each affected ${org} to another member. Once ownership has been transferred, you can proceed with account deletion.`, + groups: [{ lines: context.soleOwnerOf.map(({ title }) => title) }] + }; + } + return { + title: 'Unable to delete account', + description: 'Please resolve the following issues before deleting your account:', + groups: + context.organizationsToDelete.length > 0 + ? context.organizationsToDelete.map(({ title, blockers }) => ({ + title, + lines: blockerLines(blockers, messages, wording) + })) + : [{ lines: [GENERIC_DELETE_BLOCKED_MESSAGE] }] + }; + } + } +} const DeleteBlockedDialogContext = createContext( null @@ -55,7 +100,7 @@ export function DeleteBlockedDialogRoot({ ); } -function DeleteBlockedContent({ +export function DeleteBlockedContent({ payload, close }: { @@ -100,33 +145,45 @@ export function DeleteBlockedDialog({ ); } - const copy = DEFAULT_COPY[context.entity](context.entityLabel); - const lines = blockerLines(blockers ?? context.blockers, messages, { + const wording = { organizationLabel: t.organization({ case: 'lower' }), basePlanTitle: basePlan?.title - }); + }; + const content = defaultContent(context, t, messages, wording); + const groups = blockers + ? [{ lines: blockerLines(blockers, messages, wording) }] + : content.groups; return ( - {title ?? copy.title} + {title ?? content.title} - {description ?? copy.description} + {description ?? content.description} - {lines.length > 0 && ( - } - > - {lines.map(line => ( -
  • {line}
  • - ))} -
    - )} + {groups.map(({ title: groupTitle, lines }, index) => ( + + {groupTitle && ( + + {groupTitle} + + )} + {lines.length > 0 && ( + } + > + {lines.map(line => ( +
  • {line}
  • + ))} +
    + )} +
    + ))}
    diff --git a/web/sdk/client/components/delete-blocked-dialog/index.ts b/web/sdk/client/components/delete-blocked-dialog/index.ts index 741193bd62..9f6a86ae03 100644 --- a/web/sdk/client/components/delete-blocked-dialog/index.ts +++ b/web/sdk/client/components/delete-blocked-dialog/index.ts @@ -1,4 +1,5 @@ export { + DeleteBlockedContent, DeleteBlockedDialog, DeleteBlockedDialogRoot } from './delete-blocked-dialog'; diff --git a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.module.css b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.module.css new file mode 100644 index 0000000000..fbafd75550 --- /dev/null +++ b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.module.css @@ -0,0 +1,3 @@ +.content { + width: 480px !important; +} 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 new file mode 100644 index 0000000000..a3de7c3923 --- /dev/null +++ b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { + AlertDialog, + Button, + Checkbox, + Field, + Flex, + Input, + Skeleton, + Text, + toastManager +} from '@raystack/apsara'; +import type { Slot } from '../../../shared/types'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { + GENERIC_DELETE_BLOCKED_MESSAGE, + toUserDeleteBlocked +} from '../../utils/delete-blockers'; +import { DeleteBlockedContent } from '../delete-blocked-dialog'; +import { handleConnectError } from '~/utils/error'; +import styles from './delete-user-dialog.module.css'; + +const TITLE = 'Delete Account'; + +const showErrorToast = () => + toastManager.add({ + title: 'Something went wrong', + description: 'Please try again later or contact support.', + type: 'error' + }); + +export type DeleteUserDialogHandle = ReturnType< + typeof AlertDialog.createHandle +>; + +const createDeleteUserDialogHandle = (): DeleteUserDialogHandle => + AlertDialog.createHandle(); + +export interface DeleteUserDialogProps { + /** A trigger that stays mounted while the dialog is open. */ + trigger?: Slot<{ onClick: () => void }>; + /** Opens the dialog from elsewhere, such as a menu that unmounts when it closes. */ + handle?: DeleteUserDialogHandle; + /** Called after the account is deleted. The host app decides where to go. */ + onDeleted: () => void; +} + +/** + * The whole account delete flow. It opens first and checks inside, so any + * trigger can open it. It never shows the confirmation while the delete is + * blocked. + */ +export function DeleteUserDialog({ + trigger, + handle, + onDeleted +}: DeleteUserDialogProps) { + const [ownHandle] = useState(createDeleteUserDialogHandle); + const dialogHandle = handle ?? ownHandle; + return ( + <> + {trigger?.({ onClick: () => dialogHandle.open(null) })} + + + + + + + ); +} + +DeleteUserDialog.createHandle = createDeleteUserDialogHandle; + +interface DeleteUserFlowProps { + handle: DeleteUserDialogHandle; + onDeleted: () => void; +} + +// Mounts only while the dialog is open, so every open runs a new check. +function DeleteUserFlow({ handle, onDeleted }: DeleteUserFlowProps) { + const { data: check, error, refetch } = useQuery( + FrontierServiceQueries.checkCurrentUserDelete, + {}, + { retry: false, gcTime: 0, refetchOnWindowFocus: false } + ); + + useEffect(() => { + if (error) { + handle.close(); + showErrorToast(); + } + }, [error, handle]); + + // A refused delete has no grouping by organization, so check again. A + // failed check closes the dialog through the error effect above. + async function onDeleteRefused() { + const { data, isSuccess } = await refetch(); + if (isSuccess && data.canDelete) { + toastManager.add({ + title: 'Unable to delete account', + description: GENERIC_DELETE_BLOCKED_MESSAGE, + type: 'error' + }); + } + } + + if (!check) { + return ; + } + if (!check.canDelete) { + return ( + handle.close()} + /> + ); + } + return ( + { + handle.close(); + onDeleted(); + }} + onDeleteRefused={onDeleteRefused} + /> + ); +} + +function DeleteUserChecking() { + return ( + <> + + {TITLE} + + + + + + + + + + + + ); +} + +interface DeleteUserFormProps { + onDeleted: () => void; + onDeleteRefused: () => void; +} + +function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { + const { user } = useFrontier(); + const [email, setEmail] = useState(''); + const [isAcknowledged, setIsAcknowledged] = useState(false); + const { mutateAsync: deleteCurrentUser, isPending } = useMutation( + FrontierServiceQueries.deleteCurrentUser + ); + const isEmailConfirmed = + !!user?.email && email.trim().toLowerCase() === user.email.toLowerCase(); + + async function onDelete() { + try { + await deleteCurrentUser({}); + toastManager.add({ title: 'Account deleted', type: 'success' }); + onDeleted(); + } catch (error) { + handleConnectError(error, { + FailedPrecondition: onDeleteRefused, + Default: showErrorToast + }); + } + } + + return ( + <> + + {TITLE} + + + + + This action is irreversible. Deleting your account will permanently + remove your account and associated data. Do you want to continue? + + + setEmail(event.target.value)} + placeholder="Provide your email" + data-test-id="frontier-sdk-delete-account-email-input" + /> + + + setIsAcknowledged(checked)} + data-test-id="frontier-sdk-delete-account-checkbox" + /> + + I acknowledge I understand that my account and its data will be + deleted and want to proceed. + + + + + + + + + + ); +} + +function DialogCancel({ disabled }: { disabled?: boolean }) { + return ( + + Cancel + + } + /> + ); +} diff --git a/web/sdk/client/components/delete-user-dialog/index.ts b/web/sdk/client/components/delete-user-dialog/index.ts new file mode 100644 index 0000000000..4a6ddf10c7 --- /dev/null +++ b/web/sdk/client/components/delete-user-dialog/index.ts @@ -0,0 +1,5 @@ +export { DeleteUserDialog } from './delete-user-dialog'; +export type { + DeleteUserDialogProps, + DeleteUserDialogHandle +} from './delete-user-dialog'; diff --git a/web/sdk/client/hooks/useDeleteCheck.ts b/web/sdk/client/hooks/useDeleteCheck.ts index 4919a75ce5..692249775c 100644 --- a/web/sdk/client/hooks/useDeleteCheck.ts +++ b/web/sdk/client/hooks/useDeleteCheck.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; import { AlertDialog, toastManager } from '@raystack/apsara'; import type { Blocker } from '@raystack/proton/frontier'; -import type { DeleteBlocker, DeleteBlockedPayload } from '../../shared/types'; +import type { DeleteBlocker } from '../../shared/types'; import type { DeleteBlockedDialogHandle } from '../components/delete-blocked-dialog'; import { GENERIC_DELETE_BLOCKED_MESSAGE, @@ -13,7 +13,7 @@ import { useTerminology } from './useTerminology'; import { handleConnectError } from '~/utils/error'; export interface UseDeleteCheckOptions { - entity: DeleteBlockedPayload['entity']; + entity: 'organization' | 'project'; check: ( payload: TPayload ) => Promise<{ canDelete: boolean; blockers: Blocker[] }>; diff --git a/web/sdk/client/index.ts b/web/sdk/client/index.ts index 778ced3715..c0d6724cef 100644 --- a/web/sdk/client/index.ts +++ b/web/sdk/client/index.ts @@ -5,6 +5,11 @@ export { AuthContainer } from './components/auth-container'; export { AuthHeader } from './components/auth-header'; export { DeleteBlockedDialog } from './components/delete-blocked-dialog'; export type { DeleteBlockedDialogProps } from './components/delete-blocked-dialog'; +export { DeleteUserDialog } from './components/delete-user-dialog'; +export type { + DeleteUserDialogProps, + DeleteUserDialogHandle +} from './components/delete-user-dialog'; export { describeAuthError } from './utils/auth-error'; export type { AuthRejection } from './utils/auth-error'; diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index 1adff5d34e..9ed26bc300 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -1,6 +1,8 @@ import { ConnectError } from '@connectrpc/connect'; import type { Blocker } from '@raystack/proton/frontier'; -import type { DeleteBlocker } from '../../shared/types'; +import type { DeleteBlocker, DeleteBlockedPayload } from '../../shared/types'; + +export const SOLE_OWNER_WITH_MEMBERS = 'SOLE_OWNER_WITH_MEMBERS'; export const GENERIC_DELETE_BLOCKED_MESSAGE = 'Something is blocking the delete right now. Please try again later or contact support.'; @@ -51,6 +53,32 @@ export function toDeleteBlockers(blockers: Blocker[]): DeleteBlocker[] { return blockers.map(({ type, subject, count }) => ({ type, subject, count })); } +interface UserDeleteCheck { + organizationsToDelete: { id: string; title: string; blockers: Blocker[] }[]; + organizationsToLeave: { id: string; title: string; blockers: Blocker[] }[]; +} + +export function toUserDeleteBlocked({ + organizationsToDelete, + organizationsToLeave +}: UserDeleteCheck): Extract { + return { + entity: 'user', + organizationsToDelete: organizationsToDelete + .filter(org => org.blockers.length > 0) + .map(({ id, title, blockers }) => ({ + id, + title, + blockers: toDeleteBlockers(blockers) + })), + soleOwnerOf: organizationsToLeave + .filter(org => + org.blockers.some(({ type }) => type === SOLE_OWNER_WITH_MEMBERS) + ) + .map(({ id, title }) => ({ id, title })) + }; +} + function preconditionFailures(err: ConnectError) { return err.details.filter( detail => diff --git a/web/sdk/client/views/profile/index.ts b/web/sdk/client/views/profile/index.ts index b58061c0dd..85cc620ba6 100644 --- a/web/sdk/client/views/profile/index.ts +++ b/web/sdk/client/views/profile/index.ts @@ -1 +1,2 @@ export { ProfileView } from './profile-view'; +export type { ProfileViewProps } from './profile-view'; diff --git a/web/sdk/client/views/profile/profile-view.tsx b/web/sdk/client/views/profile/profile-view.tsx index 7695ce483e..638ea09aaf 100644 --- a/web/sdk/client/views/profile/profile-view.tsx +++ b/web/sdk/client/views/profile/profile-view.tsx @@ -16,12 +16,14 @@ import { Field, Input, Skeleton, + Text, toastManager } from '@raystack/apsara'; import { useFrontier } from '../../contexts/FrontierContext'; import { ViewContainer } from '../../components/view-container'; import { ViewHeader } from '../../components/view-header'; import { ImageUpload } from '../../components/image-upload'; +import { DeleteUserDialog } from '../../components/delete-user-dialog'; import styles from './profile-view.module.css'; import { handleConnectError } from '~/utils/error'; @@ -55,7 +57,12 @@ const profileSchema = yup type FormData = yup.InferType; -export function ProfileView() { +export interface ProfileViewProps { + /** Shows the Delete account section. Called after the account is deleted. */ + onAccountDeleted?: () => void; +} + +export function ProfileView({ onAccountDeleted }: ProfileViewProps = {}) { const { user, isUserLoading: isLoading } = useFrontier(); const queryClient = useQueryClient(); @@ -188,6 +195,38 @@ export function ProfileView() { )} + + {/* Delete section */} + {onAccountDeleted && ( + + {isLoading ? ( + <> + + + + ) : ( + <> + + If you want to permanently delete your account and all of + its data. + + ( + + )} + onDeleted={onAccountDeleted} + /> + + )} + + )} diff --git a/web/sdk/shared/types.ts b/web/sdk/shared/types.ts index bcb28924e4..709bca6bd4 100644 --- a/web/sdk/shared/types.ts +++ b/web/sdk/shared/types.ts @@ -72,10 +72,17 @@ export interface DeleteBlocker { count?: number; } -export type DeleteBlockedPayload = { - entity: 'organization' | 'project'; - blockers: DeleteBlocker[]; -}; +export type DeleteBlockedPayload = + | { entity: 'organization' | 'project'; blockers: DeleteBlocker[] } + | { + entity: 'user'; + organizationsToDelete: { + id: string; + title: string; + blockers: DeleteBlocker[]; + }[]; + soleOwnerOf: { id: string; title: string }[]; + }; export type DeleteBlockedContext = DeleteBlockedPayload & { entityLabel: string; From 681bd00a6d8b8463ae67b6e878021a150ff23c23 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 3 Nov 2026 21:15:14 +0530 Subject: [PATCH 02/12] feat(sdk): put the account delete button behind enableUserDelete ProfileView shows the Delete Account section only when enableUserDelete is set. It is off by default, so a host app turns it on when it is ready to handle the delete with onAccountDeleted. The account delete dialogs follow the design: the ownership dialog has two paragraphs and no list, and the confirmation has the new field label and acknowledgement text. --- .../delete-blocked-dialog.module.css | 4 ++++ .../delete-blocked-dialog.tsx | 10 +++++++--- .../delete-user-dialog/delete-user-dialog.tsx | 7 +++---- web/sdk/client/views/profile/profile-view.tsx | 15 ++++++++++----- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css index dde5c86382..dcf0d7e10f 100644 --- a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css @@ -2,6 +2,10 @@ width: 480px !important; } +.description { + white-space: pre-line; +} + .list { margin: 0; padding-left: var(--rs-space-5); diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx index ed825eb607..cfecfa17ba 100644 --- a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx @@ -56,8 +56,8 @@ function defaultContent( if (context.soleOwnerOf.length > 0) { return { title: `Transfer ${org} ownership`, - description: `You're the only owner of one or more ${t.organization({ case: 'lower', plural: true })}. Before deleting your account, transfer ownership of each affected ${org} to another member. Once ownership has been transferred, you can proceed with account deletion.`, - groups: [{ lines: context.soleOwnerOf.map(({ title }) => title) }] + description: `You’re the only owner of one or more ${t.organization({ case: 'lower', plural: true })}. Before deleting your account, transfer ownership of each affected ${org} to another member.\n\nOnce ownership has been transferred, you can proceed with account deletion.`, + groups: [] }; } return { @@ -161,7 +161,11 @@ export function DeleteBlockedDialog({ - + {description ?? content.description} {groups.map(({ title: groupTitle, lines }, index) => ( 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 a3de7c3923..75641862e6 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 @@ -188,13 +188,12 @@ function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { This action is irreversible. Deleting your account will permanently remove your account and associated data. Do you want to continue? - + setEmail(event.target.value)} - placeholder="Provide your email" data-test-id="frontier-sdk-delete-account-email-input" /> @@ -205,8 +204,8 @@ function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { data-test-id="frontier-sdk-delete-account-checkbox" /> - I acknowledge I understand that my account and its data will be - deleted and want to proceed. + I acknowledge that I understand my account and associated data + will be permanently deleted and want to proceed diff --git a/web/sdk/client/views/profile/profile-view.tsx b/web/sdk/client/views/profile/profile-view.tsx index 638ea09aaf..edbb853b1b 100644 --- a/web/sdk/client/views/profile/profile-view.tsx +++ b/web/sdk/client/views/profile/profile-view.tsx @@ -58,11 +58,16 @@ const profileSchema = yup type FormData = yup.InferType; export interface ProfileViewProps { - /** Shows the Delete account section. Called after the account is deleted. */ + /** Shows the Delete Account section. Off by default. */ + enableUserDelete?: boolean; + /** Called after the account is deleted. The host app decides where to go. */ onAccountDeleted?: () => void; } -export function ProfileView({ onAccountDeleted }: ProfileViewProps = {}) { +export function ProfileView({ + enableUserDelete = false, + onAccountDeleted +}: ProfileViewProps = {}) { const { user, isUserLoading: isLoading } = useFrontier(); const queryClient = useQueryClient(); @@ -197,7 +202,7 @@ export function ProfileView({ onAccountDeleted }: ProfileViewProps = {}) { {/* Delete section */} - {onAccountDeleted && ( + {enableUserDelete && ( {isLoading ? ( <> @@ -218,10 +223,10 @@ export function ProfileView({ onAccountDeleted }: ProfileViewProps = {}) { onClick={onClick} data-test-id="frontier-sdk-delete-account-open-btn" > - Delete account + Delete Account )} - onDeleted={onAccountDeleted} + onDeleted={() => onAccountDeleted?.()} /> )} From f6f23fa78de091c70652fbf7d59380e17e92680b Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 3 Nov 2026 22:27:15 +0530 Subject: [PATCH 03/12] feat(sdk): check the account delete before opening the dialog A click on Delete Account now runs CheckCurrentUserDelete first. The trigger slot gets loading, so the button is disabled and shows a spinner with its label while the check runs. Then the ownership, blocked or confirmation dialog opens. A failed check shows a toast. The handle from DeleteUserDialog.createHandle() is now { open() }. It runs the same check, so a menu item that has already closed can still start the delete. --- .../delete-user-dialog/delete-user-dialog.tsx | 208 +++++++++--------- web/sdk/client/views/profile/profile-view.tsx | 5 +- 2 files changed, 106 insertions(+), 107 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 75641862e6..84cbeb1472 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,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useMutation, useQuery } from '@connectrpc/connect-query'; +import { useCallback, useEffect, useState } from 'react'; +import { useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries } from '@raystack/proton/frontier'; import { AlertDialog, @@ -10,11 +10,10 @@ import { Field, Flex, Input, - Skeleton, Text, toastManager } from '@raystack/apsara'; -import type { Slot } from '../../../shared/types'; +import type { DeleteBlockedPayload, Slot } from '../../../shared/types'; import { useFrontier } from '../../contexts/FrontierContext'; import { GENERIC_DELETE_BLOCKED_MESSAGE, @@ -24,8 +23,6 @@ import { DeleteBlockedContent } from '../delete-blocked-dialog'; import { handleConnectError } from '~/utils/error'; import styles from './delete-user-dialog.module.css'; -const TITLE = 'Delete Account'; - const showErrorToast = () => toastManager.add({ title: 'Something went wrong', @@ -33,73 +30,86 @@ const showErrorToast = () => type: 'error' }); -export type DeleteUserDialogHandle = ReturnType< - typeof AlertDialog.createHandle ->; +type DeleteUserPayload = + | { step: 'confirm' } + | { + step: 'blocked'; + blocked: Extract; + }; + +export interface DeleteUserDialogHandle { + /** Runs the delete check, then opens the matching dialog. */ + open: () => void; +} + +// Each handle starts the delete of the DeleteUserDialog it is passed to. +const deleteStarters = new WeakMap void>(); -const createDeleteUserDialogHandle = (): DeleteUserDialogHandle => - AlertDialog.createHandle(); +const createDeleteUserDialogHandle = (): DeleteUserDialogHandle => { + const handle: DeleteUserDialogHandle = { + open: () => deleteStarters.get(handle)?.() + }; + return handle; +}; export interface DeleteUserDialogProps { - /** A trigger that stays mounted while the dialog is open. */ - trigger?: Slot<{ onClick: () => void }>; - /** Opens the dialog from elsewhere, such as a menu that unmounts when it closes. */ + /** A trigger that stays mounted. `loading` is true while the delete check runs. */ + trigger?: Slot<{ onClick: () => void; loading: boolean }>; + /** Starts the delete from elsewhere, such as a menu that unmounts when it closes. */ handle?: DeleteUserDialogHandle; /** Called after the account is deleted. The host app decides where to go. */ onDeleted: () => void; } /** - * The whole account delete flow. It opens first and checks inside, so any - * trigger can open it. It never shows the confirmation while the delete is - * blocked. + * The whole account delete flow. A click runs the delete check, then opens + * the ownership, blocked or confirmation dialog. It never shows the + * confirmation while the delete is blocked. */ export function DeleteUserDialog({ trigger, handle, onDeleted }: DeleteUserDialogProps) { - const [ownHandle] = useState(createDeleteUserDialogHandle); - const dialogHandle = handle ?? ownHandle; - return ( - <> - {trigger?.({ onClick: () => dialogHandle.open(null) })} - - - - - - + const [dialogHandle] = useState(() => + AlertDialog.createHandle() ); -} - -DeleteUserDialog.createHandle = createDeleteUserDialogHandle; + const { mutateAsync: checkCurrentUserDelete, isPending: isChecking } = + useMutation(FrontierServiceQueries.checkCurrentUserDelete); -interface DeleteUserFlowProps { - handle: DeleteUserDialogHandle; - onDeleted: () => void; -} - -// Mounts only while the dialog is open, so every open runs a new check. -function DeleteUserFlow({ handle, onDeleted }: DeleteUserFlowProps) { - const { data: check, error, refetch } = useQuery( - FrontierServiceQueries.checkCurrentUserDelete, - {}, - { retry: false, gcTime: 0, refetchOnWindowFocus: false } - ); - - useEffect(() => { - if (error) { - handle.close(); + // Opens the dialog for what the check found. Returns whether nothing + // blocks the delete. + const checkAndOpen = useCallback(async () => { + try { + const check = await checkCurrentUserDelete({}); + dialogHandle.openWithPayload( + check.canDelete + ? { step: 'confirm' } + : { step: 'blocked', blocked: toUserDeleteBlocked(check) } + ); + return check.canDelete; + } catch { + dialogHandle.close(); showErrorToast(); + return false; } - }, [error, handle]); + }, [checkCurrentUserDelete, dialogHandle]); + + const startDelete = useCallback(() => { + void checkAndOpen(); + }, [checkAndOpen]); - // A refused delete has no grouping by organization, so check again. A - // failed check closes the dialog through the error effect above. + useEffect(() => { + if (!handle) return; + deleteStarters.set(handle, startDelete); + return () => { + deleteStarters.delete(handle); + }; + }, [handle, startDelete]); + + // A refused delete has no grouping by organization, so check again. async function onDeleteRefused() { - const { data, isSuccess } = await refetch(); - if (isSuccess && data.canDelete) { + if (await checkAndOpen()) { toastManager.add({ title: 'Unable to delete account', description: GENERIC_DELETE_BLOCKED_MESSAGE, @@ -108,47 +118,39 @@ function DeleteUserFlow({ handle, onDeleted }: DeleteUserFlowProps) { } } - if (!check) { - return ; - } - if (!check.canDelete) { - return ( - handle.close()} - /> - ); - } - return ( - { - handle.close(); - onDeleted(); - }} - onDeleteRefused={onDeleteRefused} - /> - ); -} - -function DeleteUserChecking() { return ( <> - - {TITLE} - - - - - - - - - - + {trigger?.({ onClick: startDelete, loading: isChecking })} + + {({ payload: rawPayload }) => { + const payload = rawPayload as DeleteUserPayload | undefined; + if (!payload) return null; + return ( + + {payload.step === 'blocked' ? ( + dialogHandle.close()} + /> + ) : ( + { + dialogHandle.close(); + onDeleted(); + }} + onDeleteRefused={onDeleteRefused} + /> + )} + + ); + }} + ); } +DeleteUserDialog.createHandle = createDeleteUserDialogHandle; + interface DeleteUserFormProps { onDeleted: () => void; onDeleteRefused: () => void; @@ -180,7 +182,7 @@ function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { return ( <> - {TITLE} + Delete Account @@ -211,7 +213,18 @@ function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { - + + Cancel + + } + /> - } - /> - ); -} diff --git a/web/sdk/client/views/profile/profile-view.tsx b/web/sdk/client/views/profile/profile-view.tsx index edbb853b1b..848f478815 100644 --- a/web/sdk/client/views/profile/profile-view.tsx +++ b/web/sdk/client/views/profile/profile-view.tsx @@ -216,11 +216,14 @@ export function ProfileView({ its data. ( + trigger={({ onClick, loading }) => ( )} - onDeleted={() => onAccountDeleted?.()} + onDeleted={() => onDeleteSuccess?.()} /> )} From 98bcbbd69f739136dd29d6921f14be64593b9d3d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 5 Nov 2026 10:47:03 +0530 Subject: [PATCH 09/12] chore(client-demo): turn on account delete in the profile page Passes enableUserDelete to ProfileView and goes to /login after the delete. --- web/apps/client-demo/src/pages/settings/Profile.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/apps/client-demo/src/pages/settings/Profile.tsx b/web/apps/client-demo/src/pages/settings/Profile.tsx index 636679c78a..f8010e14bc 100644 --- a/web/apps/client-demo/src/pages/settings/Profile.tsx +++ b/web/apps/client-demo/src/pages/settings/Profile.tsx @@ -1,5 +1,12 @@ import { ProfileView } from '@raystack/frontier/client'; export default function Profile() { - return ; + return ( + { + window.location.href = '/login'; + }} + /> + ); } From 524fa2ac90e3773cbca27213abf57546db89e14c Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 5 Nov 2026 12:30:28 +0530 Subject: [PATCH 10/12] refactor(sdk): drop the payload cast in DeleteUserDialog The dialog handle already types payload as DeleteUserPayload. --- .../components/delete-user-dialog/delete-user-dialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 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 ba95845418..e869ec5f4a 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 @@ -109,8 +109,7 @@ export function DeleteUserDialog({ <> {trigger?.({ onClick: startDelete, loading: isChecking })} - {({ payload: rawPayload }) => { - const payload = rawPayload as DeleteUserPayload | undefined; + {({ payload }) => { if (!payload) return null; return payload.step === 'blocked' ? ( Date: Thu, 5 Nov 2026 12:30:28 +0530 Subject: [PATCH 11/12] fix(sdk): label the account delete acknowledgement checkbox The checkbox row renders as a label, so screen readers read the acknowledgement as the checkbox name and clicking the text ticks the box. --- .../client/components/delete-user-dialog/delete-user-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e869ec5f4a..6d35b8d1ab 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 @@ -183,7 +183,7 @@ function DeleteUserForm({ onDeleted, onDeleteRefused }: DeleteUserFormProps) { data-test-id="frontier-sdk-delete-account-email-input" /> - + }> setIsAcknowledged(checked)} From bbb995dfe6ba36ba183ed1504adf0195a44aa4e8 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 5 Nov 2026 13:44:55 +0530 Subject: [PATCH 12/12] fix(sdk): show the ownership transfer last in the account dialog The account dialog now lists workspace blockers before asking for an ownership transfer, so the transfer is the last step. When the account delete is blocked in more than one workspace, the default lines say "your workspaces" instead of "this workspace". The workspace and project dialogs are unchanged. --- .../delete-blocked-dialog.tsx | 14 +++++++++----- web/sdk/client/utils/delete-blockers.ts | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx index 7efffd0ec7..296ce455d6 100644 --- a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx +++ b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx @@ -45,7 +45,10 @@ function defaultContent( }; case 'user': { const org = t.organization({ case: 'lower' }); - if (context.soleOwnerOf.length > 0) { + const blockers = context.organizationsToDelete.flatMap( + organization => organization.blockers + ); + if (blockers.length === 0 && context.soleOwnerOf.length > 0) { return { title: `Transfer ${org} ownership`, description: `You’re the only owner of one or more ${t.organization({ @@ -55,9 +58,6 @@ function defaultContent( lines: [] }; } - const blockers = context.organizationsToDelete.flatMap( - organization => organization.blockers - ); const orgs = t.organization({ case: 'lower', plural: context.organizationsToDelete.length > 1 @@ -144,8 +144,12 @@ export function DeleteBlockedDialog({ ); } + const severalOrgs = + context.entity === 'user' && context.organizationsToDelete.length > 1; const wording = { - organizationLabel: t.organization({ case: 'lower' }), + organizationRef: severalOrgs + ? `your ${t.organization({ case: 'lower', plural: true })}` + : `this ${t.organization({ case: 'lower' })}`, basePlanTitle: basePlan?.title }; const content = defaultContent(context, t, messages, wording); diff --git a/web/sdk/client/utils/delete-blockers.ts b/web/sdk/client/utils/delete-blockers.ts index 9ed26bc300..e125a4125d 100644 --- a/web/sdk/client/utils/delete-blockers.ts +++ b/web/sdk/client/utils/delete-blockers.ts @@ -7,22 +7,23 @@ export const SOLE_OWNER_WITH_MEMBERS = 'SOLE_OWNER_WITH_MEMBERS'; export const GENERIC_DELETE_BLOCKED_MESSAGE = 'Something is blocking the delete right now. Please try again later or contact support.'; -// Words that come from the host app: what it calls an organization -// and what it calls its base plan. +// How the lines name the organization ("this workspace", or "your +// workspaces" for an account delete that several of them block) and +// what the host app calls its base plan. export interface BlockerWording { - organizationLabel: string; + organizationRef: string; basePlanTitle?: string; } const DEFAULT_LINES: Record string> = { - ACTIVE_SUBSCRIPTION: ({ organizationLabel, basePlanTitle }) => + ACTIVE_SUBSCRIPTION: ({ organizationRef, basePlanTitle }) => `Active subscription: Downgrade your subscription to the ${ basePlanTitle || 'standard plan' - } before deleting this ${organizationLabel}.`, - UNPAID_INVOICE: ({ organizationLabel }) => - `Outstanding invoice: Pay all outstanding invoices before deleting this ${organizationLabel}.`, - NEGATIVE_TOKEN_BALANCE: ({ organizationLabel }) => - `Negative token balance: Purchase enough tokens to clear your outstanding token balance before deleting this ${organizationLabel}.`, + } before deleting ${organizationRef}.`, + UNPAID_INVOICE: ({ organizationRef }) => + `Outstanding invoice: Pay all outstanding invoices before deleting ${organizationRef}.`, + NEGATIVE_TOKEN_BALANCE: ({ organizationRef }) => + `Negative token balance: Purchase enough tokens to clear your outstanding token balance before deleting ${organizationRef}.`, RESOURCES_EXIST: () => 'Existing resources: Delete the remaining resources, then try again.' };