Compare commits

...

4 Commits

Author SHA1 Message Date
SagarRajput-7
bf06f8b4e2 fix(user-info): code refactor 2026-05-21 16:26:56 +05:30
SagarRajput-7
a04a61c08a fix(user-info): refactor and added tests 2026-05-21 15:08:13 +05:30
SagarRajput-7
7b047783db fix(user-info): removed notification from atnd and used toast and showerrormodal in userinfo 2026-05-21 14:59:51 +05:30
SagarRajput-7
5d5c5ae214 fix(user-info): surfaced errors for reset password and fixed issues 2026-05-21 14:41:45 +05:30
4 changed files with 198 additions and 39 deletions

View File

@@ -13,7 +13,7 @@ export function NoAuthBanner(): JSX.Element {
Impersonation mode: authentication is disabled. Anyone with access to this
instance has admin privileges.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/configuration/no-auth-mode/"
href="https://signoz.io/docs/manage/administrator-guide/configuration/impersonation-mode/"
target="_blank"
rel="noreferrer"
>

View File

@@ -127,6 +127,12 @@
flex-direction: column;
gap: 8px;
padding-bottom: 16px;
.password-error-text {
font-size: var(--font-size-xs);
color: var(--bg-cherry-400);
margin-top: 2px;
}
}
.ant-color-picker-trigger {

View File

@@ -1,25 +1,27 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
updateMyPassword,
useUpdateMyUserV2,
} from 'api/generated/services/users';
import { useNotifications } from 'hooks/useNotifications';
import { toast } from '@signozhq/ui/sonner';
import { Check, FileTerminal, Mail, User } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ErrorV2Resp } from 'types/api';
import { AxiosError } from 'axios';
import '../MySettings.styles.scss';
import './UserInfo.styles.scss';
function UserInfo(): JSX.Element {
const { user, org, updateUser } = useAppContext();
const { t } = useTranslation(['routes', 'settings', 'common']);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const [currentPassword, setCurrentPassword] = useState<string>('');
@@ -47,6 +49,8 @@ function UserInfo(): JSX.Element {
const hideResetPasswordModal = (): void => {
setIsResetPasswordModalOpen(false);
setCurrentPassword('');
setUpdatePassword('');
};
const onChangePasswordClickHandler = async (): Promise<void> => {
@@ -57,27 +61,29 @@ function UserInfo(): JSX.Element {
newPassword: updatePassword,
oldPassword: currentPassword,
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
toast.success('Password updated successfully');
hideResetPasswordModal();
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).error.error.code,
description: (error as APIError).error.error.message,
});
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
}
};
const passwordsMatch =
currentPassword.length > 0 &&
updatePassword.length > 0 &&
currentPassword === updatePassword;
const isResetPasswordDisabled =
isLoading ||
currentPassword.length === 0 ||
updatePassword.length === 0 ||
currentPassword === updatePassword;
passwordsMatch;
const onSaveHandler = async (): Promise<void> => {
void logEvent('Account Settings: Name Updated', {
@@ -94,11 +100,7 @@ function UserInfo(): JSX.Element {
setIsLoading(true);
await updateMyUser({ data: { displayName: changedName } });
notifications.success({
message: t('success', {
ns: 'common',
}),
});
toast.success('Name updated successfully');
updateUser({
...user,
displayName: changedName,
@@ -106,10 +108,11 @@ function UserInfo(): JSX.Element {
setIsLoading(false);
hideUpdateNameModal();
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
}
setIsLoading(false);
};
@@ -166,7 +169,7 @@ function UserInfo(): JSX.Element {
type="primary"
icon={<Check size={16} />}
onClick={onSaveHandler}
disabled={isLoading}
loading={isLoading}
data-testid="update-name-btn"
>
Update name
@@ -178,7 +181,11 @@ function UserInfo(): JSX.Element {
<Input
placeholder="e.g. John Doe"
value={changedName}
disabled={isLoading}
onChange={(e): void => setChangedName(e.target.value)}
onPressEnter={(): void => {
void onSaveHandler();
}}
/>
</div>
</Modal>
@@ -188,6 +195,7 @@ function UserInfo(): JSX.Element {
title={<span className="title">Reset password</span>}
open={isResetPasswordModalOpen}
closable
destroyOnClose
onCancel={hideResetPasswordModal}
footer={[
<Button
@@ -197,7 +205,8 @@ function UserInfo(): JSX.Element {
}`}
icon={<Check size={16} />}
onClick={onChangePasswordClickHandler}
disabled={isLoading || isResetPasswordDisabled}
loading={isLoading}
disabled={isResetPasswordDisabled}
data-testid="reset-password-btn"
>
Reset password
@@ -218,6 +227,11 @@ function UserInfo(): JSX.Element {
type="password"
autoComplete="off"
visibilityToggle
onPressEnter={(): void => {
if (!isResetPasswordDisabled) {
void onChangePasswordClickHandler();
}
}}
/>
</div>
@@ -235,7 +249,18 @@ function UserInfo(): JSX.Element {
type="password"
autoComplete="off"
visibilityToggle={false}
status={passwordsMatch ? 'error' : ''}
onPressEnter={(): void => {
if (!isResetPasswordDisabled) {
void onChangePasswordClickHandler();
}
}}
/>
{passwordsMatch && (
<span className="password-error-text">
New password must be different from current password
</span>
)}
</div>
</div>
</Modal>

View File

@@ -8,11 +8,23 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
const updateMyPasswordFn = jest.fn();
const showErrorModalFn = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('react-use', () => ({
__esModule: true,
@@ -24,12 +36,21 @@ jest.mock('react-use', () => ({
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
updateMyPassword: (...args: unknown[]): Promise<unknown> =>
updateMyPasswordFn(...args),
useUpdateMyUserV2: jest.fn(() => ({
mutateAsync: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
isLoading: false,
})),
}));
jest.mock('providers/ErrorModalProvider', () => ({
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal: showErrorModalFn,
})),
}));
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
useIsDarkMode: jest.fn(() => true),
@@ -65,12 +86,12 @@ const NEW_PASSWORD_TEST_ID = 'new-password-textbox';
const UPDATE_NAME_BUTTON_TEST_ID = 'update-name-btn';
const RESET_PASSWORD_BUTTON_TEST_ID = 'reset-password-btn';
const UPDATE_NAME_BUTTON_TEXT = 'Update name';
const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
describe('MySettings Flows', () => {
beforeEach(() => {
jest.clearAllMocks();
editUserFn.mockResolvedValue({});
updateMyPasswordFn.mockResolvedValue({});
render(<MySettingsContainer />);
});
@@ -152,9 +173,7 @@ describe('MySettings Flows', () => {
fireEvent.click(modalUpdateNameButton);
await waitFor(() =>
expect(successNotification).toHaveBeenCalledWith({
message: 'success',
}),
expect(toast.success).toHaveBeenCalledWith('Name updated successfully'),
);
});
});
@@ -181,22 +200,131 @@ describe('MySettings Flows', () => {
expect(screen.getByTestId(NEW_PASSWORD_TEST_ID)).toBeInTheDocument();
});
it('Should display validation error if password is less than 8 characters', async () => {
it('Should show inline error when new password matches current password', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
const currentPasswordTextbox = screen.getByTestId(CURRENT_PASSWORD_TEST_ID);
act(() => {
fireEvent.change(currentPasswordTextbox, { target: { value: '123' } });
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
});
expect(
screen.getByText('New password must be different from current password'),
).toBeInTheDocument();
expect(screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID)).toBeDisabled();
});
it('Should hide inline error when passwords are changed to be different', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
});
act(() => {
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'differentPassword1' },
});
});
expect(
screen.queryByText('New password must be different from current password'),
).not.toBeInTheDocument();
});
it('Should show error modal when password reset API returns an error', async () => {
updateMyPasswordFn.mockRejectedValue(
new Error('Current password is incorrect'),
);
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'oldPassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'newPassword1' },
});
});
fireEvent.click(screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID));
await waitFor(() => {
// Use getByTestId for the validation message (if present in your modal/component)
if (screen.queryByTestId(PASSWORD_VALIDATION_MESSAGE_TEST_ID)) {
expect(
screen.getByTestId(PASSWORD_VALIDATION_MESSAGE_TEST_ID),
).toBeInTheDocument();
}
expect(showErrorModalFn).toHaveBeenCalledWith(expect.any(APIError));
});
});
it('Should show success toast and close modal on successful password reset', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'oldPassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'newPassword1' },
});
});
fireEvent.click(screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID));
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Password updated successfully');
expect(
screen.queryByTestId(CURRENT_PASSWORD_TEST_ID),
).not.toBeInTheDocument();
});
});
it('Should clear password fields when modal is cancelled', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'somePassword' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'otherPassword' },
});
});
expect(screen.getByTestId(CURRENT_PASSWORD_TEST_ID)).toHaveValue(
'somePassword',
);
// Close the modal
const closeButton = document.querySelector(
'.reset-password-modal .ant-modal-close',
) as HTMLElement;
fireEvent.click(closeButton);
// Reopen the modal
await waitFor(() => {
expect(
screen.queryByTestId(CURRENT_PASSWORD_TEST_ID),
).not.toBeInTheDocument();
});
fireEvent.click(screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT)[0]);
await waitFor(() => {
expect(screen.getByTestId(CURRENT_PASSWORD_TEST_ID)).toHaveValue('');
expect(screen.getByTestId(NEW_PASSWORD_TEST_ID)).toHaveValue('');
});
});