1
0
Fork 0
mirror of https://github.com/overleaf/overleaf.git synced 2025-04-22 14:48:14 +00:00

Merge pull request from overleaf/ta-leave-modal

[DeleteAccount] Create Modal with Form

GitOrigin-RevId: 611f08c7253f59d91c6937b79c80a386b9d21ccd
This commit is contained in:
ilkin-overleaf 2022-04-08 14:00:31 +03:00 committed by Copybot
parent 72f6d89e78
commit d50271c1e9
14 changed files with 688 additions and 8 deletions
services/web

View file

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

View file

@ -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')}{' '}
<button className="btn btn-inline-link" onClick={handleOpen}>
{t('delete_your_account')}
</button>
<LeaveModal isOpen={isModalOpen} handleClose={handleClose} />
</>
)
}
export default LeaveSection

View file

@ -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<SetStateAction<boolean>>
}
function LeaveModalContent({
handleHide,
inFlight,
setInFlight,
}: LeaveModalContentProps) {
const { t } = useTranslation()
const [isFormValid, setIsFormValid] = useState(false)
return (
<>
<Modal.Header closeButton>
<Modal.Title>{t('delete_account')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
<Trans
i18nKey="delete_account_warning_message_3"
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</p>
<LeaveModalForm
setInFlight={setInFlight}
isFormValid={isFormValid}
setIsFormValid={setIsFormValid}
/>
</Modal.Body>
<Modal.Footer>
<Button type="button" disabled={inFlight} onClick={handleHide}>
{t('cancel')}
</Button>
<Button
form="leave-form"
type="submit"
bsStyle="danger"
disabled={inFlight || !isFormValid}
>
{inFlight ? <>{t('deleting')}</> : t('delete')}
</Button>
</Modal.Footer>
</>
)
}
export default LeaveModalContent

View file

@ -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 = (
<Trans
i18nKey="user_deletion_password_reset_tip"
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
components={[<a href="/user/password/reset" />]}
/>
)
}
} else if (error.data?.error === 'SubscriptionAdminDeletionError') {
errorMessage = t('subscription_admins_cannot_be_deleted')
} else {
errorMessage = t('user_deletion_error')
}
return (
<Alert bsStyle="danger">
{errorMessage}
{errorTip ? (
<>
<br />
{errorTip}
</>
) : null}
</Alert>
)
}
export default LeaveModalFormError

View file

@ -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<SetStateAction<boolean>>
isFormValid: boolean
setIsFormValid: Dispatch<SetStateAction<boolean>>
}
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<FetchError | null>(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 (
<form id="leave-form" onSubmit={handleSubmit}>
<FormGroup>
<ControlLabel htmlFor="email-input">{t('email')}</ControlLabel>
<FormControl
id="email-input"
type="text"
placeholder={t('email')}
required
value={email}
onChange={handleEmailChange}
/>
</FormGroup>
<FormGroup>
<ControlLabel htmlFor="password-input">{t('password')}</ControlLabel>
<FormControl
id="password-input"
type="password"
placeholder={t('password')}
required
value={password}
onChange={handlePasswordChange}
/>
</FormGroup>
<Checkbox
required
checked={confirmation}
onChange={handleConfirmationChange}
>
<Trans
i18nKey="delete_account_confirmation_label"
components={[<i />]} // eslint-disable-line react/jsx-key
values={{
userDefaultEmail,
}}
/>
</Checkbox>
{error ? <LeaveModalFormError error={error} /> : null}
</form>
)
}
export default LeaveModalForm

View file

@ -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 (
<AccessibleModal
animation
show={isOpen}
onHide={handleHide}
id="leave-modal"
backdrop="static"
>
<LeaveModalContent
handleHide={handleHide}
inFlight={inFlight}
setInFlight={setInFlight}
/>
</AccessibleModal>
)
}
export default LeaveModal

View file

