diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index f4075f20fc..4b7f0b51bc 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -221,7 +221,7 @@ block content .modal-header h3 #{translate("delete_account")} div.modal-body#delete-account-modal - p !{translate("delete_account_warning_message_3")} + p !{translate("delete_account_warning_message_3", {}, ['strong'])} if settings.createV1AccountOnLogin && settings.overleaf p strong diff --git a/services/web/frontend/js/features/settings/components/leave-section.tsx b/services/web/frontend/js/features/settings/components/leave-section.tsx new file mode 100644 index 0000000000..ceaf270b58 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/leave-section.tsx @@ -0,0 +1,29 @@ +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import LeaveModal from './leave/modal' + +function LeaveSection() { + const { t } = useTranslation() + + const [isModalOpen, setIsModalOpen] = useState(false) + + const handleClose = useCallback(() => { + setIsModalOpen(false) + }, []) + + const handleOpen = useCallback(() => { + setIsModalOpen(true) + }, []) + + return ( + <> + {t('need_to_leave')}{' '} + + {t('delete_your_account')} + + + > + ) +} + +export default LeaveSection diff --git a/services/web/frontend/js/features/settings/components/leave/modal-content.tsx b/services/web/frontend/js/features/settings/components/leave/modal-content.tsx new file mode 100644 index 0000000000..91655cd2a9 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/leave/modal-content.tsx @@ -0,0 +1,58 @@ +import { useState, Dispatch, SetStateAction } from 'react' +import { Modal, Button } from 'react-bootstrap' +import { useTranslation, Trans } from 'react-i18next' +import LeaveModalForm from './modal-form' + +type LeaveModalContentProps = { + handleHide: () => void + inFlight: boolean + setInFlight: Dispatch> +} + +function LeaveModalContent({ + handleHide, + inFlight, + setInFlight, +}: LeaveModalContentProps) { + const { t } = useTranslation() + const [isFormValid, setIsFormValid] = useState(false) + + return ( + <> + + {t('delete_account')} + + + + + ]} // eslint-disable-line react/jsx-key + /> + + + + + + + {t('cancel')} + + + + {inFlight ? <>{t('deleting')}…> : t('delete')} + + + > + ) +} + +export default LeaveModalContent diff --git a/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx b/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx new file mode 100644 index 0000000000..8c89eadc13 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx @@ -0,0 +1,46 @@ +import { Alert } from 'react-bootstrap' +import { useTranslation, Trans } from 'react-i18next' +import getMeta from '../../../../utils/meta' +import { FetchError } from '../../../../infrastructure/fetch-json' + +type LeaveModalFormErrorProps = { + error: FetchError +} + +function LeaveModalFormError({ error }: LeaveModalFormErrorProps) { + const { t } = useTranslation() + const isSaas = getMeta('ol-isSaas') as boolean + + let errorMessage + let errorTip = null + if (error.response?.status === 403) { + errorMessage = t('email_or_password_wrong_try_again') + if (isSaas) { + errorTip = ( + ]} + /> + ) + } + } else if (error.data?.error === 'SubscriptionAdminDeletionError') { + errorMessage = t('subscription_admins_cannot_be_deleted') + } else { + errorMessage = t('user_deletion_error') + } + + return ( + + {errorMessage} + {errorTip ? ( + <> + + {errorTip} + > + ) : null} + + ) +} + +export default LeaveModalFormError diff --git a/services/web/frontend/js/features/settings/components/leave/modal-form.tsx b/services/web/frontend/js/features/settings/components/leave/modal-form.tsx new file mode 100644 index 0000000000..d391f932d1 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/leave/modal-form.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect, Dispatch, SetStateAction } from 'react' +import { Checkbox, ControlLabel, FormControl, FormGroup } from 'react-bootstrap' +import { useTranslation, Trans } from 'react-i18next' +import { postJSON, FetchError } from '../../../../infrastructure/fetch-json' +import getMeta from '../../../../utils/meta' +import LeaveModalFormError from './modal-form-error' + +type LeaveModalFormProps = { + setInFlight: Dispatch> + isFormValid: boolean + setIsFormValid: Dispatch> +} + +function LeaveModalForm({ + setInFlight, + isFormValid, + setIsFormValid, +}: LeaveModalFormProps) { + const { t } = useTranslation() + const userDefaultEmail = getMeta('ol-userDefaultEmail') as string + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmation, setConfirmation] = useState(false) + const [error, setError] = useState(null) + + const handleEmailChange = event => { + setEmail(event.target.value) + } + + const handlePasswordChange = event => { + setPassword(event.target.value) + } + + const handleConfirmationChange = () => { + setConfirmation(prev => !prev) + } + + const handleSubmit = event => { + event.preventDefault() + if (!isFormValid) { + return + } + setError(null) + setInFlight(true) + postJSON('/user/delete', { + body: { + password, + }, + }) + .then(() => { + window.location.assign('/login') + }) + .catch(setError) + .finally(() => { + setInFlight(false) + }) + } + + useEffect(() => { + setIsFormValid( + !!email && + email === userDefaultEmail && + password.length > 0 && + confirmation + ) + }, [setIsFormValid, userDefaultEmail, email, password, confirmation]) + + return ( + + + {t('email')} + + + + {t('password')} + + + + ]} // eslint-disable-line react/jsx-key + values={{ + userDefaultEmail, + }} + /> + + {error ? : null} + + ) +} + +export default LeaveModalForm diff --git a/services/web/frontend/js/features/settings/components/leave/modal.tsx b/services/web/frontend/js/features/settings/components/leave/modal.tsx new file mode 100644 index 0000000000..82b9a5cad1 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/leave/modal.tsx @@ -0,0 +1,36 @@ +import { useState, useCallback } from 'react' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import LeaveModalContent from './modal-content' + +type LeaveModalProps = { + isOpen: boolean + handleClose: () => void +} + +function LeaveModal({ isOpen, handleClose }: LeaveModalProps) { + const [inFlight, setInFlight] = useState(false) + + const handleHide = useCallback(() => { + if (!inFlight) { + handleClose() + } + }, [handleClose, inFlight]) + + return ( + + + + ) +} + +export default LeaveModal diff --git a/services/web/frontend/stories/settings/leave.stories.js b/services/web/frontend/stories/settings/leave.stories.js new file mode 100644 index 0000000000..427ac1c9b9 --- /dev/null +++ b/services/web/frontend/stories/settings/leave.stories.js @@ -0,0 +1,71 @@ +import useFetchMock from '../hooks/use-fetch-mock' +import LeaveModal from '../../js/features/settings/components/leave/modal' +import LeaveSection from '../../js/features/settings/components/leave-section' + +const MOCK_DELAY = 1000 +window.metaAttributesCache = window.metaAttributesCache || new Map() + +function defaultSetupMocks(fetchMock) { + fetchMock.post(/\/user\/delete/, 200, { + delay: MOCK_DELAY, + }) +} + +export const Section = args => { + window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com') + useFetchMock(defaultSetupMocks) + + return +} +Section.component = LeaveSection +Section.parameters = { controls: { include: [], hideNoControlsWarning: true } } + +export const ModalSuccess = args => { + window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com') + useFetchMock(defaultSetupMocks) + + return +} + +export const ModalAuthError = args => { + window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com') + useFetchMock(fetchMock => { + fetchMock.post(/\/user\/delete/, 403) + }) + + return +} + +export const ModalServerError = args => { + window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com') + useFetchMock(fetchMock => { + fetchMock.post(/\/user\/delete/, 500) + }) + + return +} + +export const ModalSubscriptionError = args => { + window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com') + useFetchMock(fetchMock => { + fetchMock.post(/\/user\/delete/, { + status: 422, + body: { + error: 'SubscriptionAdminDeletionError', + }, + }) + }) + + return +} + +export default { + title: 'Account Settings / Leave', + component: LeaveModal, + args: { + isOpen: true, + }, + argTypes: { + handleClose: { action: 'handleClose' }, + }, +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 1fe3789bd8..2f74693ddd 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -229,6 +229,7 @@ "can_link_institution_email_acct_to_institution_acct": "You can now link your __email__ __appName__ account to your __institutionName__ institutional account.", "can_link_institution_email_acct_to_institution_acct_alt": "You can link your __email__ __appName__ account to your __institutionName__ institutional account.", "user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.", + "user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as Twitter or Google), please <0>reset your password0> and try again.", "card_must_be_authenticated_by_3dsecure": "Your card must be authenticated with 3D Secure before continuing", "view_your_invoices": "View Your Invoices", "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.", @@ -631,7 +632,7 @@ "log_in_with": "Log in with __provider__", "return_to_login_page": "Return to Login page", "login_failed": "Login failed", - "delete_account_warning_message_3": "You are about to permanently delete all of your account data, including your projects and settings. Please type your account email address and password in the boxes below to proceed.", + "delete_account_warning_message_3": "You are about to permanently <0>delete all of your account data0>, including your projects and settings. Please type your account email address and password in the boxes below to proceed.", "delete_account_warning_message_2": "You are about to permanently delete all of your account data, including your projects and settings. Please type your account email address into the box below to proceed.", "your_sessions": "Your Sessions", "clear_sessions_description": "This is a list of other sessions (logins) which are active on your account, not including your current session. Click the \"Clear Sessions\" button below to log them out.", @@ -917,6 +918,7 @@ "delete_your_account": "Delete your account", "delete_account": "Delete Account", "delete_account_warning_message": "You are about to permanently delete all of your account data, including your projects and settings. Please type your account email address into the box below to proceed.", + "delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__0>", "deleting": "Deleting", "delete": "Delete", "sl_benefits_plans": "__appName__ is the world’s easiest to use LaTeX editor. Stay up to date with your collaborators, keep track of all changes to your work, and use our LaTeX environment from anywhere in the world.", @@ -1182,7 +1184,7 @@ "your_settings": "Your settings", "maintenance": "Maintenance", "to_many_login_requests_2_mins": "This account has had too many login requests. Please wait 2 minutes before trying to log in again", - "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again", + "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.", "rate_limit_hit_wait": "Rate limit hit. Please wait a while before retrying", "problem_changing_email_address": "There was a problem changing your email address. Please try again in a few moments. If the problem continues please contact us.", "single_version_easy_collab_blurb": "__appName__ makes sure that you’re always up to date with your collaborators and what they are doing. There is only a single master version of each document which everyone has access to. It’s impossible to make conflicting changes, and you don’t have to wait for your colleagues to send you the latest draft before you can keep working.", diff --git a/services/web/test/acceptance/src/CaptchaTests.js b/services/web/test/acceptance/src/CaptchaTests.js index dedd4ec993..a6347f2193 100644 --- a/services/web/test/acceptance/src/CaptchaTests.js +++ b/services/web/test/acceptance/src/CaptchaTests.js @@ -54,7 +54,7 @@ describe('Captcha', function () { expect(response.statusCode).to.equal(401) expect(body).to.deep.equal({ message: { - text: 'Your email or password is incorrect. Please try again', + text: 'Your email or password is incorrect. Please try again.', type: 'error', }, }) diff --git a/services/web/test/acceptance/src/RegistrationTests.js b/services/web/test/acceptance/src/RegistrationTests.js index f3c4c16d00..b7353b347d 100644 --- a/services/web/test/acceptance/src/RegistrationTests.js +++ b/services/web/test/acceptance/src/RegistrationTests.js @@ -97,7 +97,9 @@ describe('Registration', function () { it('should produce the correct responses so far', function () { expect(results.length).to.equal(9) expect(results).to.deep.equal( - Array(9).fill('Your email or password is incorrect. Please try again') + Array(9).fill( + 'Your email or password is incorrect. Please try again.' + ) ) }) @@ -115,7 +117,7 @@ describe('Registration', function () { expect(results.length).to.equal(15) expect(results).to.deep.equal( Array(10) - .fill('Your email or password is incorrect. Please try again') + .fill('Your email or password is incorrect. Please try again.') .concat( Array(5).fill( 'This account has had too many login requests. Please wait 2 minutes before trying to log in again' @@ -145,7 +147,7 @@ describe('Registration', function () { it('should not rate limit their request', function () { expect(messages).to.deep.equal([ - 'Your email or password is incorrect. Please try again', + 'Your email or password is incorrect. Please try again.', ]) }) @@ -194,7 +196,7 @@ describe('Registration', function () { expect(results.length).to.equal(9) expect(results).to.deep.equal( Array(9).fill( - 'Your email or password is incorrect. Please try again' + 'Your email or password is incorrect. Please try again.' ) ) }) diff --git a/services/web/test/frontend/features/settings/components/leave-section.test.tsx b/services/web/test/frontend/features/settings/components/leave-section.test.tsx new file mode 100644 index 0000000000..673a395e99 --- /dev/null +++ b/services/web/test/frontend/features/settings/components/leave-section.test.tsx @@ -0,0 +1,38 @@ +import { + fireEvent, + screen, + waitForElementToBeRemoved, + render, +} from '@testing-library/react' + +import LeaveSection from '../../../../../frontend/js/features/settings/components/leave-section' + +describe('', function () { + it('opens modal', async function () { + render() + + const button = screen.getByRole('button', { + name: 'Delete your account', + }) + + fireEvent.click(button) + await screen.findByText('Delete Account') + }) + + it('closes modal', async function () { + render() + fireEvent.click( + screen.getByRole('button', { + name: 'Delete your account', + }) + ) + + const cancelButton = screen.getByRole('button', { + name: 'Close', + }) + + fireEvent.click(cancelButton) + + await waitForElementToBeRemoved(() => screen.getByText('Delete Account')) + }) +}) diff --git a/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx new file mode 100644 index 0000000000..c8d85ca3ac --- /dev/null +++ b/services/web/test/frontend/features/settings/components/leave/modal-content.test.tsx @@ -0,0 +1,26 @@ +import { expect } from 'chai' +import { screen, render } from '@testing-library/react' +import fetchMock from 'fetch-mock' + +import LeaveModalContent from '../../../../../../frontend/js/features/settings/components/leave/modal-content' + +describe('', function () { + afterEach(function () { + fetchMock.reset() + }) + + it('disable delete button if form is not valid', function () { + render( + {}} + inFlight={false} + setInFlight={() => {}} + /> + ) + + const deleteButton = screen.getByRole('button', { + name: 'Delete', + }) + expect(deleteButton.hasAttribute('disabled')).to.be.true + }) +}) diff --git a/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx new file mode 100644 index 0000000000..139268e7ba --- /dev/null +++ b/services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx @@ -0,0 +1,196 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { fireEvent, screen, render, waitFor } from '@testing-library/react' +import fetchMock from 'fetch-mock' + +import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form' + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-userDefaultEmail', 'foo@bar.com') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('validates form', async function () { + const setIsFormValid = sinon.stub() + render( + {}} + isFormValid={false} + setIsFormValid={setIsFormValid} + /> + ) + + const emailInput = screen.getByLabelText('Email') + fireEvent.change(emailInput, { target: { value: 'foo@bar.com' } }) + + const passwordInput = screen.getByLabelText('Password') + fireEvent.change(passwordInput, { target: { value: 'foobar' } }) + + const checkbox = screen.getByLabelText( + 'I understand this will delete all projects in my Overleaf account with email address foo@bar.com' + ) + fireEvent.click(checkbox) + + const setIsFormValidCalls = setIsFormValid.getCalls() + const lastSetIsFormValidCall = setIsFormValidCalls.pop() + expect(lastSetIsFormValidCall.args[0]).to.be.true + + for (const setIsFormValidCall of setIsFormValidCalls) { + expect(setIsFormValidCall.args[0]).to.be.false + } + }) + + describe('submits', async function () { + let setInFlight + let setIsFormValid + let deleteMock + let locationStub + const originalLocation = window.location + + beforeEach(function () { + setInFlight = sinon.stub() + setIsFormValid = sinon.stub() + deleteMock = fetchMock.post('/user/delete', 200) + locationStub = sinon.stub() + Object.defineProperty(window, 'location', { + value: { + assign: locationStub, + }, + }) + }) + + afterEach(function () { + fetchMock.reset() + Object.defineProperty(window, 'location', { + value: originalLocation, + }) + }) + + it('with valid form', async function () { + render( + + ) + + fireEvent.submit(screen.getByLabelText('Email')) + + sinon.assert.calledOnce(setInFlight) + sinon.assert.calledWithMatch(setInFlight, true) + expect(deleteMock.called()).to.be.true + await waitFor(() => { + sinon.assert.calledTwice(setInFlight) + sinon.assert.calledWithMatch(setInFlight, false) + sinon.assert.calledOnce(locationStub) + sinon.assert.calledWithMatch(locationStub, '/login') + }) + }) + + it('with invalid form', async function () { + render( + + ) + + fireEvent.submit(screen.getByLabelText('Email')) + + expect(deleteMock.called()).to.be.false + sinon.assert.notCalled(setInFlight) + }) + }) + + it('handles credentials error with Saas tip', async function () { + fetchMock.post('/user/delete', 403) + render( + {}} + isFormValid + setIsFormValid={() => {}} + /> + ) + + fireEvent.submit(screen.getByLabelText('Email')) + + await waitFor(() => { + screen.getByText(/Your email or password is incorrect. Please try again/) + }) + expect(screen.queryByText(/If you cannot remember your password/)).to.not + .exist + }) + + it('handles credentials error without Saas tip', async function () { + fetchMock.post('/user/delete', 403) + window.metaAttributesCache.set('ol-isSaas', true) + render( + {}} + isFormValid + setIsFormValid={() => {}} + /> + ) + + fireEvent.submit(screen.getByLabelText('Email')) + + await waitFor(() => { + screen.getByText(/Your email or password is incorrect. Please try again/) + }) + screen.getByText(/If you cannot remember your password/) + const link = screen.getByRole('link', { name: 'reset your password' }) + expect(link.getAttribute('href')).to.equal('/user/password/reset') + }) + + it('handles subscription error', async function () { + fetchMock.post('/user/delete', { + status: 422, + body: { + error: 'SubscriptionAdminDeletionError', + }, + }) + + render( + {}} + isFormValid + setIsFormValid={() => {}} + /> + ) + + fireEvent.submit(screen.getByLabelText('Email')) + + await waitFor(() => { + screen.getByText( + 'You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.' + ) + }) + }) + + it('handles generic error', async function () { + fetchMock.post('/user/delete', 500) + render( + {}} + isFormValid + setIsFormValid={() => {}} + /> + ) + + fireEvent.submit(screen.getByLabelText('Email')) + + await waitFor(() => { + screen.getByText( + 'Sorry, something went wrong deleting your account. Please try again in a minute.' + ) + }) + }) +}) diff --git a/services/web/test/frontend/features/settings/components/leave/modal.test.tsx b/services/web/test/frontend/features/settings/components/leave/modal.test.tsx new file mode 100644 index 0000000000..00e6856f97 --- /dev/null +++ b/services/web/test/frontend/features/settings/components/leave/modal.test.tsx @@ -0,0 +1,65 @@ +import sinon from 'sinon' +import { fireEvent, screen, render, waitFor } from '@testing-library/react' +import fetchMock from 'fetch-mock' + +import LeaveModal from '../../../../../../frontend/js/features/settings/components/leave/modal' + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set('ol-userDefaultEmail', 'foo@bar.com') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + fetchMock.reset() + }) + + it('closes modal on cancel', async function () { + const handleClose = sinon.stub() + render() + + const cancelButton = screen.getByRole('button', { + name: 'Cancel', + }) + fireEvent.click(cancelButton) + + sinon.assert.calledOnce(handleClose) + }) + + it('does not close modal while in flight', async function () { + fetchMock.post('/user/delete', new Promise(() => {})) + const handleClose = sinon.stub() + render() + + fillValidForm() + + const deleteButton = screen.getByRole('button', { + name: 'Delete', + }) + fireEvent.click(deleteButton) + + await waitFor(() => { + screen.getByRole('button', { + name: 'Deleting…', + }) + }) + + const cancelButton = screen.getByRole('button', { + name: 'Cancel', + }) + fireEvent.click(cancelButton) + + sinon.assert.notCalled(handleClose) + }) +}) + +function fillValidForm() { + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'foo@bar.com' }, + }) + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'foobar' }, + }) + fireEvent.click(screen.getByLabelText(/I understand/)) +}
+ ]} // eslint-disable-line react/jsx-key + /> +