diff --git a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx index ee6c1a505e0af..20a00c195cd85 100644 --- a/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx +++ b/apps/meteor/client/views/account/profile/ActionConfirmModal.tsx @@ -1,6 +1,6 @@ import { Box, PasswordInput, TextInput, FieldGroup, Field, FieldRow, FieldError } from '@rocket.chat/fuselage'; import type { ChangeEvent } from 'react'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useId } from 'react'; import { useTranslation } from 'react-i18next'; import GenericModal from '../../../components/GenericModal'; @@ -38,6 +38,7 @@ const ActionConfirmModal = ({ isPassword, onConfirm, onCancel }: ActionConfirmMo [inputText, onConfirm, onCancel, t], ); + const actionTextId = useId(); return ( } @@ -48,12 +49,14 @@ const ActionConfirmModal = ({ isPassword, onConfirm, onCancel }: ActionConfirmMo title={t('Delete_account?')} confirmText={t('Delete_account')} > - {isPassword ? t('Enter_your_password_to_delete_your_account') : t('Enter_your_username_to_delete_your_account')} + + {isPassword ? t('Enter_your_password_to_delete_your_account') : t('Enter_your_username_to_delete_your_account')} + - {isPassword && } - {!isPassword && } + {isPassword && } + {!isPassword && } {inputError} diff --git a/apps/meteor/tests/e2e/delete-account.spec.ts b/apps/meteor/tests/e2e/delete-account.spec.ts new file mode 100644 index 0000000000000..636e0115f1415 --- /dev/null +++ b/apps/meteor/tests/e2e/delete-account.spec.ts @@ -0,0 +1,133 @@ +import { DEFAULT_USER_CREDENTIALS } from './config/constants'; +import { AccountProfile, Registration, Utils } from './page-objects'; +import { ToastBar } from './page-objects/toastBar'; +import { test, expect } from './utils/test'; +import { createTestUser, type ITestUser } from './utils/user-helpers'; + +test.describe('Delete Own Account', () => { + let poAccountProfile: AccountProfile; + let poRegistration: Registration; + let poToastBar: ToastBar; + let poUtils: Utils; + let userToDelete: ITestUser; + let userWithInvalidPassword: ITestUser; + let userWithoutPermissions: ITestUser; + + test.beforeAll(async ({ api }) => { + const response = await api.post('/settings/Accounts_AllowDeleteOwnAccount', { value: true }); + expect(response.status()).toBe(200); + userToDelete = await createTestUser(api, { username: 'user-to-delete' }); + userWithInvalidPassword = await createTestUser(api, { username: 'user-with-invalid-password' }); + userWithoutPermissions = await createTestUser(api, { username: 'user-without-permissions' }); + }); + + test.beforeEach(async ({ page }) => { + poAccountProfile = new AccountProfile(page); + poRegistration = new Registration(page); + poToastBar = new ToastBar(page); + poUtils = new Utils(page); + await page.goto('/home'); + }); + + test.afterAll(async ({ api }) => { + const response = await api.post('/settings/Accounts_AllowDeleteOwnAccount', { value: false }); + expect(response.status()).toBe(200); + await userToDelete.delete(); + await userWithInvalidPassword.delete(); + await userWithoutPermissions.delete(); + }); + + test('should not delete account when invalid password is provided', async ({ page }) => { + await test.step('login with the user to delete', async () => { + await poRegistration.username.type(userWithInvalidPassword.data.username); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + await expect(poUtils.mainContent).toBeVisible(); + }); + + await test.step('navigate to profile and locate Delete My Account button', async () => { + await page.goto('/account/profile'); + await poAccountProfile.profileTitle.waitFor({ state: 'visible' }); + await poAccountProfile.btnDeleteMyAccount.click(); + await expect(poAccountProfile.deleteAccountDialog).toBeVisible(); + }); + + await test.step('verify delete confirmation dialog appears', async () => { + await expect(poAccountProfile.deleteAccountDialogMessageWithPassword).toBeVisible(); + await expect(poAccountProfile.inputDeleteAccountPassword).toBeVisible(); + await expect(poAccountProfile.btnDeleteAccountConfirm).toBeVisible(); + await expect(poAccountProfile.btnDeleteAccountCancel).toBeVisible(); + }); + + await test.step('enter invalid password in the confirmation field and click delete account', async () => { + await poAccountProfile.inputDeleteAccountPassword.fill('invalid-password'); + await expect(poAccountProfile.inputDeleteAccountPassword).toHaveValue('invalid-password'); + await poAccountProfile.btnDeleteAccountConfirm.click(); + }); + + await test.step('verify error message appears', async () => { + await expect(poToastBar.alert).toBeVisible(); + await expect(poToastBar.alert).toHaveText('Invalid password [error-invalid-password]'); + }); + + await test.step('verify user is still on the profile page', async () => { + await expect(poAccountProfile.profileTitle).toBeVisible(); + }); + }); + + test('should delete account when valid password is provided and permission is enabled', async ({ page }) => { + await test.step('login with the user to delete', async () => { + await poRegistration.username.type(userToDelete.data.username); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + await expect(poUtils.mainContent).toBeVisible(); + }); + + await test.step('navigate to profile and locate Delete My Account button', async () => { + await page.goto('/account/profile'); + await poAccountProfile.profileTitle.waitFor({ state: 'visible' }); + await poAccountProfile.btnDeleteMyAccount.click(); + await expect(poAccountProfile.deleteAccountDialog).toBeVisible(); + }); + + await test.step('verify delete confirmation dialog appears', async () => { + await expect(poAccountProfile.deleteAccountDialogMessageWithPassword).toBeVisible(); + await expect(poAccountProfile.inputDeleteAccountPassword).toBeVisible(); + await expect(poAccountProfile.btnDeleteAccountConfirm).toBeVisible(); + await expect(poAccountProfile.btnDeleteAccountCancel).toBeVisible(); + }); + + await test.step('enter password in the confirmation field and click delete account', async () => { + await poAccountProfile.inputDeleteAccountPassword.fill(DEFAULT_USER_CREDENTIALS.password); + await expect(poAccountProfile.inputDeleteAccountPassword).toHaveValue(DEFAULT_USER_CREDENTIALS.password); + await poAccountProfile.btnDeleteAccountConfirm.click(); + }); + + await test.step('verify user is redirected to login page', async () => { + await expect(poRegistration.btnLogin).toBeVisible(); + userToDelete.markAsDeleted(); + }); + }); + + test.describe('Delete Own Account - Permission Disabled', () => { + test.beforeAll(async ({ api }) => { + const response = await api.post('/settings/Accounts_AllowDeleteOwnAccount', { value: false }); + expect(response.status()).toBe(200); + }); + + test('should not show delete account button when permission is disabled', async ({ page }) => { + await test.step('login with the user to delete', async () => { + await poRegistration.username.type(userWithoutPermissions.data.username); + await poRegistration.inputPassword.type(DEFAULT_USER_CREDENTIALS.password); + await poRegistration.btnLogin.click(); + await expect(poUtils.mainContent).toBeVisible(); + }); + + await test.step('navigate to profile and locate Delete My Account button', async () => { + await page.goto('/account/profile'); + await poAccountProfile.profileTitle.waitFor({ state: 'visible' }); + await expect(poAccountProfile.btnDeleteMyAccount).not.toBeVisible(); + }); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/account-profile.ts b/apps/meteor/tests/e2e/page-objects/account-profile.ts index bdd3b8873c68e..65f219a3a2388 100644 --- a/apps/meteor/tests/e2e/page-objects/account-profile.ts +++ b/apps/meteor/tests/e2e/page-objects/account-profile.ts @@ -156,4 +156,32 @@ export class AccountProfile { get required2faModalSetUpButton(): Locator { return this.page.locator('dialog >> button'); } + + get btnDeleteMyAccount(): Locator { + return this.page.getByRole('button', { name: 'Delete my account' }); + } + + get deleteAccountDialog(): Locator { + return this.page.getByRole('dialog', { name: 'Delete account?' }); + } + + get deleteAccountDialogMessageWithPassword(): Locator { + return this.deleteAccountDialog.getByText('Enter your password to delete your account. This cannot be undone.'); + } + + get inputDeleteAccountPassword(): Locator { + return this.deleteAccountDialog.getByRole('textbox', { name: 'Enter your password to delete your account. This cannot be undone.' }); + } + + get btnDeleteAccountConfirm(): Locator { + return this.deleteAccountDialog.getByRole('button', { name: 'Delete Account' }); + } + + get btnDeleteAccountCancel(): Locator { + return this.deleteAccountDialog.getByRole('button', { name: 'Cancel' }); + } + + get profileTitle(): Locator { + return this.page.getByRole('heading', { name: 'Profile' }); + } } diff --git a/apps/meteor/tests/e2e/utils/user-helpers.ts b/apps/meteor/tests/e2e/utils/user-helpers.ts new file mode 100644 index 0000000000000..329b7b83aaa0c --- /dev/null +++ b/apps/meteor/tests/e2e/utils/user-helpers.ts @@ -0,0 +1,75 @@ +import { faker } from '@faker-js/faker'; +import type { APIResponse } from '@playwright/test'; +import type { IUser } from '@rocket.chat/core-typings'; + +import type { BaseTest } from './test'; +import { DEFAULT_USER_CREDENTIALS } from '../config/constants'; + +export interface ICreateUserOptions { + username?: string; + email?: string; + name?: string; + password?: string; + roles?: string[]; +} + +export interface ITestUser { + response: APIResponse; + data: IUser & { username: string }; + deleted: boolean; + delete: () => Promise; + markAsDeleted: () => void; +} + +/** + * Creates a test user with optional customizations + */ +export async function createTestUser(api: BaseTest['api'], options: ICreateUserOptions = {}): Promise { + const userData = { + email: options.email || faker.internet.email(), + name: options.name || faker.person.fullName(), + password: options.password || DEFAULT_USER_CREDENTIALS.password, + username: options.username || `test-user-${faker.string.uuid()}`, + roles: options.roles || ['user'], + }; + + const response = await api.post('/users.create', userData); + + if (response.status() !== 200) { + throw new Error(`Failed to create user: ${response.status()}, response: ${await response.text()}`); + } + + const { user } = await response.json(); + + return { + response, + data: user, + deleted: false, + markAsDeleted(this: ITestUser) { + this.deleted = true; + }, + async delete(this: ITestUser) { + if (this.deleted) { + return; + } + + const response = await api.post('/users.delete', { userId: user._id }); + this.markAsDeleted(); + return response; + }, + }; +} + +/** + * Creates multiple test users at once + */ +export async function createTestUsers(api: BaseTest['api'], count: number, options: ICreateUserOptions = {}): Promise { + const promises = Array.from({ length: count }, (_, i) => + createTestUser(api, { + ...options, + username: options.username ? `${options.username}-${i + 1}` : undefined, + }), + ); + + return Promise.all(promises); +}