Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions TaskNoteBruno/User Session/999-Delete account.bru
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ meta {
seq: 2
}

delete {
post {
url: {{addr}}/rest/user-sessions/delete-account
body: none
body: json
auth: none
}

headers {
Authorization: Bearer {{authToken}}
}

body:json {
{
"password": "Teste@123.,"
}
}

tests {
test("Status code is 200", function() {
expect(res.getStatus()).to.equal(200);
Expand Down
2 changes: 1 addition & 1 deletion client/src/__test__/utils/PortugueseUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('Portuguese Utils unit tests', () => {
expect(translateServerResponse(keys[9], 'pt_br')).toBe('Proibido! Acesso negado');
expect(translateServerResponse(keys[10], 'pt_br')).toBe('Se o endereço de e-mail informado estiver associado a uma conta, você receberá um link para resetar a senha em breve.');
expect(translateServerResponse(keys[11], 'pt_br')).toBe('Erro Interno do Servidor!');
expect(translateServerResponse(keys[12], 'pt_br')).toBe('Limite máximo de tentativas atingido. Por favor aguarde 30 minutos');
expect(translateServerResponse(keys[12], 'pt_br')).toBe('Limite máximo de tentativas atingido. Por favor aguarde 3 minutos');
expect(translateServerResponse(keys[13], 'pt_br')).toBe('Erro de rede ao tentar obter recursos.');
expect(translateServerResponse(keys[14], 'pt_br')).toBe('Por favor, confirme seu e-mail antes de continuar');
expect(translateServerResponse(keys[15], 'pt_br')).toBe('Por favor, preencha todos os campos');
Expand Down
2 changes: 1 addition & 1 deletion client/src/__test__/utils/SpanishUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ describe('Spanish Utils unit tests', () => {
expect(translateServerResponse(keys[9], 'es')).toBe('¡Prohibido! Acceso denegado');
expect(translateServerResponse(keys[10], 'es')).toBe('Si la dirección de correo electrónico ingresada está asociada a una cuenta, recibirá un enlace para restablecer su contraseña en breve.');
expect(translateServerResponse(keys[11], 'es')).toBe('¡Error interno del servidor!');
expect(translateServerResponse(keys[12], 'es')).toBe('Has alcanzado el límite máximo de intentos de inicio de sesión. Por favor, espera 30 minutos');
expect(translateServerResponse(keys[12], 'es')).toBe('Has alcanzado el límite máximo de intentos de inicio de sesión. Por favor, espera 3 minutos');
expect(translateServerResponse(keys[13], 'es')).toBe('Error de red al intentar obtener el recurso.');
expect(translateServerResponse(keys[14], 'es')).toBe('Por favor, confirme su correo electrónico antes de continuar');
expect(translateServerResponse(keys[15], 'es')).toBe('Por favor, completa todos los campos');
Expand Down
66 changes: 64 additions & 2 deletions client/src/__test__/views/Account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions client/src/constants/english.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ',
Expand Down
4 changes: 2 additions & 2 deletions client/src/constants/languageConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const serverResponsesTranslations: Record<string, string> = {
FORBIDDEN_pt_br: 'Proibido! Acesso negado',
INTERNAL_ERROR_pt_br: 'Erro Interno do Servidor!',
INVALID_CREDENTIALS_pt_br: 'E-mail ou senha inválidos!',
MAX_LOGIN_ATTEMPT_pt_br: 'Limite máximo de tentativas atingido. Por favor aguarde 30 minutos',
MAX_LOGIN_ATTEMPT_pt_br: 'Limite máximo de tentativas atingido. Por favor aguarde 3 minutos',
NETWORK_ERROR_pt_br: 'Erro de rede ao tentar obter recursos.',
FILL_ALL_FIELDS_pt_br: 'Por favor, preencha todos os campos',
FILL_NEW_PASSWORD_pt_br: 'Por favor, informe a nova senha',
Expand Down Expand Up @@ -112,7 +112,7 @@ export const serverResponsesTranslations: Record<string, string> = {
FORBIDDEN_es: '¡Prohibido! Acceso denegado',
INTERNAL_ERROR_es: '¡Error interno del servidor!',
INVALID_CREDENTIALS_es: '¡Usuario o contraseña incorrectos!',
MAX_LOGIN_ATTEMPT_es: 'Has alcanzado el límite máximo de intentos de inicio de sesión. Por favor, espera 30 minutos',
MAX_LOGIN_ATTEMPT_es: 'Has alcanzado el límite máximo de intentos de inicio de sesión. Por favor, espera 3 minutos',
NETWORK_ERROR_es: 'Error de red al intentar obtener el recurso.',
FILL_ALL_FIELDS_es: 'Por favor, completa todos los campos',
FILL_NEW_PASSWORD_es: 'Por favor, rellene la nueva contraseña',
Expand Down
4 changes: 2 additions & 2 deletions client/src/constants/portuguese.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ',
Expand Down
4 changes: 2 additions & 2 deletions client/src/constants/russian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,8 @@ const ruTranslations = {
account_privacy_delete_btn: 'Удалить все мои данные',
account_delete_title: 'Вы собираетесь удалить свой аккаунт!',
account_delete_description: `Это действие нельзя отменить. Если вы действительно хотите
удалить все свои данные, нажмите кнопку ниже. Если нет, закройте это сообщение, и ваши
данные будут в безопасности.`,
удалить все свои данные, введите текущий пароль и нажмите кнопку ниже. Если нет,
закройте это сообщение, и ваши данные будут в безопасности.`,
account_delete_btn: 'Да, удалить все',

footer_my_account: 'Мой аккаунт ',
Expand Down
2 changes: 1 addition & 1 deletion client/src/constants/serverResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const serverResponses: Record<string, string> = {
'Forbidden! Access denied!': 'FORBIDDEN',
'If the email address you entered is associated with an account, you will receive a password reset link shortly.': 'RECOVER_PASSWORD',
'Internal Server Error!': 'INTERNAL_ERROR',
'Max login attempt limit reached. Please wait 30 minutes': 'MAX_LOGIN_ATTEMPT',
'Max login attempt limit reached. Please wait 3 minutes': 'MAX_LOGIN_ATTEMPT',
'NetworkError when attempting to fetch resource.': 'NETWORK_ERROR',
'Please confirm your email before proceeding': 'CONFIRM_EMAIL_TO_GO_ON',
'Please fill in all the fields': 'FILL_ALL_FIELDS',
Expand Down
4 changes: 2 additions & 2 deletions client/src/constants/spanish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ',
Expand Down
67 changes: 58 additions & 9 deletions client/src/views/Account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ function Account(): React.ReactNode {
const [showAlert, setShowAlert] = useState<boolean>(false);
const [validated, setValidated] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string>('');
const [deletePassword, setDeletePassword] = useState<string>('');
const [deleting, setDeleting] = useState<boolean>(false);
const [userName, setUserName] = useState<string>('');
const [userEmail, setUserEmail] = useState<string>('');
const [userPassword, setUserPassword] = useState<string>('');
Expand All @@ -47,23 +50,45 @@ function Account(): React.ReactNode {
* Deletes the user account
*/
const deleteAccount = async (): Promise<void> => {
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<React.SetStateAction<string>>} setter - The state setter for the message.
*/
const handleError = (e: unknown): void => {
const handleError = (e: unknown, setter: React.Dispatch<React.SetStateAction<string>>): 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));
}
};

Expand All @@ -78,7 +103,7 @@ function Account(): React.ReactNode {
return await api.patchJSON(ApiConfig.userUrl, payload) as UserResponse;
}
catch (e) {
handleError(e);
handleError(e, setErrorMessage);
}
};

Expand Down Expand Up @@ -297,12 +322,36 @@ function Account(): React.ReactNode {
</div>

{showAlert && (
<Alert className="mt-3" variant="danger" onClose={() => setShowAlert(false)} dismissible>
<Alert className="mt-3" variant="danger" onClose={closeDeleteAlert} dismissible>
<Alert.Heading>{t('account_delete_title')}</Alert.Heading>
<p>{t('account_delete_description')}</p>

{deleteErrorMessage && (
<p className="mb-2 fw-bold" data-testid="delete-account-error">
{deleteErrorMessage}
</p>
)}

<FormInput
labelText={t('login_password_label')}
iconName="Lock"
required
type="password"
name="deleteAccountPassword"
value={deletePassword}
placeholder={t('login_password_placeholder')}
pwdShowText={t('password_show_txt')}
pwdHideText={t('password_hide_txt')}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDeletePassword(e.target.value);
}}
dataTestId="delete-account-password"
/>

<div className="d-grid">
<button
type="button"
disabled={deleting || deletePassword.length === 0}
onClick={() => deleteAccount()}
className="home-new-item-danger task-note-btn"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package br.com.tasknoteapp.server.controller;

import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.exception.UserNotFoundException;
import br.com.tasknoteapp.server.request.DeleteAccountRequest;
import br.com.tasknoteapp.server.response.JwtAuthenticationResponse;
import br.com.tasknoteapp.server.response.UserResponse;
import br.com.tasknoteapp.server.service.AuthService;
import br.com.tasknoteapp.server.service.UserSessionService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -17,8 +22,11 @@ public class UserSessionController {

private final UserSessionService userSessionService;

public UserSessionController(UserSessionService userSessionService) {
private final AuthService authService;

public UserSessionController(UserSessionService userSessionService, AuthService authService) {
this.userSessionService = userSessionService;
this.authService = authService;
}

/**
Expand All @@ -35,10 +43,14 @@ public JwtAuthenticationResponse refresh() {
/**
* Delete all the user data and information from the server.
*
* @param request {@link DeleteAccountRequest} with the current user password.
* @return {@link UserResponse} with the user information.
*/
@DeleteMapping("/delete-account")
public ResponseEntity<UserResponse> deleteAccount() {
@PostMapping("/delete-account")
public ResponseEntity<UserResponse> deleteAccount(
@RequestBody @Valid DeleteAccountRequest request) {
UserEntity user = authService.getCurrentUser().orElseThrow(UserNotFoundException::new);
authService.verifyCurrentPassword(user, request.password());
UserResponse deleted = userSessionService.deleteCurrentUserAccount();
return ResponseEntity.ok(deleted);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
public class MaxLoginLimitAttemptException extends BaseBadRequestException {

public MaxLoginLimitAttemptException() {
super("login", "Max login attempt limit reached. Please wait 30 minutes");
super("login", "Max login attempt limit reached. Please wait 3 minutes");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package br.com.tasknoteapp.server.request;

import jakarta.validation.constraints.NotBlank;

/** This class represents a delete account request carrying the current user password. */
public record DeleteAccountRequest(@NotBlank String password) {

@Override
public String toString() {
return "DeleteAccountRequest{password='[REDACTED]'}";
}
}
Loading