From f3e9a6a3047ebcf44d7061ec07675bde1748fa9f Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Sat, 29 Aug 2026 23:19:54 +0200 Subject: [PATCH 1/3] feat: require password confirmation to delete account (#66) The delete-account endpoint is now POST /rest/user-sessions/delete-account with a JSON body carrying the current password, replacing the bodyless DELETE. The password is verified before any deletion work begins; on mismatch the request fails, nothing is deleted, and the attempt is recorded against the existing login rate limit (3 failures in 3 minutes). Verification happens before the delete transaction opens (in the controller) so a wrong password does not open a transaction whose rollback would wipe the recorded attempt. Each downstream deletion already runs in its own transaction, so the service method no longer needs one. The confirmation dialog gains a password field with show/hide toggle and an inline error region: wrong password shows the error in place, clears the field, and keeps the dialog open; success signs the user out and clears local storage. The dialog also resets its state when dismissed and disables the confirm button while submitting or when the field is empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../User Session/999-Delete account.bru | 10 +- client/src/__test__/views/Account.test.tsx | 66 ++++++++++- client/src/constants/english.ts | 4 +- client/src/constants/portuguese.ts | 4 +- client/src/constants/russian.ts | 4 +- client/src/constants/spanish.ts | 4 +- client/src/views/Account/index.tsx | 67 +++++++++-- .../controller/UserSessionController.java | 20 +++- .../server/request/DeleteAccountRequest.java | 12 ++ .../server/service/AuthService.java | 24 ++++ .../server/service/UserSessionService.java | 6 +- .../controller/UserSessionControllerTest.java | 35 +++++- ...AccountDeletionAttemptSurvivesIntTest.java | 109 ++++++++++++++++++ .../service/AccountDeletionIntTest.java | 58 +++++++++- .../service/UserSessionServiceTest.java | 4 +- 15 files changed, 393 insertions(+), 34 deletions(-) create mode 100644 server/src/main/java/br/com/tasknoteapp/server/request/DeleteAccountRequest.java create mode 100644 server/src/test/java/br/com/tasknoteapp/server/service/AccountDeletionAttemptSurvivesIntTest.java diff --git a/TaskNoteBruno/User Session/999-Delete account.bru b/TaskNoteBruno/User Session/999-Delete account.bru index fea4d408..85536d7a 100644 --- a/TaskNoteBruno/User Session/999-Delete account.bru +++ b/TaskNoteBruno/User Session/999-Delete account.bru @@ -4,9 +4,9 @@ meta { seq: 2 } -delete { +post { url: {{addr}}/rest/user-sessions/delete-account - body: none + body: json auth: none } @@ -14,6 +14,12 @@ headers { Authorization: Bearer {{authToken}} } +body:json { + { + "password": "Teste@123.," + } +} + tests { test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); diff --git a/client/src/__test__/views/Account.test.tsx b/client/src/__test__/views/Account.test.tsx index 2dedcc8a..07ceae7c 100644 --- a/client/src/__test__/views/Account.test.tsx +++ b/client/src/__test__/views/Account.test.tsx @@ -80,18 +80,80 @@ describe('Account Component', () => { }) it('should call deleteAccount API and signOut when delete is confirmed', async () => { - const { getByText } = renderAccount(); + const { getByText, getByTestId } = renderAccount(); const deleteButton = getByText('account_privacy_delete_btn'); fireEvent.click(deleteButton); + + fireEvent.change(getByTestId('delete-account-password'), { target: { value: 'my-password' } }); const confirmButton = getByText('account_delete_btn'); fireEvent.click(confirmButton); await waitFor(() => { - expect(api.deleteNoContent).toHaveBeenCalledWith(ApiConfig.deleteAccountUrl); + expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.deleteAccountUrl, { password: 'my-password' }); expect(authContextMock.signOut).toHaveBeenCalled(); }); }); + it('should show inline error and keep dialog open when delete fails', async () => { + const mockPostJSON = vi.spyOn(api, 'postJSON').mockRejectedValue(new Error('Invalid credentials')); + + const { getByText, getByTestId, queryByTestId } = renderAccount(); + fireEvent.click(getByText('account_privacy_delete_btn')); + + const passwordInput = getByTestId('delete-account-password') as HTMLInputElement; + fireEvent.change(passwordInput, { target: { value: 'wrong-password' } }); + fireEvent.click(getByText('account_delete_btn')); + + await waitFor(() => { + expect(getByTestId('delete-account-error')).toBeDefined(); + }); + + expect(queryByTestId('delete-account-password')).toBeDefined(); + expect(passwordInput.value).toBe(''); + expect(authContextMock.signOut).not.toHaveBeenCalled(); + + mockPostJSON.mockRestore(); + }); + + it('should clear password and error when the delete dialog is closed', async () => { + const mockPostJSON = vi.spyOn(api, 'postJSON').mockRejectedValue(new Error('Invalid credentials')); + + const { getByText, getByTestId, queryByTestId, queryByText } = renderAccount(); + fireEvent.click(getByText('account_privacy_delete_btn')); + + fireEvent.change(getByTestId('delete-account-password'), { target: { value: 'wrong-password' } }); + fireEvent.click(getByText('account_delete_btn')); + + await waitFor(() => { + expect(getByTestId('delete-account-error')).toBeDefined(); + }); + + const closeButton = document.querySelector('.alert .btn-close') as HTMLElement; + fireEvent.click(closeButton); + + expect(queryByTestId('delete-account-password')).toBeNull(); + + fireEvent.click(getByText('account_privacy_delete_btn')); + + const reopenedInput = getByTestId('delete-account-password') as HTMLInputElement; + expect(reopenedInput.value).toBe(''); + expect(queryByTestId('delete-account-error')).toBeNull(); + expect(queryByText('account_delete_title')).toBeDefined(); + + mockPostJSON.mockRestore(); + }); + + it('should disable the confirm button when the password field is empty', () => { + const { getByText, getByTestId } = renderAccount(); + fireEvent.click(getByText('account_privacy_delete_btn')); + + const confirmButton = getByText('account_delete_btn') as HTMLButtonElement; + expect(confirmButton.disabled).toBe(true); + + fireEvent.change(getByTestId('delete-account-password'), { target: { value: 'x' } }); + expect(confirmButton.disabled).toBe(false); + }); + it('should submit the form with correct patchPayload', async () => { const mockPatchJSON = vi.spyOn(api, 'patchJSON').mockResolvedValue(authContextMock.user); diff --git a/client/src/constants/english.ts b/client/src/constants/english.ts index cfada5d5..01dd5869 100644 --- a/client/src/constants/english.ts +++ b/client/src/constants/english.ts @@ -199,8 +199,8 @@ const enTranslations = { account_privacy_delete_btn: 'Delete all my data', account_delete_title: 'You are about to delete your account!', account_delete_description: `This action cannot be undone. If you really want to - delete all your data, click the button below. If not, close this message and your - data will be safe.`, + delete all your data, type your current password and click the button below. If + not, close this message and your data will be safe.`, account_delete_btn: 'Yes, delete everything', footer_my_account: 'My Account ', diff --git a/client/src/constants/portuguese.ts b/client/src/constants/portuguese.ts index 3ba85d6c..b54b7c5b 100644 --- a/client/src/constants/portuguese.ts +++ b/client/src/constants/portuguese.ts @@ -200,8 +200,8 @@ const ptBrTranslations = { account_privacy_delete_btn: 'Excluir minha conta', account_delete_title: 'Você está prestes a deletar sua conta!', account_delete_description: `Esta ação não pode ser desfeita. Se você realmente - quer deletar todos os seus dados, clique no botão abaixo. Caso contrário, feche - esta mensagem e seus dados estarão seguros.`, + quer deletar todos os seus dados, digite sua senha atual e clique no botão abaixo. + Caso contrário, feche esta mensagem e seus dados estarão seguros.`, account_delete_btn: 'Sim, deletar tudo', footer_my_account: 'Minha Conta ', diff --git a/client/src/constants/russian.ts b/client/src/constants/russian.ts index e16a1ee7..e3741365 100644 --- a/client/src/constants/russian.ts +++ b/client/src/constants/russian.ts @@ -199,8 +199,8 @@ const ruTranslations = { account_privacy_delete_btn: 'Удалить все мои данные', account_delete_title: 'Вы собираетесь удалить свой аккаунт!', account_delete_description: `Это действие нельзя отменить. Если вы действительно хотите - удалить все свои данные, нажмите кнопку ниже. Если нет, закройте это сообщение, и ваши - данные будут в безопасности.`, + удалить все свои данные, введите текущий пароль и нажмите кнопку ниже. Если нет, + закройте это сообщение, и ваши данные будут в безопасности.`, account_delete_btn: 'Да, удалить все', footer_my_account: 'Мой аккаунт ', diff --git a/client/src/constants/spanish.ts b/client/src/constants/spanish.ts index a359cdea..e734b91f 100644 --- a/client/src/constants/spanish.ts +++ b/client/src/constants/spanish.ts @@ -199,8 +199,8 @@ const esTranslations = { account_privacy_delete_btn: 'Borrar todos mis datos', account_delete_title: 'Estás a punto de eliminar tu cuenta!', account_delete_description: `Esta acción no se puede deshacer. Si realmente desea - eliminar todos sus datos, haga clic en el botón que aparece a continuación. Si - no es así, cierre este mensaje y sus datos estarán seguros.`, + eliminar todos sus datos, escriba su contraseña actual y haga clic en el botón que + aparece a continuación. Si no es así, cierre este mensaje y sus datos estarán seguros.`, account_delete_btn: 'Sí, borra todo', footer_my_account: 'Mi Cuenta ', diff --git a/client/src/views/Account/index.tsx b/client/src/views/Account/index.tsx index b90b60b1..10d11a4b 100644 --- a/client/src/views/Account/index.tsx +++ b/client/src/views/Account/index.tsx @@ -27,6 +27,9 @@ function Account(): React.ReactNode { const [showAlert, setShowAlert] = useState(false); const [validated, setValidated] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [deleteErrorMessage, setDeleteErrorMessage] = useState(''); + const [deletePassword, setDeletePassword] = useState(''); + const [deleting, setDeleting] = useState(false); const [userName, setUserName] = useState(''); const [userEmail, setUserEmail] = useState(''); const [userPassword, setUserPassword] = useState(''); @@ -47,23 +50,45 @@ function Account(): React.ReactNode { * Deletes the user account */ const deleteAccount = async (): Promise => { + setDeleteErrorMessage(''); + setDeleting(true); + try { + await api.postJSON(ApiConfig.deleteAccountUrl, { password: deletePassword }); + setShowAlert(false); + setDeletePassword(''); + signOut(); + clearStorage(); + } + catch (e) { + setDeletePassword(''); + handleError(e, setDeleteErrorMessage); + } + finally { + setDeleting(false); + } + }; + + /** + * Closes the delete confirmation, clearing any typed password and error. + */ + const closeDeleteAlert = (): void => { setShowAlert(false); - await api.deleteNoContent(ApiConfig.deleteAccountUrl); - signOut(); - clearStorage(); + setDeletePassword(''); + setDeleteErrorMessage(''); }; /** - * Handles errors by setting the error message and form invalid state. + * Handles errors by translating the server response into the given state setter. * * @param {unknown} e - The error to handle. + * @param {React.Dispatch>} setter - The state setter for the message. */ - const handleError = (e: unknown): void => { + const handleError = (e: unknown, setter: React.Dispatch>): void => { if (typeof e === 'string') { - setErrorMessage(translateServerResponse(e, i18n.language)); + setter(translateServerResponse(e, i18n.language)); } else if (e instanceof Error) { - setErrorMessage(translateServerResponse(e.message, i18n.language)); + setter(translateServerResponse(e.message, i18n.language)); } }; @@ -78,7 +103,7 @@ function Account(): React.ReactNode { return await api.patchJSON(ApiConfig.userUrl, payload) as UserResponse; } catch (e) { - handleError(e); + handleError(e, setErrorMessage); } }; @@ -297,12 +322,36 @@ function Account(): React.ReactNode { {showAlert && ( - setShowAlert(false)} dismissible> + {t('account_delete_title')}

{t('account_delete_description')}

+ + {deleteErrorMessage && ( +

+ {deleteErrorMessage} +

+ )} + + ) => { + setDeletePassword(e.target.value); + }} + dataTestId="delete-account-password" + /> +