mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-07 12:40:26 +01:00
Compare commits
6 Commits
has-value-
...
rbac-misc-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c8e86120 | ||
|
|
5dbe919732 | ||
|
|
73b4b0bae4 | ||
|
|
b1a38b13fb | ||
|
|
dcd53f8579 | ||
|
|
ae41afc2df |
@@ -14,6 +14,8 @@ import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
@@ -28,6 +30,8 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -54,13 +58,10 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
const errMessage = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMessage as APIError);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -90,7 +91,7 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<form
|
||||
|
||||
@@ -11,6 +11,16 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
@@ -92,10 +102,13 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Failed to create service account/i),
|
||||
expect.anything(),
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useMemberRoleManager,
|
||||
} from 'hooks/member/useMemberRoleManager';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
@@ -90,8 +91,11 @@ function EditMemberDrawer({
|
||||
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
const isDeleted = member?.status === MemberStatus.Deleted;
|
||||
const isSelf = !!member?.id && member.id === currentUser?.id;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const {
|
||||
data: fetchedUser,
|
||||
isLoading: isFetchingUser,
|
||||
@@ -111,26 +115,39 @@ function EditMemberDrawer({
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const { fetchedRoleIds, applyDiff } = useMemberRoleManager(
|
||||
member?.id ?? '',
|
||||
open && !!member?.id,
|
||||
);
|
||||
const {
|
||||
fetchedRoleIds,
|
||||
isLoading: isMemberRolesLoading,
|
||||
applyDiff,
|
||||
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
|
||||
|
||||
const fetchedDisplayName =
|
||||
fetchedUser?.data?.displayName ?? member?.name ?? '';
|
||||
const fetchedUserId = fetchedUser?.data?.id;
|
||||
const fetchedUserDisplayName = fetchedUser?.data?.displayName;
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedUserId) {
|
||||
setLocalDisplayName(fetchedUserDisplayName ?? member?.name ?? '');
|
||||
}
|
||||
setSaveErrors([]);
|
||||
}, [fetchedUserId, fetchedUserDisplayName, member?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
}, [fetchedRoleIds]);
|
||||
if (fetchedUserId) {
|
||||
setSaveErrors([]);
|
||||
}
|
||||
}, [fetchedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!member?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
|
||||
setLocalRole(fetchedRoleIds[0] ?? '');
|
||||
roleSessionRef.current = member.id;
|
||||
}
|
||||
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
@@ -153,17 +170,10 @@ function EditMemberDrawer({
|
||||
onClose();
|
||||
},
|
||||
onError: (err): void => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
const prefix = isInvited
|
||||
? 'Failed to revoke invite'
|
||||
: 'Failed to delete member';
|
||||
toast.error(`${prefix}: ${errMessage}`, {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
const errMessage = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMessage as APIError);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -344,15 +354,15 @@ function EditMemberDrawer({
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
);
|
||||
showErrorModal(errMsg as APIError);
|
||||
} finally {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}, [member, isInvited, onClose]);
|
||||
}, [member, isInvited, onClose, showErrorModal]);
|
||||
|
||||
const [copyState, copyToClipboard] = useCopyToClipboard();
|
||||
const handleCopyResetLink = useCallback((): void => {
|
||||
@@ -419,7 +429,7 @@ function EditMemberDrawer({
|
||||
}}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
disabled={isRootUser}
|
||||
disabled={isRootUser || isDeleted}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -440,9 +450,15 @@ function EditMemberDrawer({
|
||||
<label className="edit-member-drawer__label" htmlFor="member-role">
|
||||
Roles
|
||||
</label>
|
||||
{isSelf || isRootUser ? (
|
||||
{isSelf || isRootUser || isDeleted ? (
|
||||
<Tooltip
|
||||
title={isRootUser ? ROOT_USER_TOOLTIP : 'You cannot modify your own role'}
|
||||
title={
|
||||
isRootUser
|
||||
? ROOT_USER_TOOLTIP
|
||||
: isDeleted
|
||||
? undefined
|
||||
: 'You cannot modify your own role'
|
||||
}
|
||||
>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<div className="edit-member-drawer__disabled-roles">
|
||||
@@ -467,7 +483,7 @@ function EditMemberDrawer({
|
||||
onRefetch={refetchRoles}
|
||||
value={localRole}
|
||||
onChange={(role): void => {
|
||||
setLocalRole(role);
|
||||
setLocalRole(role ?? '');
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(err) =>
|
||||
@@ -476,6 +492,7 @@ function EditMemberDrawer({
|
||||
);
|
||||
}}
|
||||
placeholder="Select role"
|
||||
allowClear={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -487,6 +504,10 @@ function EditMemberDrawer({
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : member?.status === MemberStatus.Deleted ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
@@ -525,55 +546,57 @@ function EditMemberDrawer({
|
||||
<div className="edit-member-drawer__layout">
|
||||
<div className="edit-member-drawer__body">{drawerBody}</div>
|
||||
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{!isDeleted && (
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink && 'Generating...'}
|
||||
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
|
||||
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink && 'Generating...'}
|
||||
{!isGeneratingLink && isInvited && 'Copy Invite Link'}
|
||||
{!isGeneratingLink && !isInvited && 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -84,6 +84,16 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const mockDeleteMutate = jest.fn();
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockFetchedUser = {
|
||||
data: {
|
||||
id: 'user-1',
|
||||
@@ -147,6 +157,7 @@ function renderDrawer(
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
@@ -459,7 +470,6 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
it('shows API error message when deleteUser fails for active member', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockDeleteMutate.mockImplementation(() => {
|
||||
@@ -477,16 +487,20 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to delete member: Something went wrong on server',
|
||||
expect.anything(),
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe(
|
||||
'Something went wrong on server',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows API error message when deleteUser fails for invited member', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
(useDeleteUser as jest.Mock).mockImplementation((options) => ({
|
||||
mutate: mockDeleteMutate.mockImplementation(() => {
|
||||
@@ -504,9 +518,14 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
'Failed to revoke invite: Something went wrong on server',
|
||||
expect.anything(),
|
||||
expect(showErrorModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
getErrorMessage: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const passedError = showErrorModal.mock.calls[0][0] as any;
|
||||
expect(passedError.getErrorMessage()).toBe(
|
||||
'Something went wrong on server',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
@@ -40,6 +41,8 @@ function InviteMembersModal({
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
@@ -204,13 +207,11 @@ function InviteMembersModal({
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
const apiErr = err as APIError;
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(errorMessage, { richColors: true, position: 'top-right' });
|
||||
showErrorModal(err as APIError);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [rows, onComplete, resetAndClose, validateAllUsers]);
|
||||
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
@@ -227,7 +228,7 @@ function InviteMembersModal({
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
@@ -329,6 +330,7 @@ function InviteMembersModal({
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -22,6 +21,16 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
@@ -34,6 +43,7 @@ const defaultProps = {
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
@@ -154,9 +164,10 @@ describe('InviteMembersModal', () => {
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: single@signoz.io'),
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -171,18 +182,16 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockInviteUsers.mockRejectedValue(
|
||||
makeApiError('An invite already exists for this email: alice@signoz.io'),
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
);
|
||||
mockInviteUsers.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -201,18 +210,17 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
mockSendInvite.mockRejectedValue(
|
||||
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
const error = makeApiError(
|
||||
'Internal server error',
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
@@ -227,10 +235,7 @@ describe('InviteMembersModal', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Internal server error',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ function MembersTable({
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => {
|
||||
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
|
||||
const isClickable = !!onRowClick;
|
||||
return {
|
||||
onClick: (): void => {
|
||||
if (isClickable) {
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('MembersTable', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
|
||||
it('renders DELETED badge and calls onRowClick when a deleted member row is clicked', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const deletedMember: MemberRow = {
|
||||
@@ -108,7 +108,7 @@ describe('MembersTable', () => {
|
||||
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Dave Deleted'));
|
||||
expect(onRowClick).not.toHaveBeenCalledWith(
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-del' }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,7 +85,8 @@ interface BaseProps {
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string) => void;
|
||||
onChange?: (role: string | undefined) => void;
|
||||
allowClear?: boolean;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
@@ -154,13 +155,14 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange } = props as SingleProps;
|
||||
const { value, onChange, allowClear = true } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
className={cx('roles-single-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AxiosError } from 'axios';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import KeyCreatedPhase from './KeyCreatedPhase';
|
||||
import KeyFormPhase from './KeyFormPhase';
|
||||
@@ -27,6 +29,7 @@ import './AddKeyModal.styles.scss';
|
||||
|
||||
function AddKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.ADD_KEY,
|
||||
@@ -81,11 +84,11 @@ function AddKeyModal(): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to create key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -151,7 +154,7 @@ function AddKeyModal(): JSX.Element {
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
{phase === Phase.FORM && (
|
||||
<KeyFormPhase
|
||||
|
||||
@@ -16,9 +16,12 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function DeleteAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId, setAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useQueryState(
|
||||
SA_QUERY_PARAMS.DELETE_SA,
|
||||
@@ -45,11 +48,11 @@ function DeleteAccountModal(): JSX.Element {
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to delete service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -79,7 +82,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
|
||||
@@ -17,7 +17,9 @@ import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { RevokeKeyContent } from '../RevokeKeyModal';
|
||||
import EditKeyForm from './EditKeyForm';
|
||||
@@ -41,6 +43,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
const open = !!editKeyId && !!selectedAccountId;
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -78,11 +81,11 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -102,12 +105,13 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
});
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -160,7 +164,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<RevokeKeyContent
|
||||
|
||||
@@ -17,7 +17,7 @@ interface OverviewTabProps {
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRole: string;
|
||||
onRoleChange: (v: string) => void;
|
||||
onRoleChange: (v: string | undefined) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
export interface RevokeKeyContentProps {
|
||||
isRevoking: boolean;
|
||||
@@ -56,6 +58,7 @@ export function RevokeKeyContent({
|
||||
|
||||
function RevokeKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
const [accountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
|
||||
const [revokeKeyId, setRevokeKeyId] = useQueryState(
|
||||
SA_QUERY_PARAMS.REVOKE_KEY,
|
||||
@@ -83,11 +86,11 @@ function RevokeKeyModal(): JSX.Element {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
showErrorModal(
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -115,7 +118,7 @@ function RevokeKeyModal(): JSX.Element {
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
@@ -8,7 +8,9 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
getListServiceAccountsQueryKey,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccount,
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
@@ -23,7 +25,10 @@ import {
|
||||
ServiceAccountStatus,
|
||||
toServiceAccountRow,
|
||||
} from 'container/ServiceAccountsSettings/utils';
|
||||
import { useServiceAccountRoleManager } from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
RoleUpdateFailure,
|
||||
useServiceAccountRoleManager,
|
||||
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -49,6 +54,13 @@ export interface ServiceAccountDrawerProps {
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
function toSaveApiError(err: unknown): APIError {
|
||||
return (
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>)
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
onSuccess,
|
||||
@@ -103,21 +115,35 @@ function ServiceAccountDrawer({
|
||||
[accountData],
|
||||
);
|
||||
|
||||
const { currentRoles, applyDiff } = useServiceAccountRoleManager(
|
||||
selectedAccountId ?? '',
|
||||
);
|
||||
const {
|
||||
currentRoles,
|
||||
isLoading: isRolesLoading,
|
||||
applyDiff,
|
||||
} = useServiceAccountRoleManager(selectedAccountId ?? '');
|
||||
|
||||
const roleSessionRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
setLocalName(account?.name ?? '');
|
||||
setKeysPage(1);
|
||||
}
|
||||
setSaveErrors([]);
|
||||
}, [account?.id, account?.name, setKeysPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRole(currentRoles[0]?.id ?? '');
|
||||
}, [currentRoles]);
|
||||
if (account?.id) {
|
||||
setSaveErrors([]);
|
||||
}
|
||||
}, [account?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!account?.id) {
|
||||
roleSessionRef.current = null;
|
||||
} else if (account.id !== roleSessionRef.current && !isRolesLoading) {
|
||||
setLocalRole(currentRoles[0]?.id ?? '');
|
||||
roleSessionRef.current = account.id;
|
||||
}
|
||||
}, [account?.id, currentRoles, isRolesLoading]);
|
||||
|
||||
const isDeleted =
|
||||
account?.status?.toUpperCase() === ServiceAccountStatus.Deleted;
|
||||
@@ -153,12 +179,22 @@ function ServiceAccountDrawer({
|
||||
|
||||
// the retry for this mutation is safe due to the api being idempotent on backend
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole();
|
||||
|
||||
const toSaveApiError = useCallback(
|
||||
(err: unknown): APIError =>
|
||||
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) ??
|
||||
toAPIError(err as AxiosError<RenderErrorResponseDTO>),
|
||||
[],
|
||||
const executeRolesOperation = useCallback(
|
||||
async (accountId: string): Promise<RoleUpdateFailure[]> => {
|
||||
if (localRole === '' && currentRoles[0]?.id) {
|
||||
await deleteRole({
|
||||
pathParams: { id: accountId, rid: currentRoles[0].id },
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
getGetServiceAccountRolesQueryKey({ id: accountId }),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return applyDiff([localRole].filter(Boolean), availableRoles);
|
||||
},
|
||||
[localRole, currentRoles, availableRoles, applyDiff, deleteRole, queryClient],
|
||||
);
|
||||
|
||||
const retryNameUpdate = useCallback(async (): Promise<void> => {
|
||||
@@ -180,14 +216,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
localName,
|
||||
updateMutateAsync,
|
||||
refetchAccount,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
]);
|
||||
}, [account, localName, updateMutateAsync, refetchAccount, queryClient]);
|
||||
|
||||
const handleNameChange = useCallback((name: string): void => {
|
||||
setLocalName(name);
|
||||
@@ -210,29 +239,39 @@ function ServiceAccountDrawer({
|
||||
);
|
||||
}
|
||||
},
|
||||
[toSaveApiError],
|
||||
[],
|
||||
);
|
||||
|
||||
const clearRoleErrors = useCallback((): void => {
|
||||
setSaveErrors((prev) =>
|
||||
prev.filter(
|
||||
(e) => e.context !== 'Roles update' && !e.context.startsWith("Role '"),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const failuresToSaveErrors = useCallback(
|
||||
(failures: RoleUpdateFailure[]): SaveError[] =>
|
||||
failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
}),
|
||||
[makeRoleRetry],
|
||||
);
|
||||
|
||||
const retryRolesUpdate = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const failures = await applyDiff(
|
||||
[localRole].filter(Boolean),
|
||||
availableRoles,
|
||||
);
|
||||
const failures = await executeRolesOperation(selectedAccountId ?? '');
|
||||
if (failures.length === 0) {
|
||||
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Roles update'));
|
||||
} else {
|
||||
setSaveErrors((prev) => {
|
||||
const rest = prev.filter((e) => e.context !== 'Roles update');
|
||||
const roleErrors = failures.map((f) => {
|
||||
const ctx = `Role '${f.roleName}'`;
|
||||
return {
|
||||
context: ctx,
|
||||
apiError: toSaveApiError(f.error),
|
||||
onRetry: makeRoleRetry(ctx, f.onRetry),
|
||||
};
|
||||
});
|
||||
return [...rest, ...roleErrors];
|
||||
return [...rest, ...failuresToSaveErrors(failures)];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -242,7 +281,7 @@ function ServiceAccountDrawer({
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [localRole, availableRoles, applyDiff, toSaveApiError, makeRoleRetry]);
|
||||
}, [selectedAccountId, executeRolesOperation, failuresToSaveErrors]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
@@ -261,7 +300,7 @@ function ServiceAccountDrawer({
|
||||
|
||||
const [nameResult, rolesResult] = await Promise.allSettled([
|
||||
namePromise,
|
||||
applyDiff([localRole].filter(Boolean), availableRoles),
|
||||
executeRolesOperation(account.id),
|
||||
]);
|
||||
|
||||
const errors: SaveError[] = [];
|
||||
@@ -281,14 +320,7 @@ function ServiceAccountDrawer({
|
||||
onRetry: retryRolesUpdate,
|
||||
});
|
||||
} else {
|
||||
for (const failure of rolesResult.value) {
|
||||
const context = `Role '${failure.roleName}'`;
|
||||
errors.push({
|
||||
context,
|
||||
apiError: toSaveApiError(failure.error),
|
||||
onRetry: makeRoleRetry(context, failure.onRetry),
|
||||
});
|
||||
}
|
||||
errors.push(...failuresToSaveErrors(rolesResult.value));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
@@ -310,17 +342,14 @@ function ServiceAccountDrawer({
|
||||
account,
|
||||
isDirty,
|
||||
localName,
|
||||
localRole,
|
||||
availableRoles,
|
||||
updateMutateAsync,
|
||||
applyDiff,
|
||||
executeRolesOperation,
|
||||
refetchAccount,
|
||||
onSuccess,
|
||||
queryClient,
|
||||
toSaveApiError,
|
||||
retryNameUpdate,
|
||||
makeRoleRetry,
|
||||
retryRolesUpdate,
|
||||
failuresToSaveErrors,
|
||||
]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
@@ -413,7 +442,10 @@ function ServiceAccountDrawer({
|
||||
localName={localName}
|
||||
onNameChange={handleNameChange}
|
||||
localRole={localRole}
|
||||
onRoleChange={setLocalRole}
|
||||
onRoleChange={(role): void => {
|
||||
setLocalRole(role ?? '');
|
||||
clearRoleErrors();
|
||||
}}
|
||||
isDisabled={isDeleted}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
|
||||
@@ -51,6 +51,8 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
if (filterMode === FilterMode.Invited) {
|
||||
result = result.filter((m) => m.status === MemberStatus.Invited);
|
||||
} else if (filterMode === FilterMode.Deleted) {
|
||||
result = result.filter((m) => m.status === MemberStatus.Deleted);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
@@ -89,6 +91,9 @@ function MembersSettings(): JSX.Element {
|
||||
const pendingCount = allMembers.filter(
|
||||
(m) => m.status === MemberStatus.Invited,
|
||||
).length;
|
||||
const deletedCount = allMembers.filter(
|
||||
(m) => m.status === MemberStatus.Deleted,
|
||||
).length;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
@@ -118,12 +123,27 @@ function MembersSettings(): JSX.Element {
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Deleted,
|
||||
label: (
|
||||
<div className="members-filter-option">
|
||||
<span>Deleted ⎯ {deletedCount}</span>
|
||||
{filterMode === FilterMode.Deleted && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Deleted);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterLabel =
|
||||
filterMode === FilterMode.All
|
||||
? `All members ⎯ ${totalCount}`
|
||||
: `Pending invites ⎯ ${pendingCount}`;
|
||||
: filterMode === FilterMode.Invited
|
||||
? `Pending invites ⎯ ${pendingCount}`
|
||||
: `Deleted ⎯ ${deletedCount}`;
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
|
||||
@@ -117,14 +117,14 @@ describe('MembersSettings (integration)', () => {
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('does not open EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Dave Deleted'));
|
||||
|
||||
expect(screen.queryByText('Member Details')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Member Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Invited = 'invited',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export enum MemberStatus {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DisplayName from '../index';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ORG_ME_ENDPOINT = '*/api/v2/orgs/me';
|
||||
|
||||
const defaultProps = { index: 0, id: 'does-not-matter-id' };
|
||||
|
||||
describe('DisplayName', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders form pre-filled with org displayName from context', async () => {
|
||||
render(<DisplayName {...defaultProps} />);
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
expect(input).toHaveValue('Pentagon');
|
||||
|
||||
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables submit and calls PUT when display name is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(rest.put(ORG_ME_ENDPOINT, (_, res, ctx) => res(ctx.status(200))));
|
||||
|
||||
render(<DisplayName {...defaultProps} />);
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'New Org Name');
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /submit/i });
|
||||
expect(submitBtn).toBeEnabled();
|
||||
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error when display name is cleared and submitted', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<DisplayName {...defaultProps} />);
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
await user.clear(input);
|
||||
|
||||
// Submit button is disabled when empty, so trigger validation via Enter
|
||||
await user.type(input, '{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/missing display name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useUpdateMyOrganization } from 'api/generated/services/orgs';
|
||||
import {
|
||||
useGetMyOrganization,
|
||||
useUpdateMyOrganization,
|
||||
} from 'api/generated/services/orgs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
|
||||
function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
@@ -14,8 +21,25 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
const orgName = Form.useWatch('displayName', form);
|
||||
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
const { org, updateOrg } = useAppContext();
|
||||
const { displayName } = (org || [])[index];
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { org, updateOrg, user } = useAppContext();
|
||||
const currentOrg = (org || [])[index];
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
|
||||
const { data: orgData } = useGetMyOrganization({
|
||||
query: {
|
||||
enabled: isAdmin && !currentOrg?.displayName,
|
||||
},
|
||||
});
|
||||
|
||||
const displayName =
|
||||
currentOrg?.displayName ?? orgData?.data?.displayName ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName && !form.getFieldValue('displayName')) {
|
||||
form.setFieldsValue({ displayName });
|
||||
}
|
||||
}, [displayName, form]);
|
||||
|
||||
const {
|
||||
mutateAsync: updateMyOrganization,
|
||||
@@ -30,12 +54,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
updateOrg(orgId, data.displayName ?? '');
|
||||
},
|
||||
onError: (error) => {
|
||||
const apiError = convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO>,
|
||||
);
|
||||
toast.error(
|
||||
apiError?.getErrorMessage() ?? t('something_went_wrong', { ns: 'common' }),
|
||||
{ richColors: true, position: 'top-right' },
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -73,7 +73,10 @@ export function useServiceAccountRoleManager(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
await invalidateRoles();
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
||||
if (successCount > 0) {
|
||||
await invalidateRoles();
|
||||
}
|
||||
|
||||
const failures: RoleUpdateFailure[] = [];
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { useQuery } from 'react-query';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useGetMyOrganization } from 'api/generated/services/orgs';
|
||||
import { useGetMyUser } from 'api/generated/services/users';
|
||||
import listOrgPreferences from 'api/v1/org/preferences/list';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
@@ -85,14 +84,6 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
query: { enabled: isLoggedIn },
|
||||
});
|
||||
|
||||
const {
|
||||
data: orgData,
|
||||
isFetching: isFetchingOrgData,
|
||||
error: orgFetchDataError,
|
||||
} = useGetMyOrganization({
|
||||
query: { enabled: isLoggedIn },
|
||||
});
|
||||
|
||||
const {
|
||||
permissions: permissionsResult,
|
||||
isFetching: isFetchingPermissions,
|
||||
@@ -102,10 +93,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
|
||||
const isFetchingUser =
|
||||
isFetchingUserData || isFetchingOrgData || isFetchingPermissions;
|
||||
const userFetchError =
|
||||
userFetchDataError || orgFetchDataError || errorOnPermissions;
|
||||
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
|
||||
const userFetchError = userFetchDataError || errorOnPermissions;
|
||||
|
||||
const userRole = useMemo(() => {
|
||||
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
|
||||
@@ -145,39 +134,40 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
createdAt: toISOString(userData.data.createdAt) ?? prev.createdAt,
|
||||
updatedAt: toISOString(userData.data.updatedAt) ?? prev.updatedAt,
|
||||
}));
|
||||
}
|
||||
}, [userData, isFetchingUserData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingOrgData && orgData?.data) {
|
||||
const { id: orgId, displayName: orgDisplayName } = orgData.data;
|
||||
setOrg((prev) => {
|
||||
// todo: we need to update the org name as well, we should have the [admin only role restriction on the get org api call] - BE input needed
|
||||
setOrg((prev): any => {
|
||||
if (!prev) {
|
||||
return [{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' }];
|
||||
return [
|
||||
{
|
||||
createdAt: 0,
|
||||
id: userData.data.orgId,
|
||||
},
|
||||
];
|
||||
}
|
||||
const orgIndex = prev.findIndex((e) => e.id === orgId);
|
||||
const orgIndex = prev.findIndex((e) => e.id === userData.data.orgId);
|
||||
|
||||
if (orgIndex === -1) {
|
||||
return [
|
||||
...prev,
|
||||
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
|
||||
{
|
||||
createdAt: 0,
|
||||
id: userData.data.orgId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const updatedOrg: Organization[] = [
|
||||
return [
|
||||
...prev.slice(0, orgIndex),
|
||||
{ createdAt: 0, id: orgId, displayName: orgDisplayName ?? '' },
|
||||
{
|
||||
createdAt: 0,
|
||||
id: userData.data.orgId,
|
||||
},
|
||||
...prev.slice(orgIndex + 1),
|
||||
];
|
||||
return updatedOrg;
|
||||
});
|
||||
|
||||
setDefaultUser((prev) => ({
|
||||
...prev,
|
||||
organization: orgDisplayName ?? prev.organization,
|
||||
}));
|
||||
}
|
||||
}, [orgData, isFetchingOrgData]);
|
||||
}, [userData, isFetchingUserData]);
|
||||
|
||||
// fetcher for licenses v3
|
||||
const {
|
||||
|
||||
@@ -281,48 +281,6 @@ describe('AppProvider user and org data from v2 APIs', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('populates org state from GET /api/v2/orgs/me', async () => {
|
||||
server.use(
|
||||
rest.get(MY_ORG_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
id: 'org-abc',
|
||||
displayName: 'My Org',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.get(MY_USER_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: { id: 'u-default', email: 'default@signoz.io' } }),
|
||||
),
|
||||
),
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.org).not.toBeNull();
|
||||
const org = result.current.org?.[0];
|
||||
expect(org?.id).toBe('org-abc');
|
||||
expect(org?.displayName).toBe('My Org');
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('sets isFetchingUser false once both user and org calls complete', async () => {
|
||||
server.use(
|
||||
rest.get(MY_USER_URL, (_, res, ctx) =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import APIError from 'types/api/error';
|
||||
interface ErrorModalContextType {
|
||||
showErrorModal: (error: APIError) => void;
|
||||
hideErrorModal: () => void;
|
||||
isErrorModalVisible: boolean;
|
||||
}
|
||||
|
||||
const ErrorModalContext = createContext<ErrorModalContextType | undefined>(
|
||||
@@ -38,10 +39,10 @@ export function ErrorModalProvider({
|
||||
setIsVisible(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [
|
||||
showErrorModal,
|
||||
hideErrorModal,
|
||||
]);
|
||||
const value = useMemo(
|
||||
() => ({ showErrorModal, hideErrorModal, isErrorModalVisible: isVisible }),
|
||||
[showErrorModal, hideErrorModal, isVisible],
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorModalContext.Provider value={value}>
|
||||
|
||||
@@ -818,9 +818,9 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
case "has":
|
||||
cond = fmt.Sprintf("has(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||
case "hasAny":
|
||||
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||
cond = fmt.Sprintf("hasAny(%s, %s)", fieldName, v.builder.Var(value))
|
||||
case "hasAll":
|
||||
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value[0]))
|
||||
cond = fmt.Sprintf("hasAll(%s, %s)", fieldName, v.builder.Var(value))
|
||||
}
|
||||
conds = append(conds, cond)
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestStatementBuilderListQueryBodyHas(t *testing.T) {
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND hasAll(dynamicElement(body_v2.`user.permissions`, 'Array(Nullable(String))'), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), []any{"read", "write"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"read", "write"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
@@ -212,7 +212,7 @@ func TestStatementBuilderListQueryBodyHas(t *testing.T) {
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND hasAny(arrayFlatten(arrayConcat(arrayMap(`body_v2.education`->arrayConcat(arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), dynamicElement(`body_v2.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), arrayMap(`body_v2.education[].awards`->arrayConcat(arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')), arrayMap(`body_v2.education[].awards[].participated`->dynamicElement(`body_v2.education[].awards[].participated`.`members`, 'Array(Nullable(String))'), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education[].awards`.`participated`, 'Array(Dynamic)'))))), arrayMap(x->assumeNotNull(dynamicElement(x, 'JSON')), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_v2.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_v2.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), []any{"Piyush", "Tushar"}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), []any{[]any{"Piyush", "Tushar"}}, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user