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;