feat(core): add account deletion entry to account settings (#12385)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Implemented account deletion functionality with confirmation dialogs and success notifications.
  - Added a warning modal for team workspace owners before account deletion.
  - Introduced a new, richly formatted internationalized message for account deletion confirmation.
  - Added a new dialog component to inform users of successful account deletion.
- **Improvements**
  - Updated localization strings to provide detailed guidance and warnings for account deletion.
  - Enhanced error handling by converting errors into user-friendly notifications.
  - Simplified and improved the sign-out process with better error handling and streamlined navigation.
- **Style**
  - Added new style constants for success and warning modals related to account deletion.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
JimmFly
2025-05-27 08:00:44 +00:00
parent 18da2fe4e6
commit 8d3b20ecc7
15 changed files with 372 additions and 89 deletions
@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const successDeleteAccountContainer = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '12px',
});
@@ -0,0 +1,50 @@
import { ConfirmModal } from '@affine/component';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import type {
DialogComponentProps,
GLOBAL_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { useI18n } from '@affine/i18n';
import { useCallback } from 'react';
import * as styles from './index.css';
export const DeletedAccountDialog = ({
close,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['deleted-account']>) => {
const t = useI18n();
const { jumpToIndex } = useNavigateHelper();
const callback = useCallback(() => {
jumpToIndex();
}, [jumpToIndex]);
const handleOpenChange = useCallback(() => {
callback();
close();
}, [callback, close]);
return (
<ConfirmModal
open
persistent
title={t['com.affine.setting.account.delete.success-title']()}
description={
<span className={styles.successDeleteAccountContainer}>
{t['com.affine.setting.account.delete.success-description-1']()}
<span>
{t['com.affine.setting.account.delete.success-description-2']()}
</span>
</span>
}
confirmText={t['Confirm']()}
onOpenChange={handleOpenChange}
onConfirm={handleOpenChange}
confirmButtonOptions={{
variant: 'primary',
}}
cancelButtonOptions={{
style: {
display: 'none',
},
}}
/>
);
};
@@ -10,6 +10,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import { ChangePasswordDialog } from './change-password';
import { CollectionEditorDialog } from './collection-editor';
import { CreateWorkspaceDialog } from './create-workspace';
import { DeletedAccountDialog } from './deleted-account';
import { DocInfoDialog } from './doc-info';
import { EnableCloudDialog } from './enable-cloud';
import { ImportDialog } from './import';
@@ -31,6 +32,7 @@ const GLOBAL_DIALOGS = {
'change-password': ChangePasswordDialog,
'verify-email': VerifyEmailDialog,
'enable-cloud': EnableCloudDialog,
'deleted-account': DeletedAccountDialog,
} satisfies {
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
DialogComponentProps<GLOBAL_DIALOG_SCHEMA[key]>
@@ -0,0 +1,157 @@
import { ConfirmModal, Input, notify } from '@affine/component';
import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { AuthService } from '@affine/core/modules/cloud';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import { Trans, useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useState } from 'react';
import * as styles from './style.css';
export const DeleteAccount = () => {
const t = useI18n();
const workspacesService = useService(WorkspacesService);
const workspaceProfiles = workspacesService.getAllWorkspaceProfile();
const isTeamWorkspaceOwner = workspaceProfiles.some(
profile => profile.profile$.value?.isTeam && profile.profile$.value.isOwner
);
const [showModal, setShowModal] = useState(false);
const openModal = useCallback(() => {
setShowModal(true);
}, []);
return (
<SettingWrapper>
<SettingRow
name={
<span style={{ color: cssVarV2('status/error') }}>
{t['com.affine.setting.account.delete']()}
</span>
}
desc={t['com.affine.setting.account.delete.message']()}
style={{ cursor: 'pointer' }}
onClick={openModal}
data-testid="delete-account-button"
>
<ArrowRightSmallIcon />
</SettingRow>
{isTeamWorkspaceOwner ? (
<TeamOwnerWarningModal open={showModal} onOpenChange={setShowModal} />
) : (
<DeleteAccountModal open={showModal} onOpenChange={setShowModal} />
)}
</SettingWrapper>
);
};
const TeamOwnerWarningModal = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const t = useI18n();
const onConfirm = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
return (
<ConfirmModal
open={open}
onOpenChange={onOpenChange}
title={t['com.affine.setting.account.delete.team-warning-title']()}
description={t[
'com.affine.setting.account.delete.team-warning-description'
]()}
confirmText={t['Confirm']()}
confirmButtonOptions={{
variant: 'primary',
}}
onConfirm={onConfirm}
cancelButtonOptions={{
style: {
display: 'none',
},
}}
/>
);
};
const DeleteAccountModal = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const t = useI18n();
const authService = useService(AuthService);
const session = authService.session;
const account = useLiveData(session.account$);
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleDeleteAccount = useCallback(async () => {
try {
setIsLoading(true);
await authService.deleteAccount();
track.$.$.auth.deleteAccount();
} catch (err) {
console.error(err);
const error = UserFriendlyError.fromAny(err);
notify.error(error);
} finally {
setIsLoading(false);
}
}, [authService]);
const onDeleteAccountConfirm = useCallback(async () => {
await handleDeleteAccount();
}, [handleDeleteAccount]);
if (!account) {
return null;
}
return (
<ConfirmModal
open={open}
cancelText={t['com.affine.confirmModal.button.cancel']()}
onConfirm={onDeleteAccountConfirm}
onOpenChange={onOpenChange}
title={t['com.affine.setting.account.delete.confirm-title']()}
description={t[
'com.affine.setting.account.delete.confirm-description-1'
]()}
confirmText={t['com.affine.setting.account.delete.confirm-button']()}
confirmButtonOptions={{
variant: 'error',
disabled: email !== account.email,
loading: isLoading,
}}
childrenContentClassName={styles.confirmContent}
>
<Trans
i18nKey="com.affine.setting.account.delete.confirm-description-2"
components={{
1: <strong />,
}}
/>
<Input
type="text"
placeholder={t['com.affine.setting.account.delete.input-placeholder']()}
value={email}
onChange={setEmail}
className={styles.inputWrapper}
/>
</ConfirmModal>
);
};
@@ -2,6 +2,7 @@ import { FlexWrapper, Input, notify } from '@affine/component';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { Avatar } from '@affine/component/ui/avatar';
import { Button } from '@affine/component/ui/button';
@@ -20,6 +21,7 @@ import { useCallback, useEffect, useState } from 'react';
import { AuthService, ServerService } from '../../../../modules/cloud';
import type { SettingState } from '../types';
import { AIUsagePanel } from './ai-usage-panel';
import { DeleteAccount } from './delete-account';
import { StorageProgress } from './storage-progress';
import * as styles from './style.css';
@@ -214,51 +216,42 @@ export const AccountSetting = ({
data-testid="account-title"
/>
<AvatarAndName />
<SettingRow name={t['com.affine.settings.email']()} desc={account.email}>
<Button onClick={onChangeEmail}>
{account.info?.emailVerified
? t['com.affine.settings.email.action.change']()
: t['com.affine.settings.email.action.verify']()}
</Button>
</SettingRow>
<SettingRow
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onPasswordButtonClick}>
{account.info?.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<StoragePanel onChangeSettingState={onChangeSettingState} />
{serverFeatures?.copilot && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
)}
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }}
data-testid="sign-out-button"
onClick={openSignOutModal}
>
<ArrowRightSmallIcon />
</SettingRow>
{/*<SettingRow*/}
{/* name={*/}
{/* <span style={{ color: 'var(--affine-warning-color)' }}>*/}
{/* {t['com.affine.setting.account.delete']()}*/}
{/* </span>*/}
{/* }*/}
{/* desc={t['com.affine.setting.account.delete.message']()}*/}
{/* style={{ cursor: 'pointer' }}*/}
{/* onClick={useCallback(() => {*/}
{/* toast('Function coming soon');*/}
{/* }, [])}*/}
{/* testId="delete-account-button"*/}
{/*>*/}
{/* <ArrowRightSmallIcon />*/}
{/*</SettingRow>*/}
<SettingWrapper>
<SettingRow
name={t['com.affine.settings.email']()}
desc={account.email}
>
<Button onClick={onChangeEmail}>
{account.info?.emailVerified
? t['com.affine.settings.email.action.change']()
: t['com.affine.settings.email.action.verify']()}
</Button>
</SettingRow>
<SettingRow
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onPasswordButtonClick}>
{account.info?.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<StoragePanel onChangeSettingState={onChangeSettingState} />
{serverFeatures?.copilot && (
<AIUsagePanel onChangeSettingState={onChangeSettingState} />
)}
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }}
data-testid="sign-out-button"
onClick={openSignOutModal}
>
<ArrowRightSmallIcon />
</SettingRow>
</SettingWrapper>
<DeleteAccount />
</>
);
};
@@ -39,3 +39,17 @@ globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
color: cssVar('white'),
fontSize: cssVar('fontH4'),
});
export const successDeleteAccountContainer = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '12px',
});
export const confirmContent = style({
paddingLeft: '0',
paddingRight: '0',
});
export const inputWrapper = style({
marginTop: '12px',
});
@@ -11,6 +11,7 @@ import {
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -101,7 +102,7 @@ export const DeleteLeaveWorkspace = ({
<>
<SettingRow
name={
<span style={{ color: 'var(--affine-error-color)' }}>
<span style={{ color: cssVarV2('status/error') }}>
{isOwner
? t['com.affine.workspaceDelete.title']()
: t['com.affine.deleteLeaveWorkspace.leave']()}