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

View File

@@ -3,11 +3,10 @@ import {
notify,
useConfirmModal,
} from '@affine/component';
import { AuthService, ServerService } from '@affine/core/modules/cloud';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { AuthService } from '@affine/core/modules/cloud';
import { UserFriendlyError } from '@affine/error';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { useNavigateHelper } from '../use-navigate-helper';
@@ -26,47 +25,21 @@ export const useSignOut = ({
}: ConfirmModalProps = {}) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const { openPage } = useNavigateHelper();
const { jumpToIndex } = useNavigateHelper();
const serverService = useService(ServerService);
const authService = useService(AuthService);
const workspacesService = useService(WorkspacesService);
const globalContextService = useService(GlobalContextService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const currentWorkspaceFlavour = useLiveData(
globalContextService.globalContext.workspaceFlavour.$
);
const signOut = useCallback(async () => {
onConfirm?.()?.catch(console.error);
try {
await authService.signOut();
jumpToIndex();
} catch (err) {
console.error(err);
// TODO(@eyhn): i18n
notify.error({
title: 'Failed to sign out',
});
const error = UserFriendlyError.fromAny(err);
notify.error(error);
}
// if current workspace is sign out, switch to other workspace
if (currentWorkspaceFlavour === serverService.server.id) {
const localWorkspace = workspaces.find(
w => w.flavour !== serverService.server.id
);
if (localWorkspace) {
openPage(localWorkspace.id, 'all');
}
}
}, [
authService,
currentWorkspaceFlavour,
onConfirm,
openPage,
serverService.server.id,
workspaces,
]);
}, [authService, jumpToIndex, onConfirm]);
const getDefaultText = useCallback(
(key: SignOutConfirmModalI18NKeys) => {

View File

@@ -0,0 +1,8 @@
import { style } from '@vanilla-extract/css';
export const successDeleteAccountContainer = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '12px',
});

View File

@@ -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',
},
}}
/>
);
};

View File

@@ -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]>

View File

@@ -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>
);
};

View File

@@ -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 />
</>
);
};

View File

@@ -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',
});

View File

@@ -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']()}

View File

@@ -101,6 +101,7 @@ import { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-upda
import { WorkspacePermissionService } from '../permissions';
import { DocScope, DocService, DocsService } from '../doc';
import { DocCreatedByUpdatedBySyncStore } from './stores/doc-created-by-updated-by-sync';
import { GlobalDialogService } from '../dialogs';
export function configureCloudModule(framework: Framework) {
configureDefaultAuthProvider(framework);
@@ -123,7 +124,12 @@ export function configureCloudModule(framework: Framework) {
f.getOptional(ValidatorProvider)
);
})
.service(AuthService, [FetchService, AuthStore, UrlService])
.service(AuthService, [
FetchService,
AuthStore,
UrlService,
GlobalDialogService,
])
.store(AuthStore, [
FetchService,
GraphQLService,

View File

@@ -5,6 +5,7 @@ import { OnEvent, Service } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { distinctUntilChanged, map, skip } from 'rxjs';
import type { GlobalDialogService } from '../../dialogs';
import { ApplicationFocused } from '../../lifecycle';
import type { UrlService } from '../../url';
import { AuthSession } from '../entities/session';
@@ -23,7 +24,8 @@ export class AuthService extends Service {
constructor(
private readonly fetchService: FetchService,
private readonly store: AuthStore,
private readonly urlService: UrlService
private readonly urlService: UrlService,
private readonly dialogService: GlobalDialogService
) {
super();
@@ -189,6 +191,14 @@ export class AuthService extends Service {
this.session.revalidate();
}
async deleteAccount() {
const res = await this.store.deleteAccount();
this.store.setCachedAuthSession(null);
this.session.revalidate();
this.dialogService.open('deleted-account', {});
return res;
}
checkUserByEmail(email: string) {
return this.store.checkUserByEmail(email);
}

View File

@@ -1,4 +1,5 @@
import {
deleteAccountMutation,
removeAvatarMutation,
updateUserProfileMutation,
uploadAvatarMutation,
@@ -150,4 +151,11 @@ export class AuthStore extends Store {
return data;
}
async deleteAccount() {
const res = await this.gqlService.gql({
query: deleteAccountMutation,
});
return res.deleteAccount;
}
}

View File

@@ -37,6 +37,7 @@ export type GLOBAL_DIALOG_SCHEMA = {
openPageId?: string;
serverId?: string;
}) => boolean;
'deleted-account': () => void;
};
export type WORKSPACE_DIALOG_SCHEMA = {