diff --git a/packages/frontend/core/src/components/hooks/affine/use-sign-out.ts b/packages/frontend/core/src/components/hooks/affine/use-sign-out.ts index 66b61311f..baee5d007 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-sign-out.ts +++ b/packages/frontend/core/src/components/hooks/affine/use-sign-out.ts @@ -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) => { diff --git a/packages/frontend/core/src/desktop/dialogs/deleted-account/index.css.ts b/packages/frontend/core/src/desktop/dialogs/deleted-account/index.css.ts new file mode 100644 index 000000000..bbb751f41 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/deleted-account/index.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const successDeleteAccountContainer = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + gap: '12px', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/deleted-account/index.tsx b/packages/frontend/core/src/desktop/dialogs/deleted-account/index.tsx new file mode 100644 index 000000000..c37ea0e97 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/deleted-account/index.tsx @@ -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) => { + const t = useI18n(); + const { jumpToIndex } = useNavigateHelper(); + const callback = useCallback(() => { + jumpToIndex(); + }, [jumpToIndex]); + + const handleOpenChange = useCallback(() => { + callback(); + close(); + }, [callback, close]); + return ( + + {t['com.affine.setting.account.delete.success-description-1']()} + + {t['com.affine.setting.account.delete.success-description-2']()} + + + } + confirmText={t['Confirm']()} + onOpenChange={handleOpenChange} + onConfirm={handleOpenChange} + confirmButtonOptions={{ + variant: 'primary', + }} + cancelButtonOptions={{ + style: { + display: 'none', + }, + }} + /> + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/index.tsx b/packages/frontend/core/src/desktop/dialogs/index.tsx index 07e1c1570..0f8697535 100644 --- a/packages/frontend/core/src/desktop/dialogs/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/index.tsx @@ -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 diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/delete-account.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/delete-account.tsx new file mode 100644 index 000000000..68dc55133 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/delete-account.tsx @@ -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 ( + + + {t['com.affine.setting.account.delete']()} + + } + desc={t['com.affine.setting.account.delete.message']()} + style={{ cursor: 'pointer' }} + onClick={openModal} + data-testid="delete-account-button" + > + + + {isTeamWorkspaceOwner ? ( + + ) : ( + + )} + + ); +}; + +const TeamOwnerWarningModal = ({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) => { + const t = useI18n(); + const onConfirm = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + return ( + + ); +}; + +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 ( + + , + }} + /> + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx index ee82fe975..0b33b2809 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/index.tsx @@ -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" /> - - - - - - - - {serverFeatures?.copilot && ( - - )} - - - - {/**/} - {/* {t['com.affine.setting.account.delete']()}*/} - {/* */} - {/* }*/} - {/* desc={t['com.affine.setting.account.delete.message']()}*/} - {/* style={{ cursor: 'pointer' }}*/} - {/* onClick={useCallback(() => {*/} - {/* toast('Function coming soon');*/} - {/* }, [])}*/} - {/* testId="delete-account-button"*/} - {/*>*/} - {/* */} - {/**/} + + + + + + + + + {serverFeatures?.copilot && ( + + )} + + + + + ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/style.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/style.css.ts index f4a17038d..9206d232a 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/account-setting/style.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/account-setting/style.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/delete-leave-workspace/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/delete-leave-workspace/index.tsx index 38bcccdf4..89d87dc79 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/delete-leave-workspace/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/preference/delete-leave-workspace/index.tsx @@ -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 = ({ <> + {isOwner ? t['com.affine.workspaceDelete.title']() : t['com.affine.deleteLeaveWorkspace.leave']()} diff --git a/packages/frontend/core/src/modules/cloud/index.ts b/packages/frontend/core/src/modules/cloud/index.ts index 1898a3d53..04a6d0ab5 100644 --- a/packages/frontend/core/src/modules/cloud/index.ts +++ b/packages/frontend/core/src/modules/cloud/index.ts @@ -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, diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index 1e6074237..bf73ba465 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index a9774a6f2..2578db866 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -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; + } } diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts index ca9258e87..18e9b4a08 100644 --- a/packages/frontend/core/src/modules/dialogs/constant.ts +++ b/packages/frontend/core/src/modules/dialogs/constant.ts @@ -37,6 +37,7 @@ export type GLOBAL_DIALOG_SCHEMA = { openPageId?: string; serverId?: string; }) => boolean; + 'deleted-account': () => void; }; export type WORKSPACE_DIALOG_SCHEMA = { diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 67fe2fb23..fb82a13d1 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -4830,13 +4830,49 @@ export function useAFFiNEI18N(): { */ ["com.affine.setting.account"](): string; /** - * `Delete account` + * `Delete your account` */ ["com.affine.setting.account.delete"](): string; /** - * `Permanently delete this account and the Workspace data backup in AFFiNE Cloud. This action can not be undone.` + * `Once deleted, your account will no longer be accessible, and all data in your personal cloud space will be permanently deleted.` */ ["com.affine.setting.account.delete.message"](): string; + /** + * `Cannot delete account` + */ + ["com.affine.setting.account.delete.team-warning-title"](): string; + /** + * `You’re the owner of a team workspace. To delete your account, please delete the workspace or transfer ownership first.` + */ + ["com.affine.setting.account.delete.team-warning-description"](): string; + /** + * `Delete your account?` + */ + ["com.affine.setting.account.delete.confirm-title"](): string; + /** + * `Are you sure you want to delete your account?` + */ + ["com.affine.setting.account.delete.confirm-description-1"](): string; + /** + * `Please type your email to confirm` + */ + ["com.affine.setting.account.delete.input-placeholder"](): string; + /** + * `Delete` + */ + ["com.affine.setting.account.delete.confirm-button"](): string; + /** + * `Account deleted` + */ + ["com.affine.setting.account.delete.success-title"](): string; + /** + * `Your account and cloud data have been deleted.` + */ + ["com.affine.setting.account.delete.success-description-1"](): string; + /** + * `Local data can be deleted by uninstalling app and clearing browser data.` + */ + ["com.affine.setting.account.delete.success-description-2"](): string; /** * `Your personal information` */ @@ -9152,6 +9188,12 @@ export const TypedTrans: { }, { ["1"]: JSX.Element; }>>; + /** + * `Your account will be inaccessible, and your personal cloud space will be permanently deleted. You can remove local data by uninstalling the app or clearing your browser storage. <1>This action is irreversible.` + */ + ["com.affine.setting.account.delete.confirm-description-2"]: ComponentType, { + ["1"]: JSX.Element; + }>>; /** * `Don't have the app? <1>Click to download.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index bda0dfc52..5d88d65e4 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1199,8 +1199,18 @@ "com.affine.setting.notifications.email.invites.title": "Invites", "com.affine.setting.notifications.email.invites.subtitle": "Invitation related messages will be sent through emails.", "com.affine.setting.account": "Account settings", - "com.affine.setting.account.delete": "Delete account", - "com.affine.setting.account.delete.message": "Permanently delete this account and the Workspace data backup in AFFiNE Cloud. This action can not be undone.", + "com.affine.setting.account.delete": "Delete your account", + "com.affine.setting.account.delete.message": "Once deleted, your account will no longer be accessible, and all data in your personal cloud space will be permanently deleted.", + "com.affine.setting.account.delete.team-warning-title": "Cannot delete account", + "com.affine.setting.account.delete.team-warning-description": "You’re the owner of a team workspace. To delete your account, please delete the workspace or transfer ownership first.", + "com.affine.setting.account.delete.confirm-title": "Delete your account?", + "com.affine.setting.account.delete.confirm-description-1": "Are you sure you want to delete your account?", + "com.affine.setting.account.delete.confirm-description-2": "Your account will be inaccessible, and your personal cloud space will be permanently deleted. You can remove local data by uninstalling the app or clearing your browser storage. <1>This action is irreversible.", + "com.affine.setting.account.delete.input-placeholder": "Please type your email to confirm", + "com.affine.setting.account.delete.confirm-button": "Delete", + "com.affine.setting.account.delete.success-title": "Account deleted", + "com.affine.setting.account.delete.success-description-1": "Your account and cloud data have been deleted.", + "com.affine.setting.account.delete.success-description-2": "Local data can be deleted by uninstalling app and clearing browser data.", "com.affine.setting.account.message": "Your personal information", "com.affine.setting.sign.message": "Sync with AFFiNE Cloud", "com.affine.setting.sign.out.message": "Securely sign out of your account.", diff --git a/packages/frontend/track/src/events.ts b/packages/frontend/track/src/events.ts index 8fd831cfc..d83ba183f 100644 --- a/packages/frontend/track/src/events.ts +++ b/packages/frontend/track/src/events.ts @@ -118,7 +118,8 @@ type AuthEvents = | 'signIn' | 'signInFail' | 'signedIn' - | 'signOut'; + | 'signOut' + | 'deleteAccount'; type AccountEvents = 'uploadAvatar' | 'removeAvatar' | 'updateUserName'; type PaymentEvents = | 'viewPlans' @@ -235,7 +236,14 @@ interface PageEvents extends PageDivision { $: { $: { $: ['createWorkspace', 'checkout']; - auth: ['requestSignIn', 'signIn', 'signedIn', 'signInFail', 'signOut']; + auth: [ + 'requestSignIn', + 'signIn', + 'signedIn', + 'signInFail', + 'signOut', + 'deleteAccount', + ]; }; sharePanel: { $: [