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';
+ }}
+ />
+ );
}
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 213f717dd1..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
@@ -8,27 +8,71 @@ 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.`
- })
-};
+function defaultContent(
+ context: DeleteBlockedContext,
+ t: TerminologyMap,
+ messages: Record,
+ wording: BlockerWording
+): { title: string; description: string; lines: string[] } {
+ const { entityLabel } = context;
+ switch (context.entity) {
+ case 'organization':
+ return {
+ title: `Unable to delete ${entityLabel}`,
+ description: `Please resolve the following issues before deleting this ${entityLabel}:`,
+ 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.`,
+ lines: blockerLines(context.blockers, messages, wording)
+ };
+ case 'user': {
+ const org = t.organization({ case: 'lower' });
+ 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({
+ 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.`,
+ lines: []
+ };
+ }
+ const orgs = t.organization({
+ case: 'lower',
+ plural: context.organizationsToDelete.length > 1
+ });
+ return {
+ title: 'Unable to delete account',
+ description: `Please resolve the following issues in your ${orgs} before deleting your account:`,
+ lines:
+ blockers.length > 0
+ ? blockerLines(blockers, messages, wording)
+ : [GENERIC_DELETE_BLOCKED_MESSAGE]
+ };
+ }
+ }
+}
const DeleteBlockedDialogContext = createContext(
null
@@ -55,7 +99,7 @@ export function DeleteBlockedDialogRoot({
);
}
-function DeleteBlockedContent({
+export function DeleteBlockedContent({
payload,
close
}: {
@@ -100,21 +144,28 @@ export function DeleteBlockedDialog({
);
}
- const copy = DEFAULT_COPY[context.entity](context.entityLabel);
- const lines = blockerLines(blockers ?? context.blockers, messages, {
- organizationLabel: t.organization({ case: 'lower' }),
+ const severalOrgs =
+ context.entity === 'user' && context.organizationsToDelete.length > 1;
+ const wording = {
+ organizationRef: severalOrgs
+ ? `your ${t.organization({ case: 'lower', plural: true })}`
+ : `this ${t.organization({ case: 'lower' })}`,
basePlanTitle: basePlan?.title
- });
+ };
+ const content = defaultContent(context, t, messages, wording);
+ const lines = blockers
+ ? blockerLines(blockers, messages, wording)
+ : content.lines;
return (
- {title ?? copy.title}
+ {title ?? content.title}
-
- {description ?? copy.description}
+
+ {description ?? content.description}
{lines.length > 0 && (
+ toastManager.add({
+ title: 'Something went wrong',
+ description: 'Please try again later or contact support.',
+ type: 'error'
+ });
+
+type DeleteUserPayload =
+ | { step: 'confirm' }
+ | {
+ step: 'blocked';
+ blocked: Extract;
+ };
+
+export interface DeleteUserDialogHandle {
+ open: () => void;
+}
+
+const deleteStarters = new WeakMap void>();
+
+const createDeleteUserDialogHandle = (): DeleteUserDialogHandle => {
+ const handle: DeleteUserDialogHandle = {
+ open: () => deleteStarters.get(handle)?.()
+ };
+ return handle;
+};
+
+export interface DeleteUserDialogProps {
+ trigger?: Slot<{ onClick: () => void; loading: boolean }>;
+ handle?: DeleteUserDialogHandle;
+ onDeleted: () => void;
+}
+
+export function DeleteUserDialog({
+ trigger,
+ handle,
+ onDeleted
+}: DeleteUserDialogProps) {
+ const [dialogHandle] = useState(() =>
+ AlertDialog.createHandle()
+ );
+ const { mutateAsync: checkCurrentUserDelete, isPending: isChecking } =
+ useMutation(FrontierServiceQueries.checkCurrentUserDelete);
+
+ 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;
+ }
+ }, [checkCurrentUserDelete, dialogHandle]);
+
+ const startDelete = useCallback(() => {
+ void checkAndOpen();
+ }, [checkAndOpen]);
+
+ useEffect(() => {
+ if (!handle) return;
+ deleteStarters.set(handle, startDelete);
+ return () => {
+ deleteStarters.delete(handle);
+ };
+ }, [handle, startDelete]);
+
+ async function onDeleteRefused() {
+ if (await checkAndOpen()) {
+ toastManager.add({
+ title: 'Unable to delete account',
+ description: GENERIC_DELETE_BLOCKED_MESSAGE,
+ type: 'error'
+ });
+ }
+ }
+
+ return (
+ <>
+ {trigger?.({ onClick: startDelete, loading: isChecking })}
+
+ {({ payload }) => {
+ if (!payload) return null;
+ return payload.step === 'blocked' ? (
+ dialogHandle.close()}
+ />
+ ) : (
+
+ {
+ dialogHandle.close();
+ onDeleted();
+ }}
+ onDeleteRefused={onDeleteRefused}
+ />
+
+ );
+ }}
+
+ >
+ );
+}
+
+DeleteUserDialog.createHandle = createDeleteUserDialogHandle;
+
+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 (
+ <>
+
+ Delete Account
+
+
+
+
+ This action is irreversible. Deleting your account will permanently
+ remove your account and associated data. Do you want to continue?
+
+
+ setEmail(event.target.value)}
+ data-test-id="frontier-sdk-delete-account-email-input"
+ />
+
+ }>
+ setIsAcknowledged(checked)}
+ data-test-id="frontier-sdk-delete-account-checkbox"
+ />
+
+ I acknowledge that I understand my account and associated data
+ will be permanently deleted and want to proceed
+
+
+
+
+
+
+ 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..e125a4125d 100644
--- a/web/sdk/client/utils/delete-blockers.ts
+++ b/web/sdk/client/utils/delete-blockers.ts
@@ -1,26 +1,29 @@
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.';
-// 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.'
};
@@ -51,6 +54,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..2aa69b835c 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,15 @@ const profileSchema = yup
type FormData = yup.InferType;
-export function ProfileView() {
+export interface ProfileViewProps {
+ enableUserDelete?: boolean;
+ onDeleteSuccess?: () => void;
+}
+
+export function ProfileView({
+ enableUserDelete = false,
+ onDeleteSuccess
+}: ProfileViewProps = {}) {
const { user, isUserLoading: isLoading } = useFrontier();
const queryClient = useQueryClient();
@@ -188,6 +198,40 @@ export function ProfileView() {
)}
+
+ {enableUserDelete && (
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ If you want to permanently delete your account and all of
+ its data.
+
+ (
+
+ )}
+ onDeleted={() => onDeleteSuccess?.()}
+ />
+ >
+ )}
+
+ )}
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;