Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion web/apps/client-demo/src/pages/settings/Profile.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { ProfileView } from '@raystack/frontier/client';

export default function Profile() {
return <ProfileView />;
return (
<ProfileView
enableUserDelete
onDeleteSuccess={() => {
window.location.href = '/login';
}}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
width: 480px !important;
Comment thread
Shreyag02 marked this conversation as resolved.
Resolved
}

.description {
white-space: pre-line;
}

.list {
margin: 0;
padding-left: var(--rs-space-5);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeleteBlockedPayload>
>;

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<string, string>,
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) {
Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved
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<DeleteBlockedContext | null>(
null
Expand All @@ -55,7 +99,7 @@ export function DeleteBlockedDialogRoot({
);
}

function DeleteBlockedContent({
export function DeleteBlockedContent({
payload,
close
}: {
Expand Down Expand Up @@ -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 (
<AlertDialog.Content className={styles.content}>
<AlertDialog.Header>
<AlertDialog.Title>{title ?? copy.title}</AlertDialog.Title>
<AlertDialog.Title>{title ?? content.title}</AlertDialog.Title>
</AlertDialog.Header>
<AlertDialog.Body>
<Flex direction="column" gap={5}>
<Text size="small" variant="secondary">
{description ?? copy.description}
<Text size="small" variant="secondary" className={styles.description}>
{description ?? content.description}
</Text>
{lines.length > 0 && (
<Text
Expand Down
1 change: 1 addition & 0 deletions web/sdk/client/components/delete-blocked-dialog/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export {
DeleteBlockedContent,
DeleteBlockedDialog,
DeleteBlockedDialogRoot
} from './delete-blocked-dialog';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.content {
width: 480px !important;
}
226 changes: 226 additions & 0 deletions web/sdk/client/components/delete-user-dialog/delete-user-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
'use client';

import { useCallback, useEffect, useState } from 'react';
import { useMutation } from '@connectrpc/connect-query';
import { FrontierServiceQueries } from '@raystack/proton/frontier';
import {
AlertDialog,
Button,
Checkbox,
Field,
Flex,
Input,
Text,
toastManager
} from '@raystack/apsara';
import type { DeleteBlockedPayload, 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 showErrorToast = () =>
toastManager.add({
title: 'Something went wrong',
description: 'Please try again later or contact support.',
type: 'error'
});

type DeleteUserPayload =
| { step: 'confirm' }
| {
step: 'blocked';
blocked: Extract<DeleteBlockedPayload, { entity: 'user' }>;
};

export interface DeleteUserDialogHandle {
open: () => void;
}

const deleteStarters = new WeakMap<DeleteUserDialogHandle, () => 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<DeleteUserPayload>()
);
const { mutateAsync: checkCurrentUserDelete, isPending: isChecking } =
useMutation(FrontierServiceQueries.checkCurrentUserDelete);

const checkAndOpen = useCallback(async () => {
try {
const check = await checkCurrentUserDelete({});
dialogHandle.openWithPayload(
Comment thread
Shreyag02 marked this conversation as resolved.
Resolved
check.canDelete
? { step: 'confirm' }
: { step: 'blocked', blocked: toUserDeleteBlocked(check) }
);
return check.canDelete;
Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved
} 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]);

Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved
async function onDeleteRefused() {
if (await checkAndOpen()) {
toastManager.add({
title: 'Unable to delete account',
description: GENERIC_DELETE_BLOCKED_MESSAGE,
type: 'error'
});
}
Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved
}

return (
<>
{trigger?.({ onClick: startDelete, loading: isChecking })}
<AlertDialog handle={dialogHandle}>
{({ payload }) => {
if (!payload) return null;
return payload.step === 'blocked' ? (
<DeleteBlockedContent
payload={payload.blocked}
close={() => dialogHandle.close()}
/>
) : (
<AlertDialog.Content className={styles.content}>
<DeleteUserForm
onDeleted={() => {
dialogHandle.close();
onDeleted();
}}
onDeleteRefused={onDeleteRefused}
/>
</AlertDialog.Content>
);
}}
</AlertDialog>
</>
);
}

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
});
}
}
Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved

return (
<>
<AlertDialog.Header>
<AlertDialog.Title>Delete Account</AlertDialog.Title>
</AlertDialog.Header>
<AlertDialog.Body>
<Flex direction="column" gap={6}>
<Text size="small" variant="secondary">
This action is irreversible. Deleting your account will permanently
remove your account and associated data. Do you want to continue?
</Text>
<Field label="Please type your email id.">
<Input
size="large"
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
data-test-id="frontier-sdk-delete-account-email-input"
/>
</Field>
<Flex gap={3} align="start" render={<label />}>
<Checkbox
Comment thread
Shreyag02 marked this conversation as resolved.
Resolved
checked={isAcknowledged}
onCheckedChange={(checked: boolean) => setIsAcknowledged(checked)}
data-test-id="frontier-sdk-delete-account-checkbox"
/>
<Text size="small" variant="secondary">
I acknowledge that I understand my account and associated data
will be permanently deleted and want to proceed
</Text>
</Flex>
</Flex>
</AlertDialog.Body>
<AlertDialog.Footer>
<AlertDialog.Close
render={
<Button
variant="outline"
color="neutral"
disabled={isPending}
data-test-id="frontier-sdk-delete-account-cancel-btn"
>
Cancel
</Button>
}
/>
<Button
variant="solid"
color="danger"
onClick={onDelete}
disabled={!isEmailConfirmed || !isAcknowledged || isPending}
loading={isPending}
loaderText="Deleting..."
data-test-id="frontier-sdk-delete-account-btn"
Comment thread
rohanchkrabrty marked this conversation as resolved.
Resolved
>
Delete
</Button>
</AlertDialog.Footer>
</>
);
}
5 changes: 5 additions & 0 deletions web/sdk/client/components/delete-user-dialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { DeleteUserDialog } from './delete-user-dialog';
export type {
DeleteUserDialogProps,
DeleteUserDialogHandle
} from './delete-user-dialog';
Loading
Loading