@ -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 <LeaveSection {...args} />
}
Section.component = LeaveSection
Section.parameters = { controls: { include: [], hideNoControlsWarning: true } }
export const ModalSuccess = args => {
window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com')
useFetchMock(defaultSetupMocks)
return <LeaveModal {...args} />
}
export const ModalAuthError = args => {
window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com')
useFetchMock(fetchMock => {
fetchMock.post(/\/user\/delete/, 403)
})
return <LeaveModal {...args} />
}
export const ModalServerError = args => {
window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com')
useFetchMock(fetchMock => {
fetchMock.post(/\/user\/delete/, 500)
})
return <LeaveModal {...args} />
}
export const ModalSubscriptionError = args => {
window.metaAttributesCache.set('ol-userDefaultEmail', 'user@primary.com')
useFetchMock(fetchMock => {
fetchMock.post(/\/user\/delete/, {
status: 422,
body: {
error: 'SubscriptionAdminDeletionError',
},
})
})
return <LeaveModal {...args} />
}
export default {
title: 'Account Settings / Leave',
component: LeaveModal,
args: {
isOpen: true,
},
argTypes: {
handleClose: { action: 'handleClose' },
},
}

View file

@ -229,6 +229,7 @@
"can_link_institution_email_acct_to_institution_acct": "You can now link your <b>__email__</b> <b>__appName__</b> account to your <b>__institutionName__</b> institutional account.",
"can_link_institution_email_acct_to_institution_acct_alt": "You can link your <b>__email__</b> <b>__appName__</b> account to your <b>__institutionName__</b> 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 password</0> 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 <strong>delete all of your account data</strong>, 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 data</0>, 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 <strong>delete all of your account data</strong>, 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 <strong>delete all of your account data</strong>, 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 worlds 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 youre 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. Its impossible to make conflicting changes, and you dont have to wait for your colleagues to send you the latest draft before you can keep working.",

View file

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

View file

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

View file

@ -0,0 +1,38 @@
import {
fireEvent,
screen,
waitForElementToBeRemoved,
render,
} from '@testing-library/react'
import LeaveSection from '../../../../../frontend/js/features/settings/components/leave-section'
describe('<LeaveSection />', function () {
it('opens modal', async function () {
render(<LeaveSection />)
const button = screen.getByRole('button', {
name: 'Delete your account',
})
fireEvent.click(button)
await screen.findByText('Delete Account')
})
it('closes modal', async function () {
render(<LeaveSection />)
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'))
})
})

View file

@ -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('<LeaveModalContent />', function () {
afterEach(function () {
fetchMock.reset()
})
it('disable delete button if form is not valid', function () {
render(
<LeaveModalContent
handleHide={() => {}}
inFlight={false}
setInFlight={() => {}}
/>
)
const deleteButton = screen.getByRole('button', {
name: 'Delete',
})
expect(deleteButton.hasAttribute('disabled')).to.be.true
})
})

View file

@ -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('<LeaveModalForm />', 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(
<LeaveModalForm
setInFlight={() => {}}
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(
<LeaveModalForm
setInFlight={setInFlight}
isFormValid
setIsFormValid={setIsFormValid}
/>
)
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(
<LeaveModalForm
setInFlight={setInFlight}
isFormValid={false}
setIsFormValid={setIsFormValid}
/>
)
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(
<LeaveModalForm
setInFlight={() => {}}
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(
<LeaveModalForm
setInFlight={() => {}}
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(
<LeaveModalForm
setInFlight={() => {}}
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(
<LeaveModalForm
setInFlight={() => {}}
isFormValid
setIsFormValid={() => {}}
/>
)
fireEvent.submit(screen.getByLabelText('Email'))
await waitFor(() => {
screen.getByText(
'Sorry, something went wrong deleting your account. Please try again in a minute.'
)
})
})
})

View file

@ -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('<LeaveModal />', 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(<LeaveModal isOpen handleClose={handleClose} />)
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(<LeaveModal isOpen handleClose={handleClose} />)
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/))
}