mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export type GLOBAL_DIALOG_SCHEMA = {
|
||||
openPageId?: string;
|
||||
serverId?: string;
|
||||
}) => boolean;
|
||||
'deleted-account': () => void;
|
||||
};
|
||||
|
||||
export type WORKSPACE_DIALOG_SCHEMA = {
|
||||
|
||||
Reference in New Issue
Block a user