소스 검색

Feat: support account deletion (#10008)

NFish 3 달 전
부모
커밋
adacd01f82

+ 12 - 15
web/app/(commonLayout)/layout.tsx

@@ -8,27 +8,24 @@ import Header from '@/app/components/header'
 import { EventEmitterContextProvider } from '@/context/event-emitter'
 import { ProviderContextProvider } from '@/context/provider-context'
 import { ModalContextProvider } from '@/context/modal-context'
-import { TanstackQueryIniter } from '@/context/query-client'
 
 const Layout = ({ children }: { children: ReactNode }) => {
   return (
     <>
       <GA gaType={GaType.admin} />
       <SwrInitor>
-        <TanstackQueryIniter>
-          <AppContextProvider>
-            <EventEmitterContextProvider>
-              <ProviderContextProvider>
-                <ModalContextProvider>
-                  <HeaderWrapper>
-                    <Header />
-                  </HeaderWrapper>
-                  {children}
-                </ModalContextProvider>
-              </ProviderContextProvider>
-            </EventEmitterContextProvider>
-          </AppContextProvider>
-        </TanstackQueryIniter>
+        <AppContextProvider>
+          <EventEmitterContextProvider>
+            <ProviderContextProvider>
+              <ModalContextProvider>
+                <HeaderWrapper>
+                  <Header />
+                </HeaderWrapper>
+                {children}
+              </ModalContextProvider>
+            </ProviderContextProvider>
+          </EventEmitterContextProvider>
+        </AppContextProvider>
       </SwrInitor>
     </>
   )

+ 2 - 30
web/app/account/account-page/index.tsx

@@ -3,11 +3,11 @@ import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 
 import { useContext } from 'use-context-selector'
+import DeleteAccount from '../delete-account'
 import s from './index.module.css'
 import Collapse from '@/app/components/header/account-setting/collapse'
 import type { IItem } from '@/app/components/header/account-setting/collapse'
 import Modal from '@/app/components/base/modal'
-import Confirm from '@/app/components/base/confirm'
 import Button from '@/app/components/base/button'
 import { updateUserProfile } from '@/service/common'
 import { useAppContext } from '@/context/app-context'
@@ -296,37 +296,9 @@ export default function AccountPage() {
       }
       {
         showDeleteAccountModal && (
-          <Confirm
-            isShow
+          <DeleteAccount
             onCancel={() => setShowDeleteAccountModal(false)}
             onConfirm={() => setShowDeleteAccountModal(false)}
-            showCancel={false}
-            type='warning'
-            title={t('common.account.delete')}
-            content={
-              <>
-                <div className='my-1 text-text-destructive body-md-medium'>
-                  {t('common.account.deleteTip')}
-                </div>
-                <div className='mt-3 text-sm leading-5'>
-                  <span>{t('common.account.deleteConfirmTip')}</span>
-                  <a
-                    className='text-text-accent cursor'
-                    href={`mailto:support@dify.ai?subject=Delete Account Request&body=Delete Account: ${userProfile.email}`}
-                    target='_blank'
-                    rel='noreferrer noopener'
-                    onClick={(e) => {
-                      e.preventDefault()
-                      window.location.href = e.currentTarget.href
-                    }}
-                  >
-                    support@dify.ai
-                  </a>
-                </div>
-                <div className='my-2 px-3 py-2 rounded-lg bg-components-input-bg-active border border-components-input-border-active system-sm-regular text-components-input-text-filled'>{`${t('common.account.delete')}: ${userProfile.email}`}</div>
-              </>
-            }
-            confirmText={t('common.operation.ok') as string}
           />
         )
       }

+ 48 - 0
web/app/account/delete-account/components/check-email.tsx

@@ -0,0 +1,48 @@
+'use client'
+import { useTranslation } from 'react-i18next'
+import { useCallback, useState } from 'react'
+import Link from 'next/link'
+import { useSendDeleteAccountEmail } from '../state'
+import { useAppContext } from '@/context/app-context'
+import Input from '@/app/components/base/input'
+import Button from '@/app/components/base/button'
+
+type DeleteAccountProps = {
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+export default function CheckEmail(props: DeleteAccountProps) {
+  const { t } = useTranslation()
+  const { userProfile } = useAppContext()
+  const [userInputEmail, setUserInputEmail] = useState('')
+
+  const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail()
+
+  const handleConfirm = useCallback(async () => {
+    try {
+      const ret = await getDeleteEmailVerifyCode()
+      if (ret.result === 'success')
+        props.onConfirm()
+    }
+    catch (error) { console.error(error) }
+  }, [getDeleteEmailVerifyCode, props])
+
+  return <>
+    <div className='py-1 text-text-destructive body-md-medium'>
+      {t('common.account.deleteTip')}
+    </div>
+    <div className='pt-1 pb-2 text-text-secondary body-md-regular'>
+      {t('common.account.deletePrivacyLinkTip')}
+      <Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
+    </div>
+    <label className='mt-3 mb-1 h-6 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.deleteLabel')}</label>
+    <Input placeholder={t('common.account.deletePlaceholder') as string} onChange={(e) => {
+      setUserInputEmail(e.target.value)
+    }} />
+    <div className='w-full flex flex-col mt-3 gap-2'>
+      <Button className='w-full' disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant='primary' onClick={handleConfirm}>{t('common.account.sendVerificationButton')}</Button>
+      <Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
+    </div>
+  </>
+}

+ 68 - 0
web/app/account/delete-account/components/feed-back.tsx

@@ -0,0 +1,68 @@
+'use client'
+import { useTranslation } from 'react-i18next'
+import { useCallback, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { useDeleteAccountFeedback } from '../state'
+import { useAppContext } from '@/context/app-context'
+import Button from '@/app/components/base/button'
+import CustomDialog from '@/app/components/base/dialog'
+import Textarea from '@/app/components/base/textarea'
+import Toast from '@/app/components/base/toast'
+import { logout } from '@/service/common'
+
+type DeleteAccountProps = {
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+export default function FeedBack(props: DeleteAccountProps) {
+  const { t } = useTranslation()
+  const { userProfile } = useAppContext()
+  const router = useRouter()
+  const [userFeedback, setUserFeedback] = useState('')
+  const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
+
+  const handleSuccess = useCallback(async () => {
+    try {
+      await logout({
+        url: '/logout',
+        params: {},
+      })
+      localStorage.removeItem('refresh_token')
+      localStorage.removeItem('console_token')
+      router.push('/signin')
+      Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
+    }
+    catch (error) { console.error(error) }
+  }, [router, t])
+
+  const handleSubmit = useCallback(async () => {
+    try {
+      await sendFeedback({ feedback: userFeedback, email: userProfile.email })
+      props.onConfirm()
+      await handleSuccess()
+    }
+    catch (error) { console.error(error) }
+  }, [handleSuccess, userFeedback, sendFeedback, userProfile, props])
+
+  const handleSkip = useCallback(() => {
+    props.onCancel()
+    handleSuccess()
+  }, [handleSuccess, props])
+  return <CustomDialog
+    show={true}
+    onClose={props.onCancel}
+    title={t('common.account.feedbackTitle')}
+    className="max-w-[480px]"
+    footer={false}
+  >
+    <label className='mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.feedbackLabel')}</label>
+    <Textarea rows={6} value={userFeedback} placeholder={t('common.account.feedbackPlaceholder') as string} onChange={(e) => {
+      setUserFeedback(e.target.value)
+    }} />
+    <div className='w-full flex flex-col mt-3 gap-2'>
+      <Button className='w-full' loading={isPending} variant='primary' onClick={handleSubmit}>{t('common.operation.submit')}</Button>
+      <Button className='w-full' onClick={handleSkip}>{t('common.operation.skip')}</Button>
+    </div>
+  </CustomDialog>
+}

+ 55 - 0
web/app/account/delete-account/components/verify-email.tsx

@@ -0,0 +1,55 @@
+'use client'
+import { useTranslation } from 'react-i18next'
+import { useCallback, useEffect, useState } from 'react'
+import Link from 'next/link'
+import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
+import Input from '@/app/components/base/input'
+import Button from '@/app/components/base/button'
+import Countdown from '@/app/components/signin/countdown'
+
+const CODE_EXP = /[A-Za-z\d]{6}/gi
+
+type DeleteAccountProps = {
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+export default function VerifyEmail(props: DeleteAccountProps) {
+  const { t } = useTranslation()
+  const emailToken = useAccountDeleteStore(state => state.sendEmailToken)
+  const [verificationCode, setVerificationCode] = useState<string>()
+  const [shouldButtonDisabled, setShouldButtonDisabled] = useState(true)
+  const { mutate: sendEmail } = useSendDeleteAccountEmail()
+  const { isPending: isDeleting, mutateAsync: confirmDeleteAccount } = useConfirmDeleteAccount()
+
+  useEffect(() => {
+    setShouldButtonDisabled(!(verificationCode && CODE_EXP.test(verificationCode)) || isDeleting)
+  }, [verificationCode, isDeleting])
+
+  const handleConfirm = useCallback(async () => {
+    try {
+      const ret = await confirmDeleteAccount({ code: verificationCode!, token: emailToken })
+      if (ret.result === 'success')
+        props.onConfirm()
+    }
+    catch (error) { console.error(error) }
+  }, [emailToken, verificationCode, confirmDeleteAccount, props])
+  return <>
+    <div className='pt-1 text-text-destructive body-md-medium'>
+      {t('common.account.deleteTip')}
+    </div>
+    <div className='pt-1 pb-2 text-text-secondary body-md-regular'>
+      {t('common.account.deletePrivacyLinkTip')}
+      <Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
+    </div>
+    <label className='mt-3 mb-1 h-6 flex items-center system-sm-semibold text-text-secondary'>{t('common.account.verificationLabel')}</label>
+    <Input minLength={6} maxLength={6} placeholder={t('common.account.verificationPlaceholder') as string} onChange={(e) => {
+      setVerificationCode(e.target.value)
+    }} />
+    <div className='w-full flex flex-col mt-3 gap-2'>
+      <Button className='w-full' disabled={shouldButtonDisabled} loading={isDeleting} variant='warning' onClick={handleConfirm}>{t('common.account.permanentlyDeleteButton')}</Button>
+      <Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
+      <Countdown onResend={sendEmail} />
+    </div>
+  </>
+}

+ 44 - 0
web/app/account/delete-account/index.tsx

@@ -0,0 +1,44 @@
+'use client'
+import { useTranslation } from 'react-i18next'
+import { useCallback, useState } from 'react'
+import CheckEmail from './components/check-email'
+import VerifyEmail from './components/verify-email'
+import FeedBack from './components/feed-back'
+import CustomDialog from '@/app/components/base/dialog'
+import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
+
+type DeleteAccountProps = {
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+export default function DeleteAccount(props: DeleteAccountProps) {
+  const { t } = useTranslation()
+
+  const [showVerifyEmail, setShowVerifyEmail] = useState(false)
+  const [showFeedbackDialog, setShowFeedbackDialog] = useState(false)
+
+  const handleEmailCheckSuccess = useCallback(async () => {
+    try {
+      setShowVerifyEmail(true)
+      localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
+    }
+    catch (error) { console.error(error) }
+  }, [])
+
+  if (showFeedbackDialog)
+    return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
+
+  return <CustomDialog
+    show={true}
+    onClose={props.onCancel}
+    title={t('common.account.delete')}
+    className="max-w-[480px]"
+    footer={false}
+  >
+    {!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
+    {showVerifyEmail && <VerifyEmail onCancel={props.onCancel} onConfirm={() => {
+      setShowFeedbackDialog(true)
+    }} />}
+  </CustomDialog>
+}

+ 39 - 0
web/app/account/delete-account/state.tsx

@@ -0,0 +1,39 @@
+import { useMutation } from '@tanstack/react-query'
+import { create } from 'zustand'
+import { sendDeleteAccountCode, submitDeleteAccountFeedback, verifyDeleteAccountCode } from '@/service/common'
+
+type State = {
+  sendEmailToken: string
+  setSendEmailToken: (token: string) => void
+}
+
+export const useAccountDeleteStore = create<State>(set => ({
+  sendEmailToken: '',
+  setSendEmailToken: (token: string) => set({ sendEmailToken: token }),
+}))
+
+export function useSendDeleteAccountEmail() {
+  const updateEmailToken = useAccountDeleteStore(state => state.setSendEmailToken)
+  return useMutation({
+    mutationKey: ['delete-account'],
+    mutationFn: sendDeleteAccountCode,
+    onSuccess: (ret) => {
+      if (ret.result === 'success')
+        updateEmailToken(ret.data)
+    },
+  })
+}
+
+export function useConfirmDeleteAccount() {
+  return useMutation({
+    mutationKey: ['confirm-delete-account'],
+    mutationFn: verifyDeleteAccountCode,
+  })
+}
+
+export function useDeleteAccountFeedback() {
+  return useMutation({
+    mutationKey: ['delete-account-feedback'],
+    mutationFn: submitDeleteAccountFeedback,
+  })
+}

+ 5 - 5
web/app/components/base/dialog/index.tsx

@@ -47,7 +47,7 @@ const CustomDialog = ({
         </Transition.Child>
 
         <div className="fixed inset-0 overflow-y-auto">
-          <div className="flex items-center justify-center min-h-full p-4 text-center">
+          <div className="flex items-center justify-center min-h-full">
             <Transition.Child
               as={Fragment}
               enter="ease-out duration-300"
@@ -57,20 +57,20 @@ const CustomDialog = ({
               leaveFrom="opacity-100 scale-100"
               leaveTo="opacity-0 scale-95"
             >
-              <Dialog.Panel className={classNames('w-full max-w-[800px] p-0 overflow-hidden text-left text-gray-900 align-middle transition-all transform bg-white shadow-xl rounded-2xl', className)}>
+              <Dialog.Panel className={classNames('w-full max-w-[800px] p-6 overflow-hidden transition-all transform bg-components-panel-bg border-[0.5px] border-components-panel-border shadow-xl rounded-2xl', className)}>
                 {Boolean(title) && (
                   <Dialog.Title
                     as={titleAs || 'h3'}
-                    className={classNames('px-8 py-6 text-lg font-medium leading-6 text-gray-900', titleClassName)}
+                    className={classNames('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)}
                   >
                     {title}
                   </Dialog.Title>
                 )}
-                <div className={classNames('px-8 text-lg font-medium leading-6', bodyClassName)}>
+                <div className={classNames(bodyClassName)}>
                   {children}
                 </div>
                 {Boolean(footer) && (
-                  <div className={classNames('flex items-center justify-end gap-2 px-8 py-6', footerClassName)}>
+                  <div className={classNames('flex items-center justify-end gap-2 px-6 pb-6 pt-3', footerClassName)}>
                     {footer}
                   </div>
                 )}

+ 0 - 9
web/app/components/header/account-setting/account-page/index.module.css

@@ -1,9 +0,0 @@
-.modal {
-  padding: 24px 32px !important;
-  width: 400px !important;
-}
-
-.bg {
-  background: linear-gradient(180deg, rgba(217, 45, 32, 0.05) 0%, rgba(217, 45, 32, 0.00) 24.02%), #F9FAFB;
-}
-

+ 0 - 282
web/app/components/header/account-setting/account-page/index.tsx

@@ -1,282 +0,0 @@
-'use client'
-import { useState } from 'react'
-import { useTranslation } from 'react-i18next'
-
-import { useContext, useContextSelector } from 'use-context-selector'
-import Collapse from '../collapse'
-import type { IItem } from '../collapse'
-import s from './index.module.css'
-import classNames from '@/utils/classnames'
-import Modal from '@/app/components/base/modal'
-import Confirm from '@/app/components/base/confirm'
-import Button from '@/app/components/base/button'
-import { updateUserProfile } from '@/service/common'
-import AppContext, { useAppContext } from '@/context/app-context'
-import { ToastContext } from '@/app/components/base/toast'
-import AppIcon from '@/app/components/base/app-icon'
-import Avatar from '@/app/components/base/avatar'
-import { IS_CE_EDITION } from '@/config'
-
-const titleClassName = `
-  text-sm font-medium text-gray-900
-`
-const descriptionClassName = `
-  mt-1 text-xs font-normal text-gray-500
-`
-const inputClassName = `
-  mt-2 w-full px-3 py-2 bg-gray-100 rounded
-  text-sm font-normal text-gray-800
-`
-
-const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
-
-export default function AccountPage() {
-  const { t } = useTranslation()
-  const { mutateUserProfile, userProfile, apps } = useAppContext()
-  const { notify } = useContext(ToastContext)
-  const [editNameModalVisible, setEditNameModalVisible] = useState(false)
-  const [editName, setEditName] = useState('')
-  const [editing, setEditing] = useState(false)
-  const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
-  const [currentPassword, setCurrentPassword] = useState('')
-  const [password, setPassword] = useState('')
-  const [confirmPassword, setConfirmPassword] = useState('')
-  const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
-  const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
-
-  const handleEditName = () => {
-    setEditNameModalVisible(true)
-    setEditName(userProfile.name)
-  }
-  const handleSaveName = async () => {
-    try {
-      setEditing(true)
-      await updateUserProfile({ url: 'account/name', body: { name: editName } })
-      notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
-      mutateUserProfile()
-      setEditNameModalVisible(false)
-      setEditing(false)
-    }
-    catch (e) {
-      notify({ type: 'error', message: (e as Error).message })
-      setEditNameModalVisible(false)
-      setEditing(false)
-    }
-  }
-
-  const showErrorMessage = (message: string) => {
-    notify({
-      type: 'error',
-      message,
-    })
-  }
-  const valid = () => {
-    if (!password.trim()) {
-      showErrorMessage(t('login.error.passwordEmpty'))
-      return false
-    }
-    if (!validPassword.test(password)) {
-      showErrorMessage(t('login.error.passwordInvalid'))
-      return false
-    }
-    if (password !== confirmPassword) {
-      showErrorMessage(t('common.account.notEqual'))
-      return false
-    }
-
-    return true
-  }
-  const resetPasswordForm = () => {
-    setCurrentPassword('')
-    setPassword('')
-    setConfirmPassword('')
-  }
-  const handleSavePassword = async () => {
-    if (!valid())
-      return
-    try {
-      setEditing(true)
-      await updateUserProfile({
-        url: 'account/password',
-        body: {
-          password: currentPassword,
-          new_password: password,
-          repeat_new_password: confirmPassword,
-        },
-      })
-      notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
-      mutateUserProfile()
-      setEditPasswordModalVisible(false)
-      resetPasswordForm()
-      setEditing(false)
-    }
-    catch (e) {
-      notify({ type: 'error', message: (e as Error).message })
-      setEditPasswordModalVisible(false)
-      setEditing(false)
-    }
-  }
-
-  const renderAppItem = (item: IItem) => {
-    return (
-      <div className='flex px-3 py-1'>
-        <div className='mr-3'>
-          <AppIcon size='tiny' />
-        </div>
-        <div className='mt-[3px] text-xs font-medium text-gray-700 leading-[18px]'>{item.name}</div>
-      </div>
-    )
-  }
-
-  return (
-    <>
-      <div className='mb-8'>
-        <div className={titleClassName}>{t('common.account.avatar')}</div>
-        <Avatar name={userProfile.name} size={64} className='mt-2' />
-      </div>
-      <div className='mb-8'>
-        <div className={titleClassName}>{t('common.account.name')}</div>
-        <div className={classNames('flex items-center justify-between mt-2 w-full h-9 px-3 bg-gray-100 rounded text-sm font-normal text-gray-800 cursor-pointer group')}>
-          {userProfile.name}
-          <div className='items-center hidden h-6 px-2 text-xs font-normal bg-white border border-gray-200 rounded-md group-hover:flex' onClick={handleEditName}>{t('common.operation.edit')}</div>
-        </div>
-      </div>
-      <div className='mb-8'>
-        <div className={titleClassName}>{t('common.account.email')}</div>
-        <div className={classNames(inputClassName, 'cursor-pointer')}>{userProfile.email}</div>
-      </div>
-      {systemFeatures.enable_email_password_login && (
-        <div className='mb-8'>
-          <div className='mb-1 text-sm font-medium text-gray-900'>{t('common.account.password')}</div>
-          <div className='mb-2 text-xs text-gray-500'>{t('common.account.passwordTip')}</div>
-          <Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
-        </div>
-      )}
-      <div className='mb-6 border-[0.5px] border-gray-100' />
-      <div className='mb-8'>
-        <div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
-        <div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
-        {!!apps.length && (
-          <Collapse
-            title={`${t('common.account.showAppLength', { length: apps.length })}`}
-            items={apps.map(app => ({ key: app.id, name: app.name }))}
-            renderItem={renderAppItem}
-            wrapperClassName='mt-2'
-          />
-        )}
-        {!IS_CE_EDITION && <Button className='mt-2 text-[#D92D20]' onClick={() => setShowDeleteAccountModal(true)}>{t('common.account.delete')}</Button>}
-      </div>
-      {editNameModalVisible && (
-        <Modal
-          isShow
-          onClose={() => setEditNameModalVisible(false)}
-          className={s.modal}
-        >
-          <div className='mb-6 text-lg font-medium text-gray-900'>{t('common.account.editName')}</div>
-          <div className={titleClassName}>{t('common.account.name')}</div>
-          <input
-            className={inputClassName}
-            value={editName}
-            onChange={e => setEditName(e.target.value)}
-          />
-          <div className='flex justify-end mt-10'>
-            <Button className='mr-2' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
-            <Button
-              disabled={editing || !editName}
-              variant='primary'
-              onClick={handleSaveName}
-            >
-              {t('common.operation.save')}
-            </Button>
-          </div>
-        </Modal>
-      )}
-      {editPasswordModalVisible && (
-        <Modal
-          isShow
-          onClose={() => {
-            setEditPasswordModalVisible(false)
-            resetPasswordForm()
-          }}
-          className={s.modal}
-        >
-          <div className='mb-6 text-lg font-medium text-gray-900'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
-          {userProfile.is_password_set && (
-            <>
-              <div className={titleClassName}>{t('common.account.currentPassword')}</div>
-              <input
-                type="password"
-                className={inputClassName}
-                value={currentPassword}
-                onChange={e => setCurrentPassword(e.target.value)}
-              />
-            </>
-          )}
-          <div className='mt-8 text-sm font-medium text-gray-900'>
-            {userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
-          </div>
-          <input
-            type="password"
-            className={inputClassName}
-            value={password}
-            onChange={e => setPassword(e.target.value)}
-          />
-          <div className='mt-8 text-sm font-medium text-gray-900'>{t('common.account.confirmPassword')}</div>
-          <input
-            type="password"
-            className={inputClassName}
-            value={confirmPassword}
-            onChange={e => setConfirmPassword(e.target.value)}
-          />
-          <div className='flex justify-end mt-10'>
-            <Button className='mr-2' onClick={() => {
-              setEditPasswordModalVisible(false)
-              resetPasswordForm()
-            }}>{t('common.operation.cancel')}</Button>
-            <Button
-              disabled={editing}
-              variant='primary'
-              onClick={handleSavePassword}
-            >
-              {userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
-            </Button>
-          </div>
-        </Modal>
-      )}
-      {showDeleteAccountModal && (
-        <Confirm
-          isShow
-          onCancel={() => setShowDeleteAccountModal(false)}
-          onConfirm={() => setShowDeleteAccountModal(false)}
-          showCancel={false}
-          type='warning'
-          title={t('common.account.delete')}
-          content={
-            <>
-              <div className='my-1 text-[#D92D20] text-sm leading-5'>
-                {t('common.account.deleteTip')}
-              </div>
-              <div className='mt-3 text-sm leading-5'>
-                <span>{t('common.account.deleteConfirmTip')}</span>
-                <a
-                  className='text-primary-600 cursor'
-                  href={`mailto:support@dify.ai?subject=Delete Account Request&body=Delete Account: ${userProfile.email}`}
-                  target='_blank'
-                  rel='noreferrer noopener'
-                  onClick={(e) => {
-                    e.preventDefault()
-                    window.location.href = e.currentTarget.href
-                  }}
-                >
-                  support@dify.ai
-                </a>
-              </div>
-              <div className='my-2 px-3 py-2 rounded-lg bg-gray-100 text-sm font-medium leading-5 text-gray-800'>{`${t('common.account.delete')}: ${userProfile.email}`}</div>
-            </>
-          }
-          confirmText={t('common.operation.ok') as string}
-        />
-      )}
-    </>
-  )
-}

+ 4 - 1
web/app/layout.tsx

@@ -3,6 +3,7 @@ import I18nServer from './components/i18n-server'
 import BrowserInitor from './components/browser-initor'
 import SentryInitor from './components/sentry-initor'
 import { getLocaleOnServer } from '@/i18n/server'
+import { TanstackQueryIniter } from '@/context/query-client'
 import './styles/globals.css'
 import './styles/markdown.scss'
 
@@ -46,7 +47,9 @@ const LocaleLayout = ({
       >
         <BrowserInitor>
           <SentryInitor>
-            <I18nServer>{children}</I18nServer>
+            <TanstackQueryIniter>
+              <I18nServer>{children}</I18nServer>
+            </TanstackQueryIniter>
           </SentryInitor>
         </BrowserInitor>
       </body>

+ 15 - 2
web/i18n/en-US/common.ts

@@ -47,6 +47,8 @@ const translation = {
     view: 'View',
     viewMore: 'VIEW MORE',
     regenerate: 'Regenerate',
+    submit: 'Submit',
+    skip: 'Skip',
   },
   errorMsg: {
     fieldRequired: '{{field}} is required',
@@ -181,8 +183,19 @@ const translation = {
     editName: 'Edit Name',
     showAppLength: 'Show {{length}} apps',
     delete: 'Delete Account',
-    deleteTip: 'Deleting your account will permanently erase all your data and it cannot be recovered.',
-    deleteConfirmTip: 'To confirm, please send the following from your registered email to ',
+    deleteTip: 'Please note, once confirmed, as the Owner of any Workspaces, your workspaces will be scheduled in a queue for permanent deletion, and all your user data will be queued for permanent deletion.',
+    deletePrivacyLinkTip: 'For more information about how we handle your data, please see our ',
+    deletePrivacyLink: 'Privacy Policy.',
+    deleteSuccessTip: 'Your account needs time to finish deleting. We\'ll email you when it\'s all done.',
+    deleteLabel: 'To confirm, please type in your email below',
+    deletePlaceholder: 'Please enter your email',
+    sendVerificationButton: 'Send Verification Code',
+    verificationLabel: 'Verification Code',
+    verificationPlaceholder: 'Paste the 6-digit code',
+    permanentlyDeleteButton: 'Permanently Delete Account',
+    feedbackTitle: 'Feedback',
+    feedbackLabel: 'Tell us why you deleted your account?',
+    feedbackPlaceholder: 'Optional',
   },
   members: {
     team: 'Team',

+ 15 - 2
web/i18n/zh-Hans/common.ts

@@ -47,6 +47,8 @@ const translation = {
     view: '查看',
     viewMore: '查看更多',
     regenerate: '重新生成',
+    submit: '提交',
+    skip: '跳过',
   },
   errorMsg: {
     fieldRequired: '{{field}} 为必填项',
@@ -181,8 +183,19 @@ const translation = {
     editName: '编辑名字',
     showAppLength: '显示 {{length}} 个应用',
     delete: '删除账户',
-    deleteTip: '删除账户后,所有数据将被永久删除且不可恢复。',
-    deleteConfirmTip: '请将以下内容通过您的账户邮箱发送到 ',
+    deleteTip: '请注意,一旦确认,作为任何空间的所有者,您的空间将被安排进入永久删除队列,您的所有用户数据也将被排入永久删除队列。',
+    deletePrivacyLinkTip: '有关我们如何处理您的数据的更多信息,请参阅我们的',
+    deletePrivacyLink: '隐私政策',
+    deleteSuccessTip: '删除账户需要一些时间。完成后,我们会通过邮件通知您。',
+    deleteLabel: '请输入您的邮箱以确认',
+    deletePlaceholder: '输入您的邮箱...',
+    sendVerificationButton: '发送验证码',
+    verificationLabel: '验证码',
+    verificationPlaceholder: '输入 6 位数字验证码',
+    permanentlyDeleteButton: '永久删除',
+    feedbackTitle: '反馈',
+    feedbackLabel: '请告诉我们您为什么删除账户?',
+    feedbackPlaceholder: '选填',
   },
   members: {
     team: '团队',

+ 9 - 0
web/service/common.ts

@@ -339,3 +339,12 @@ export const sendResetPasswordCode = (email: string, language = 'en-US') =>
 
 export const verifyResetPasswordCode = (body: { email: string;code: string;token: string }) =>
   post<CommonResponse & { is_valid: boolean }>('/forgot-password/validity', { body })
+
+export const sendDeleteAccountCode = () =>
+  get<CommonResponse & { data: string }>('/account/delete/verify')
+
+export const verifyDeleteAccountCode = (body: { code: string;token: string }) =>
+  post<CommonResponse & { is_valid: boolean }>('/account/delete', { body })
+
+export const submitDeleteAccountFeedback = (body: { feedback: string;email: string }) =>
+  post<CommonResponse>('/account/delete/feedback', { body })