Skip to content

feat(sdk): user delete blockers UI - #2042

Open
rohanchkrabrty wants to merge 6 commits into
feat/project-delete-blocked-dialogfrom
feat/account-delete-dialog
Open

rohanchkrabrty wants to merge 6 commits into
feat/project-delete-blocked-dialogfrom
feat/account-delete-dialog

Conversation

@rohanchkrabrty

@rohanchkrabrty rohanchkrabrty commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • DeleteUserDialog runs the whole account delete. A click runs CheckCurrentUserDelete first, then opens the matching dialog. The trigger slot gets { onClick, loading }, so the trigger button is disabled and shows a spinner, with its label unchanged, while the check runs. This matches the org and project delete buttons.
  • A host can also start the delete with handle.open(), using a handle from DeleteUserDialog.createHandle(). It runs the same check, so a menu item that has already closed can still start the delete. onDeleted is required: the SDK does not navigate.
  • When the user is the only owner of an organization with other members, the dialog asks for an ownership transfer. When an organization that would be deleted with the account is blocked, it lists the blockers for each one. Both go through the deleteBlockedDialog slot with entity: 'user'.
  • Otherwise it shows the Delete Account confirmation. Delete stays disabled until the user types their email and ticks the acknowledgement.
  • A refused delete checks again, because the refusal has no grouping by organization.
  • ProfileView takes enableUserDelete (off by default) and onAccountDeleted. With the flag on, the view shows a Delete Account section. A host app turns it on when it handles the delete.

Stacked on #2041.

Test plan

  • tsc --noEmit shows no new errors, eslint, pnpm build
  • Sole owner of a workspace with other members: the ownership dialog opens
  • Sole member of a workspace with an active paid subscription: the blocked dialog lists it under that workspace
  • Delete Account button: disabled with a spinner while the check runs, then the matching dialog opens
  • Clean account: the confirmation enables Delete only after the email and the tick, then onDeleted runs
  • ProfileView without enableUserDelete shows no Delete Account section

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
frontier Ready Ready Preview Nov 4, 2026 12:41pm UTC

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Summary

Summary by CodeRabbit

  • New Features
    • Added an optional account-deletion section to the profile view, disabled by default, with a confirmation step requiring an email match and acknowledgment.
    • Added deletion guidance tailored to account, organization, and project blockers, including ownership-transfer requirements and grouped organization-specific details.
    • Added success and error notifications during the deletion process, including updated eligibility checks when deletion is blocked.

The SDK adds a user account-deletion dialog with eligibility checks, confirmation requirements, and user-specific blocker details. The profile view can optionally expose the dialog and receive a callback after deletion.

Changes: User deletion

Layer / File(s) Summary
User deletion blocker content
web/sdk/shared/types.ts, web/sdk/client/utils/delete-blockers.ts, web/sdk/client/hooks/useDeleteCheck.ts, web/sdk/client/components/delete-blocked-dialog/*
The blocked payload supports user outcomes. Conversion creates groups for organizations to delete and organizations where the user must transfer ownership. The dialog renders grouped blockers and ownership-transfer guidance.
Deletion dialog and confirmation
web/sdk/client/components/delete-user-dialog/*, web/sdk/client/index.ts
The dialog checks eligibility before opening, renders blocker details or a confirmation form, and submits deletion when the normalized email matches and acknowledgment is checked. It exposes a trigger and external handle.
Profile view integration
web/sdk/client/views/profile/*
The profile view accepts optional deletion enablement and a completion callback. When enabled, it displays loading content or an account-deletion section with a dialog trigger.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Possibly related PRs

  • raystack/frontier#2025: Adds SDK delete-blocker components and conversion patterns that this change extends for user deletion.
  • raystack/frontier#2027: Defines user deletion checks and organization outcomes converted into the blocker payload.
  • raystack/frontier#2031: Adds the current-user deletion check and deletion RPCs used by the dialog.

Suggested reviewers: rohilsurana

Merge Risk: Low · up to 98b1e

The deletion flow retains several usability and edge-case defects: blocker guidance can be incomplete, retries can overlap, and callback failures can produce misleading feedback. These are bounded risks suitable for explicit owner acceptance or follow-up.

Pre-merge checks: Passed 2
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@coveralls

coveralls commented Nov 3, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41831366907

Coverage remained the same at 56.053%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 42189
Covered Lines: 23648
Line Coverage: 56.05%
Coverage Strength: 19.7 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 825809c and 14323c2.

Files selected for processing: 12
  • web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.module.css
  • web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx
  • web/sdk/client/components/delete-blocked-dialog/index.ts
  • web/sdk/client/components/delete-user-dialog/delete-user-dialog.module.css
  • web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx
  • web/sdk/client/components/delete-user-dialog/index.ts
  • web/sdk/client/hooks/useDeleteCheck.ts
  • web/sdk/client/index.ts
  • web/sdk/client/utils/delete-blockers.ts
  • web/sdk/client/views/profile/index.ts
  • web/sdk/client/views/profile/profile-view.tsx
  • web/sdk/shared/types.ts

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Comment on lines +53 to +58
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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
groups: []
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

rg -n -C 3 'organizationsToDelete|OrganizationsToDelete|soleOwnerOf|SOLE_OWNER_WITH_MEMBERS' web/sdk test --glob '*.{ts,tsx,go}' | head -190

Repository: raystack/frontier

Length of output: 6522


Script executed:

rg -n -F --glob '*.go' -e 'CheckCurrentUserDelete' -e 'OrganizationsToLeave' -e 'OrganizationsToDelete' -- internal/ pkg/ core/ cmd/ 2>/dev/null || test "$?" -eq 1
echo '---'
rg -n -F -e 'organizationsToLeave' -e 'organizationsToDelete' -e 'soleOwnerOf' -e 'deleteBlockedDialog' -e 'DeleteBlockedDialog' -- web/sdk/client web/sdk/shared || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 10173


Script executed:

sed -n '570,630p' core/deleter/service.go
echo '--- host ---'
sed -n '76,125p' web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx
echo '--- user dialog ---'
rg -n -F -e 'DeleteBlockedDialog' -e 'toUserDeleteBlocked' -e 'isEmpty' -e 'organizationsToLeave' -e 'soleOwnerOf' -- web/sdk/client/components/delete-user-dialog web/sdk/client/hooks/useDeleteCheck.ts web/sdk/client/utils/delete-blockers.ts || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 3857


Show organization delete blockers in the ownership-transfer state.

When soleOwnerOf is nonempty, the user branch returns groups: []. If the same check also returns an organizationsToDelete entry with blockers, the default dialog hides them. The copy also says the user can proceed after the transfer, so the user finds the blocker only on the next attempt.

Suggested fix
--- "a/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx"
+++ "b/web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx"
@@ -50,12 +50,15 @@
       };
     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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
-          groups: []
+          groups: context.organizationsToDelete.map(({ title, blockers }) => ({
+            title,
+            lines: blockerLines(blockers, messages, wording)
+          }))
         };
       }
       return {
         title: 'Unable to delete account',

Also show the "you can proceed" sentence only when organizationsToDelete is empty.

Committable suggestion

IMPORTANT
Review the replacement and indentation, then test before committing.

Suggested change
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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
groups: []
};
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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
groups: context.organizationsToDelete.map(({ title, blockers }) => ({
title,
lines: blockerLines(blockers, messages, wording)
}))
};

Comment on lines +69 to +78

const checkAndOpen = useCallback(async () => {
try {
const check = await checkCurrentUserDelete({});
dialogHandle.openWithPayload(
check.canDelete
? { step: 'confirm' }
: { step: 'blocked', blocked: toUserDeleteBlocked(check) }
);
return check.canDelete;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

rg -n -C 3 'checkCurrentUserDelete|checkAndOpen|startDelete\(|isChecking|createHandle' web/sdk/client/components web/sdk/client/hooks | head -200

Repository: raystack/frontier

Length of output: 7269


Script executed:

set -e
echo "== profile-view trigger usage =="
rg -n -C 6 'DeleteUserDialog' web/sdk/client/views/profile/profile-view.tsx || test "$?" -eq 1
echo "== useDeleteCheck full =="
sed -n '1,80p' web/sdk/client/hooks/useDeleteCheck.ts
echo "== org delete dialog checkAndOpen / guard =="
rg -n -C 4 'checkWorkspaceDelete|checkOrganizationDelete|isChecking|isPending|useDeleteCheck' web/sdk/client/components web/sdk/client/views || test "$?" -eq 1
echo "== checkCurrentUserDelete usage elsewhere =="
rg -n -F 'checkCurrentUserDelete' web/sdk || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 39020


Web query:

TanStack Query v5 useMutation mutateAsync called again before previous mutation resolves promise resolution behavior

Result:

Sources consulted:
- query/packages/query-core/src/mutationObserver.ts at main · TanStack/query · GitHub — https://github.com/TanStack/query/blob/main/packages/query-core/src/mutationObserver.ts
- UNPKG — https://app.unpkg.com/%40tanstack/react-query%405.59.0/files/build/modern/useMutation.cjs.map
- query/packages/react-query/src/useMutation.ts at main · TanStack/query · GitHub — https://github.com/TanStack/query/blob/main/packages/react-query/src/useMutation.ts
- tanstack-query/docs/framework/react/guides/mutations.md at main · webmasterstack/tanstack-query · GitHub — https://github.com/webmasterstack/tanstack-query/blob/main/docs/framework/react/guides/mutations.md
- query/docs/framework/react/guides/mutations.md at main · TanStack/query · GitHub — https://github.com/TanStack/query/blob/main/docs/framework/react/guides/mutations.md
- …and 25 more

Discard superseded eligibility checks in checkAndOpen.

checkAndOpen applies every completed check. Overlapping checks can finish out of order, so an older result can replace the newer dialog state. Track the latest check and ignore results from older checks, including their errors.

Suggested fix
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+  const latestCheckId = useRef(0);
+
   const checkAndOpen = useCallback(async () => {
+    const checkId = ++latestCheckId.current;
     try {
       const check = await checkCurrentUserDelete({});
+      if (checkId !== latestCheckId.current) return false;
       dialogHandle.openWithPayload(
         check.canDelete
           ? { step: 'confirm' }
           : { step: 'blocked', blocked: toUserDeleteBlocked(check) }
       );
       return check.canDelete;
     } catch {
+      if (checkId !== latestCheckId.current) return false;
       dialogHandle.close();
       showErrorToast();
       return false;
     }
   }, [checkCurrentUserDelete, dialogHandle]);

Comment on lines +98 to +105
async function onDeleteRefused() {
if (await checkAndOpen()) {
toastManager.add({
title: 'Unable to delete account',
description: GENERIC_DELETE_BLOCKED_MESSAGE,
type: 'error'
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

rg -n -C 3 'FailedPrecondition: onDeleteRefused|checkCurrentUserDelete|DeleteCurrentUser|CheckCurrentUserDelete' web/sdk/client test --glob '*.{ts,tsx,go}' | head -170

Repository: raystack/frontier

Length of output: 11777


Script executed:

# Locate handleConnectError and its dispatch of FailedPrecondition
rg -n -F -e 'handleConnectError' -e 'FailedPrecondition' --glob '*.ts' --glob '*.tsx' web/sdk || test "$?" -eq 1
# Server side: DeleteCurrentUser and CheckCurrentUserDelete handlers
rg -n -F -e 'DeleteCurrentUser(' -e 'CheckCurrentUserDelete(' --glob '*.go' internal cmd pkg 2>/dev/null || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 9305


Script executed:

# Read the full dispatcher
nl -ba web/sdk/utils/error.ts | sed -n '1,80p'
# Read both server handlers
nl -ba internal/api/v1beta1connect/user.go | sed -n '410,470p'
# Locate service-level delete and check logic
rg -n -F -e 'CheckCurrentUserDelete' -e 'func (s *Service) Delete' -e 'FailedPrecondition' -e 'ErrFailedPrecondition' --glob '*.go' core internal/api pkg 2>/dev/null || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 21904


Script executed:

# Read the handler-side blocked error mapping
nl -ba internal/api/v1beta1connect/deleter.go | sed -n '1,140p'
# Locate the core deleter service and its check/delete functions
rg -n -F -e 'func (s *Service) CheckUserDelete' -e 'func (s *Service) DeleteUser' -e 'BlockedError' -e 'func (s *Service) CheckOrganizationDelete' --glob '*.go' core internal 2>/dev/null || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 9555


Script executed:

# Locate CheckUserDelete and DeleteUser definitions in the core deleter service
rg -n -F -e 'CheckUserDelete(' -e 'DeleteUser(' core/deleter/service.go
# Read the user delete path, including blocker construction
nl -ba core/deleter/service.go | sed -n '600,730p'

Repository: raystack/frontier

Length of output: 6108


Script executed:

# Read CheckUserDelete and its checkUser call
nl -ba core/deleter/service.go | sed -n '540,600p'
# Locate checkUser and allBlockers definitions
rg -n -F -e 'func (d Service) checkUser' -e 'func (c UserDeleteCheck) allBlockers' -e 'func (d Service) orgDeleteBlockers' core/deleter/ || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 2593


Script executed:

# Read orgDeleteBlockers, where fromProvider is consumed
nl -ba core/deleter/service.go | sed -n '852,900p'
# Read allBlockers and the UserDeleteCheck type
nl -ba core/deleter/deleter.go | sed -n '55,110p'

Repository: raystack/frontier

Length of output: 4094


Script executed:

# Locate and read invoiceBlockers
rg -n -F -e 'func (d Service) invoiceBlockers' core/deleter/service.go
# Read the body after its definition line; adjust the range once the line number is known
nl -ba core/deleter/service.go | sed -n '900,1000p'
# Read the check-to-PB transform in the handler
nl -ba internal/api/v1beta1connect/user.go | sed -n '474,500p'

Repository: raystack/frontier

Length of output: 6096


Script executed:

nl -ba core/deleter/service.go | sed -n '1000,1030p'

Repository: raystack/frontier

Length of output: 1454


Show the refusal's violations in the blocked step, not the confirmation.

When DeleteUser refuses, onDeleteRefused re-runs the check. The check reads local invoice rows, so it can return canDelete: true while the server blocks on a provider-side invoice. The dialog then shows the confirmation and the "Unable to delete account" toast together. The refusal's blockers are discarded.

Comment on lines +158 to +167
await deleteCurrentUser({});
toastManager.add({ title: 'Account deleted', type: 'success' });
onDeleted();
} catch (error) {
handleConnectError(error, {
FailedPrecondition: onDeleteRefused,
Default: showErrorToast
});
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

rg -n -F -e 'export function handleConnectError' -e 'export const handleConnectError' -- web/sdk || test "$?" -eq 1
rg -n -F -e '"~/*"' -e "'~/*'" -e '"~"' -- web/sdk --glob 'tsconfig*.json' --glob 'vite.config.*' --glob 'package.json' || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 584


Script executed:

nl -ba web/sdk/utils/error.ts | sed -n '1,60p'

Repository: raystack/frontier

Length of output: 2282


Script executed:

nl -ba web/sdk/utils/error.ts | sed -n '60,70p'

Repository: raystack/frontier

Length of output: 238


Move onDeleted() outside the try block.

onDelete calls onDeleted() inside the try block. If the host callback throws, the catch block shows the generic error toast after the "Account deleted" success toast. The account is already deleted at that point. Run the success path after the try/catch, so only the delete request is covered by the error handling:

Suggested fix
--- "a/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx"
+++ "b/web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx"
@@ -153,18 +153,19 @@
   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;
     }
+    toastManager.add({ title: 'Account deleted', type: 'success' });
+    onDeleted();
   }
 
   return (
     <>
Committable suggestion

IMPORTANT
Review the replacement and indentation, then test before committing.

Suggested change
await deleteCurrentUser({});
toastManager.add({ title: 'Account deleted', type: 'success' });
onDeleted();
} catch (error) {
handleConnectError(error, {
FailedPrecondition: onDeleteRefused,
Default: showErrorToast
});
}
}
await deleteCurrentUser({});
} catch (error) {
handleConnectError(error, {
FailedPrecondition: onDeleteRefused,
Default: showErrorToast
});
return;
}
toastManager.add({ title: 'Account deleted', type: 'success' });
onDeleted();
}

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.
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.
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.
DeleteBlockedDialog now renders its own AlertDialog.Content, so the
blocked step renders it directly and only the confirmation form is
wrapped in AlertDialog.Content.
The blockers prop draws one list with no heading. An account delete can be blocked in more than one workspace, so a host that words its own lines needs a heading for each workspace. The groups prop takes a title and blockers for each group.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 14323c2 and 98b1e09.

Files selected for processing: 5
  • web/sdk/client/components/delete-blocked-dialog/delete-blocked-dialog.tsx
  • web/sdk/client/components/delete-blocked-dialog/index.ts
  • web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx
  • web/sdk/client/hooks/useDeleteCheck.ts
  • web/sdk/client/utils/delete-blockers.ts

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Comment on lines +53 to +60
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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
groups: []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

cd web/sdk/client 2>/dev/null || true
rg -n -F -e 'soleOwnerOf' -e 'deleteBlockedDialog' -e 'organizationsToDelete' -e 'DeleteBlockedPayload' -- . ../../ 2>/dev/null | head -n 80 || test "$?" -eq 1
echo '---FILE---'
nl -ba components/delete-blocked-dialog/delete-blocked-dialog.tsx | sed -n '1,260p'

Repository: raystack/frontier

Length of output: 15308


Script executed:

cd web/sdk/client 2>/dev/null || true
nl -ba components/delete-user-dialog/delete-user-dialog.tsx | sed -n '1,240p'

Repository: raystack/frontier

Length of output: 8342


List the sole-owned organization titles in the ownership transfer dialog.

When soleOwnerOf is not empty, the default dialog shows only generic "one or more" text and no organization names. The titles are passed in the payload but never rendered, so users cannot tell which organization needs an ownership transfer.

Suggested fix
-          groups: []
+          groups: [{ lines: context.soleOwnerOf.map(org => org.title) }]
Committable suggestion

IMPORTANT
Review the replacement and indentation, then test before committing.

Suggested change
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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
groups: []
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.\n\nOnce ownership has been transferred, you can proceed with account deletion.`,
groups: [{ lines: context.soleOwnerOf.map(org => org.title) }]

Comment on lines +91 to +97
if (!handle) return;
deleteStarters.set(handle, startDelete);
return () => {
deleteStarters.delete(handle);
};
}, [handle, startDelete]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

Supported by static analysis

Script executed:

rg -n -C 3 'DeleteUserDialog\.createHandle|<DeleteUserDialog|handle=\{' web/sdk/client --glob '*.{ts,tsx}' | head -140

Repository: raystack/frontier

Length of output: 11562


Script executed:

rg -n -F -e 'DeleteUserDialog' -e 'deleteStarters' -e 'createHandle' web/sdk --glob '!**/node_modules/**' || test "$?" -eq 1
echo '---'
rg -n -i -e 'single owner' -e 'one handle' -e 'shared handle' -e 'same handle' -e 'handle.*shared' web/sdk web/docs docs 2>/dev/null || test "$?" -eq 1
echo '---'
sed -n '1,60p' web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx

Repository: raystack/frontier

Length of output: 15649


Keep another dialog's starter when this cleanup runs.

The effect cleanup deletes the handle's entry unconditionally. If a second dialog shares this handle, unmounting the first dialog removes the second dialog's starter. handle.open() then does nothing while the second dialog stays mounted. Delete the entry only when it still belongs to this instance.

Suggested fix
     return () => {
-      deleteStarters.delete(handle);
+      if (deleteStarters.get(handle) === startDelete) {
+        deleteStarters.delete(handle);
+      }
     };
Committable suggestion

IMPORTANT
Review the replacement and indentation, then test before committing.

Suggested change
if (!handle) return;
deleteStarters.set(handle, startDelete);
return () => {
deleteStarters.delete(handle);
};
}, [handle, startDelete]);
if (!handle) return;
deleteStarters.set(handle, startDelete);
return () => {
if (deleteStarters.get(handle) === startDelete) {
deleteStarters.delete(handle);
}
};
}, [handle, startDelete]);

Comment on lines +216 to +220
onClick={onDelete}
disabled={!isEmailConfirmed || !isAcknowledged || isPending}
loading={isPending}
loaderText="Deleting..."
data-test-id="frontier-sdk-delete-account-btn"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Stability & Availability · Quick win

Supported by static analysis

Script executed:

rg -n -C 5 'handleConnectError|FailedPrecondition: onDeleteRefused|isChecking|isPending' web/sdk/client/components/delete-user-dialog web/sdk/client/utils --glob '*.{ts,tsx}' | head -150

Repository: raystack/frontier

Length of output: 8292


Script executed:

rg -n -F -e 'handleConnectError' -- web/sdk/client/utils web/sdk/client/src 2>/dev/null || test "$?" -eq 1
fd -t f 'error' web/sdk/client/utils | head -20

Repository: raystack/frontier

Length of output: 190


Script executed:

rg -n -F -e 'export function handleConnectError' -e 'export const handleConnectError' -e 'handleConnectError =' -- web 2>/dev/null || test "$?" -eq 1
rg -n -F -e '"~/*"' -e "'~/*'" -- web/sdk/client/tsconfig.json web/sdk/client/vite.config.ts web/sdk/client/package.json 2>/dev/null || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 217


Script executed:

nl -ba web/sdk/utils/error.ts | sed -n '1,80p'
rg -n -F -e '"~/*"' -e '~/' -- web/sdk/client/tsconfig.json web/sdk/client/vite.config.ts web/sdk/client/package.json web/sdk/tsconfig.json 2>/dev/null || test "$?" -eq 1

Repository: raystack/frontier

Length of output: 2429


Disable Delete while the post-refusal eligibility check runs.

When the delete request returns FailedPrecondition, onDeleteRefused starts checkAndOpen. The confirmation form stays mounted and interactive until that check completes. isPending is false by then, so the Delete button re-enables. The user can click Delete again, which sends another deleteCurrentUser request. Overlapping checks can also show duplicate error toasts. Include the recheck state in the Delete button's disabled condition, and show loading while it runs.

@Shreyag02 Shreyag02 mentioned this pull request Nov 4, 2026
1 of 3 tasks
const checkAndOpen = useCallback(async () => {
try {
const check = await checkCurrentUserDelete({});
dialogHandle.openWithPayload(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: The ticket asks the confirmation step to show what goes away with the account: the orgs deleted, the orgs left, and the number of PATs.
The check already returns these, but they aren't passed to the confirm step. Was this dropped in the design?

Comment on lines +112 to +114
{({ payload: rawPayload }) => {
const payload = rawPayload as DeleteUserPayload | undefined;
if (!payload) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Looks like the cast isn't needed here. The handle already gives payload the right type, and tsc is happy without it.

Suggested change
{({ payload: rawPayload }) => {
const payload = rawPayload as DeleteUserPayload | undefined;
if (!payload) return null;
{({ payload }) => {
if (!payload) return null;

/>
</Field>
<Flex gap={3} align="start">
<Checkbox

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The checkbox isn't linked to the text next to it, so screen readers announce it as an unlabeled checkbox, and clicking the text doesn't tick it.
Could we add an id to the text and aria-labelledby on the checkbox, or wrap both in a <label>?

@@ -2,6 +2,10 @@
width: 480px !important;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This 480px width now lives in three CSS files. Could we share one class so it's easy to change later? Fine as a follow-up too.

};
case 'user': {
const org = t.organization({ case: 'lower' });
if (context.soleOwnerOf.length > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: We have the names of the orgs where the user is the only owner, but the dialog says "one or more organizations" without listing them.
Is that from the design? If not, listing them would save users some searching.

organizationsToLeave: { id: string; title: string; blockers: Blocker[] }[];
}

export function toUserDeleteBlocked({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: This reads blockers from organizationsToDelete and the "only owner" ones from organizationsToLeave.
The response also has a top-level blockers list, which the server leaves empty today.

Should we read that too, so a new server-side blocker shows up without an SDK change?

Comment on lines +60 to +63
export interface ProfileViewProps {
enableUserDelete?: boolean;
onAccountDeleted?: () => void;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: DeleteUserDialog needs onDeleted because the SDK doesn't redirect, but here onAccountDeleted is optional.

If a host turns on enableUserDelete and leaves out the callback, the user stays on the profile page after their account is gone.
What do you think about making it required when the flag is on?

Suggested change
export interface ProfileViewProps {
enableUserDelete?: boolean;
onAccountDeleted?: () => void;
}
export type ProfileViewProps =
| { enableUserDelete?: false; onAccountDeleted?: never }
| { enableUserDelete: true; onAccountDeleted: () => void };

}

export function ProfileView({
enableUserDelete = false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The demo app's Profile page still renders <ProfileView /> without the flag, so there's no easy place to try this.
Could we turn it on there with a simple redirect? It would help with the test plan items too.

This branch was successfully deployed

1 active deployment
Preview — 98b1e094 Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants