mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-18 10:42:14 +00:00
Compare commits
3 Commits
deprecate/
...
update-mem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a86092b93 | ||
|
|
54545c7712 | ||
|
|
7301b8005a |
@@ -1,19 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
|
||||
const get = async (): Promise<SuccessResponseV2<PendingInvite[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/invite`);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
@@ -1,22 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/user/accept';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const accept = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<UserResponse>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/invite/accept`, props);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default accept;
|
||||
@@ -1,20 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/user/deleteInvite';
|
||||
|
||||
const del = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete(`/invite/${props.id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default del;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
InviteDetails,
|
||||
PayloadProps,
|
||||
Props,
|
||||
} from 'types/api/user/getInviteDetails';
|
||||
|
||||
const getInviteDetails = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<InviteDetails>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(
|
||||
`/invite/${props.inviteId}?ref=${window.location.href}`,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getInviteDetails;
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Link,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -17,14 +16,11 @@ import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Select } from 'antd';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INVITE_PREFIX, MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
@@ -45,7 +41,6 @@ function EditMemberDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
onRefetch,
|
||||
}: EditMemberDrawerProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -60,9 +55,6 @@ function EditMemberDrawer({
|
||||
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
// Invited member IDs are prefixed with 'invite-'; strip it to get the real invite ID
|
||||
const inviteId =
|
||||
isInvited && member ? member.id.slice(INVITE_PREFIX.length) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
@@ -89,80 +81,22 @@ function EditMemberDrawer({
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const saveInvitedMember = useCallback(async (): Promise<void> => {
|
||||
if (!member || !inviteId) {
|
||||
return;
|
||||
}
|
||||
await cancelInvite({ id: inviteId });
|
||||
try {
|
||||
await sendInvite({
|
||||
email: member.email,
|
||||
name: displayName,
|
||||
role: selectedRole,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
toast.success('Invite updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
onRefetch?.();
|
||||
onClose();
|
||||
toast.error(
|
||||
'Failed to send the updated invite. Please re-invite this member.',
|
||||
{ richColors: true },
|
||||
);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
inviteId,
|
||||
displayName,
|
||||
selectedRole,
|
||||
onComplete,
|
||||
onClose,
|
||||
onRefetch,
|
||||
]);
|
||||
|
||||
const saveActiveMember = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
await update({
|
||||
userId: member.id,
|
||||
displayName,
|
||||
role: selectedRole,
|
||||
});
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
}, [member, displayName, selectedRole, onComplete, onClose]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!member || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await saveInvitedMember();
|
||||
} else {
|
||||
await saveActiveMember();
|
||||
}
|
||||
await update({ userId: member.id, displayName, role: selectedRole });
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to update invite' : 'Failed to update member details',
|
||||
{ richColors: true },
|
||||
);
|
||||
toast.error('Failed to update member details', { richColors: true });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
isDirty,
|
||||
isInvited,
|
||||
inviteId,
|
||||
saveInvitedMember,
|
||||
saveActiveMember,
|
||||
]);
|
||||
}, [member, isDirty, displayName, selectedRole, onComplete, onClose]);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
@@ -170,13 +104,13 @@ function EditMemberDrawer({
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await cancelInvite({ id: inviteId });
|
||||
toast.success('Invitation cancelled successfully', { richColors: true });
|
||||
} else {
|
||||
await deleteUser({ userId: member.id });
|
||||
toast.success('Member deleted successfully', { richColors: true });
|
||||
}
|
||||
await deleteUser({ userId: member.id });
|
||||
toast.success(
|
||||
isInvited
|
||||
? 'Invitation cancelled successfully'
|
||||
: 'Member deleted successfully',
|
||||
{ richColors: true },
|
||||
);
|
||||
setShowDeleteConfirm(false);
|
||||
onComplete();
|
||||
onClose();
|
||||
@@ -188,7 +122,7 @@ function EditMemberDrawer({
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [member, isInvited, inviteId, onComplete, onClose]);
|
||||
}, [member, isInvited, onComplete, onClose]);
|
||||
|
||||
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
@@ -235,29 +169,6 @@ function EditMemberDrawer({
|
||||
}
|
||||
}, [resetLink]);
|
||||
|
||||
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
|
||||
if (!member?.token) {
|
||||
toast.error('Invite link is not available', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
toast.success('Invite link copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to copy invite link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
@@ -352,26 +263,14 @@ function EditMemberDrawer({
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
|
||||
{isInvited ? (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled={!member?.token}
|
||||
>
|
||||
<Link size={12} />
|
||||
Copy Invite Link
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
@@ -48,8 +47,6 @@ jest.mock('@signozhq/dialog', () => ({
|
||||
|
||||
jest.mock('api/v1/user/id/update');
|
||||
jest.mock('api/v1/user/id/delete');
|
||||
jest.mock('api/v1/invite/id/delete');
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/factor_password/getResetPasswordToken');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
@@ -60,7 +57,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockUpdate = jest.mocked(update);
|
||||
const mockDeleteUser = jest.mocked(deleteUser);
|
||||
const mockCancelInvite = jest.mocked(cancelInvite);
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const activeMember = {
|
||||
@@ -74,13 +70,12 @@ const activeMember = {
|
||||
};
|
||||
|
||||
const invitedMember = {
|
||||
id: 'invite-abc123',
|
||||
id: 'abc123',
|
||||
name: '',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: '1700000000000',
|
||||
token: 'tok-xyz',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
@@ -102,7 +97,6 @@ describe('EditMemberDrawer', () => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockCancelInvite.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders active member details and disables Save when form is not dirty', () => {
|
||||
@@ -163,20 +157,23 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => {
|
||||
it('shows Cancel Invite and Generate Password Reset Link for invited members; hides Last Modified', () => {
|
||||
renderDrawer({ member: invitedMember });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel invite/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy invite link/i }),
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /copy invite link/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Invited On')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls cancelInvite after confirming Cancel Invite for invited members', async () => {
|
||||
it('calls deleteUser after confirming Cancel Invite for invited members', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -192,7 +189,29 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' });
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'abc123' });
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls update API when saving changes for an invited member', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Bob');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Bob Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'abc123', displayName: 'Bob Updated' }),
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -260,7 +279,6 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
|
||||
// Verify success path: writeText called with the correct link
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Reset link copied to clipboard',
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface MemberRow {
|
||||
status: MemberStatus;
|
||||
joinedOn: string | null;
|
||||
updatedAt?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
interface MembersTableProps {
|
||||
@@ -64,11 +63,23 @@ function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
if (status === MemberStatus.Deleted) {
|
||||
return (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === MemberStatus.Invited) {
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge color="vanilla">⎯</Badge>;
|
||||
}
|
||||
|
||||
function MembersEmptyState({
|
||||
@@ -203,10 +214,17 @@ function MembersTable({
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
|
||||
onClick: (): void => onRowClick?.(record),
|
||||
style: onRowClick ? { cursor: 'pointer' } : undefined,
|
||||
})}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => {
|
||||
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
|
||||
return {
|
||||
onClick: (): void => {
|
||||
if (isClickable) {
|
||||
onRowClick(record);
|
||||
}
|
||||
},
|
||||
style: isClickable ? { cursor: 'pointer' } : undefined,
|
||||
};
|
||||
}}
|
||||
onChange={(_, __, sorter): void => {
|
||||
if (onSortChange) {
|
||||
onSortChange(
|
||||
|
||||
@@ -24,13 +24,12 @@ const mockActiveMembers: MemberRow[] = [
|
||||
];
|
||||
|
||||
const mockInvitedMember: MemberRow = {
|
||||
id: 'invite-abc',
|
||||
id: 'inv-abc',
|
||||
name: '',
|
||||
email: 'charlie@signoz.io',
|
||||
role: 'EDITOR' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: null,
|
||||
token: 'tok-123',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
@@ -93,6 +92,34 @@ describe('MembersTable', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const deletedMember: MemberRow = {
|
||||
id: 'user-del',
|
||||
name: 'Dave Deleted',
|
||||
email: 'dave@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Deleted,
|
||||
joinedOn: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={[...mockActiveMembers, deletedMember]}
|
||||
total={3}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Dave Deleted'));
|
||||
expect(onRowClick).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-del' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No members found" empty state when no data and no search query', () => {
|
||||
render(<MembersTable {...defaultProps} data={[]} total={0} searchQuery="" />);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import getPendingInvites from 'api/v1/invite/get';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
@@ -14,7 +13,7 @@ import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
|
||||
import { FilterMode, MemberStatus } from './utils';
|
||||
|
||||
import './MembersSettings.styles.scss';
|
||||
|
||||
@@ -34,51 +33,36 @@ function MembersSettings(): JSX.Element {
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
isLoading: isUsersLoading,
|
||||
refetch: refetchUsers,
|
||||
} = useQuery({
|
||||
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
|
||||
queryFn: getAll,
|
||||
queryKey: ['getOrgUser', org?.[0]?.id],
|
||||
});
|
||||
|
||||
const {
|
||||
data: invitesData,
|
||||
isLoading: isInvitesLoading,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryFn: getPendingInvites,
|
||||
queryKey: ['getPendingInvites'],
|
||||
});
|
||||
|
||||
const isLoading = isUsersLoading || isInvitesLoading;
|
||||
|
||||
const allMembers = useMemo((): MemberRow[] => {
|
||||
const activeMembers: MemberRow[] = (usersData?.data ?? []).map((user) => ({
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: user.createdAt ? String(user.createdAt) : null,
|
||||
updatedAt: user?.updatedAt ? String(user.updatedAt) : null,
|
||||
}));
|
||||
|
||||
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
|
||||
(invite) => ({
|
||||
id: `${INVITE_PREFIX}${invite.id}`,
|
||||
name: invite.name ?? '',
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
|
||||
token: invite.token ?? null,
|
||||
const allMembers = useMemo(
|
||||
(): MemberRow[] =>
|
||||
(usersData?.data ?? []).map((user) => {
|
||||
let status: MemberStatus;
|
||||
if (user.status === 'pending_invite') {
|
||||
status = MemberStatus.Invited;
|
||||
} else if (user.status === 'deleted') {
|
||||
status = MemberStatus.Deleted;
|
||||
} else if (user.status === 'active') {
|
||||
status = MemberStatus.Active;
|
||||
} else {
|
||||
status = MemberStatus.Anonymous;
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status,
|
||||
joinedOn: user.createdAt ? String(user.createdAt) : null,
|
||||
updatedAt: user.updatedAt ? String(user.updatedAt) : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return [...activeMembers, ...pendingInvites];
|
||||
}, [usersData, invitesData]);
|
||||
[usersData],
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo((): MemberRow[] => {
|
||||
let result = allMembers;
|
||||
@@ -124,7 +108,9 @@ function MembersSettings(): JSX.Element {
|
||||
}
|
||||
}, [filteredMembers.length, currentPage, setPage]);
|
||||
|
||||
const pendingCount = invitesData?.data?.length ?? 0;
|
||||
const pendingCount = allMembers.filter(
|
||||
(m) => m.status === MemberStatus.Invited,
|
||||
).length;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
@@ -163,8 +149,7 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
refetchInvites();
|
||||
}, [refetchUsers, refetchInvites]);
|
||||
}, [refetchUsers]);
|
||||
|
||||
const handleRowClick = useCallback((member: MemberRow): void => {
|
||||
setSelectedMember(member);
|
||||
@@ -176,9 +161,8 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
const handleMemberEditComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
refetchInvites();
|
||||
setSelectedMember(null);
|
||||
}, [refetchUsers, refetchInvites]);
|
||||
}, [refetchUsers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
@@ -13,7 +12,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
}));
|
||||
|
||||
const USERS_ENDPOINT = '*/api/v1/user';
|
||||
const INVITES_ENDPOINT = '*/api/v1/invite';
|
||||
|
||||
const mockUsers: UserResponse[] = [
|
||||
{
|
||||
@@ -21,7 +19,8 @@ const mockUsers: UserResponse[] = [
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN',
|
||||
createdAt: 1700000000,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
@@ -30,20 +29,30 @@ const mockUsers: UserResponse[] = [
|
||||
displayName: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER',
|
||||
createdAt: 1700000001,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
const mockInvites: PendingInvite[] = [
|
||||
{
|
||||
id: 'inv-1',
|
||||
displayName: '',
|
||||
email: 'charlie@signoz.io',
|
||||
name: 'Charlie',
|
||||
role: 'EDITOR',
|
||||
createdAt: 1700000002,
|
||||
token: 'tok-abc',
|
||||
status: 'pending_invite',
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
displayName: 'Dave Deleted',
|
||||
email: 'dave@signoz.io',
|
||||
role: 'VIEWER',
|
||||
status: 'deleted',
|
||||
createdAt: '2024-01-04T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,9 +63,6 @@ describe('MembersSettings (integration)', () => {
|
||||
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockUsers })),
|
||||
),
|
||||
rest.get(INVITES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockInvites })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,14 +70,16 @@ describe('MembersSettings (integration)', () => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('loads and displays active users and pending invites', async () => {
|
||||
it('loads and displays active users, pending invites, and deleted members', async () => {
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
|
||||
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dave Deleted')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
expect(screen.getByText('INVITED')).toBeInTheDocument();
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
@@ -107,7 +115,7 @@ describe('MembersSettings (integration)', () => {
|
||||
expect(screen.queryByText('charlie@signoz.io')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a member row is clicked', async () => {
|
||||
it('opens EditMemberDrawer when an active member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
@@ -117,6 +125,16 @@ describe('MembersSettings (integration)', () => {
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('does not open 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();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const INVITE_PREFIX = 'invite-';
|
||||
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Invited = 'invited',
|
||||
@@ -8,4 +6,6 @@ export enum FilterMode {
|
||||
export enum MemberStatus {
|
||||
Active = 'Active',
|
||||
Invited = 'Invited',
|
||||
Deleted = 'Deleted',
|
||||
Anonymous = 'Anonymous',
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export const inviteUser = {
|
||||
status: 'success',
|
||||
data: {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: [
|
||||
{
|
||||
email: 'jane@doe.com',
|
||||
name: 'Jane',
|
||||
token: 'testtoken',
|
||||
createdAt: 1715741587,
|
||||
role: 'VIEWER',
|
||||
organization: 'test',
|
||||
},
|
||||
{
|
||||
email: 'test+in@singoz.io',
|
||||
name: '',
|
||||
token: 'testtoken1',
|
||||
createdAt: 1720095913,
|
||||
role: 'VIEWER',
|
||||
organization: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getDashboardById,
|
||||
} from './__mockdata__/dashboards';
|
||||
import { explorerView } from './__mockdata__/explorer_views';
|
||||
import { inviteUser } from './__mockdata__/invite_user';
|
||||
import { licensesSuccessResponse } from './__mockdata__/licenses';
|
||||
import { membersResponse } from './__mockdata__/members';
|
||||
import { queryRangeSuccessResponse } from './__mockdata__/query_range';
|
||||
@@ -175,11 +174,8 @@ export const handlers = [
|
||||
res(ctx.status(200), ctx.json(getDashboardById)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(inviteUser)),
|
||||
),
|
||||
rest.post('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(inviteUser)),
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.put('http://localhost/api/v1/user/:id', (_, res, ctx) =>
|
||||
res(
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Input as AntdInput, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import accept from 'api/v1/invite/id/accept';
|
||||
import getInviteDetails from 'api/v1/invite/id/get';
|
||||
import signUpApi from 'api/v1/register/post';
|
||||
import passwordAuthNContext from 'api/v2/sessions/email_password/post';
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
@@ -15,9 +11,7 @@ import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ArrowRight, CircleAlert } from 'lucide-react';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { InviteDetails } from 'types/api/user/getInviteDetails';
|
||||
|
||||
import { FormContainer, Label } from './styles';
|
||||
|
||||
@@ -39,22 +33,6 @@ function SignUp(): JSX.Element {
|
||||
false,
|
||||
);
|
||||
const [formError, setFormError] = useState<APIError | null>();
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
const token = params.get('token');
|
||||
const [isDetailsDisable, setIsDetailsDisable] = useState<boolean>(false);
|
||||
|
||||
const getInviteDetailsResponse = useQuery<
|
||||
SuccessResponseV2<InviteDetails>,
|
||||
APIError
|
||||
>({
|
||||
queryFn: () =>
|
||||
getInviteDetails({
|
||||
inviteId: token || '',
|
||||
}),
|
||||
queryKey: ['getInviteDetails', token],
|
||||
enabled: token !== null,
|
||||
});
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
@@ -64,49 +42,6 @@ function SignUp(): JSX.Element {
|
||||
const password = Form.useWatch('password', form);
|
||||
const confirmPassword = Form.useWatch('confirmPassword', form);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
getInviteDetailsResponse.status === 'success' &&
|
||||
getInviteDetailsResponse.data.data
|
||||
) {
|
||||
const responseDetails = getInviteDetailsResponse.data.data;
|
||||
form.setFieldValue('email', responseDetails.email);
|
||||
form.setFieldValue('organizationName', responseDetails.organization);
|
||||
setIsDetailsDisable(true);
|
||||
|
||||
logEvent('Account Creation Page Visited', {
|
||||
email: responseDetails.email,
|
||||
name: responseDetails.name,
|
||||
company_name: responseDetails.organization,
|
||||
source: 'SigNoz Cloud',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
getInviteDetailsResponse.data?.data,
|
||||
form,
|
||||
getInviteDetailsResponse.status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
getInviteDetailsResponse.status === 'success' &&
|
||||
getInviteDetailsResponse?.error
|
||||
) {
|
||||
const { error } = getInviteDetailsResponse;
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
getInviteDetailsResponse,
|
||||
getInviteDetailsResponse.data,
|
||||
getInviteDetailsResponse.status,
|
||||
notifications,
|
||||
]);
|
||||
|
||||
const isSignUp = token === null;
|
||||
|
||||
const signUp = async (values: FormValues): Promise<void> => {
|
||||
try {
|
||||
const { organizationName, password, email } = values;
|
||||
@@ -114,7 +49,6 @@ function SignUp(): JSX.Element {
|
||||
email,
|
||||
orgDisplayName: organizationName,
|
||||
password,
|
||||
token: params.get('token') || undefined,
|
||||
});
|
||||
|
||||
const token = await passwordAuthNContext({
|
||||
@@ -129,25 +63,6 @@ function SignUp(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const acceptInvite = async (values: FormValues): Promise<void> => {
|
||||
try {
|
||||
const { password, email } = values;
|
||||
const user = await accept({
|
||||
password,
|
||||
token: params.get('token') || '',
|
||||
});
|
||||
const token = await passwordAuthNContext({
|
||||
email,
|
||||
password,
|
||||
orgId: user.data.orgId,
|
||||
});
|
||||
|
||||
await afterLogin(token.data.accessToken, token.data.refreshToken);
|
||||
} catch (error) {
|
||||
setFormError(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -155,14 +70,10 @@ function SignUp(): JSX.Element {
|
||||
setLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
if (isSignUp) {
|
||||
await signUp(values);
|
||||
logEvent('Account Created Successfully', {
|
||||
email: values.email,
|
||||
});
|
||||
} else {
|
||||
await acceptInvite(values);
|
||||
}
|
||||
await signUp(values);
|
||||
logEvent('Account Created Successfully', {
|
||||
email: values.email,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
@@ -247,7 +158,6 @@ function SignUp(): JSX.Element {
|
||||
autoFocus
|
||||
required
|
||||
id="signupEmail"
|
||||
disabled={isDetailsDisable}
|
||||
className="signup-form-input"
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
@@ -291,15 +201,13 @@ function SignUp(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
className="signup-info-callout"
|
||||
description="This will create an admin account. If you are not an admin, please ask your admin for an invite link"
|
||||
/>
|
||||
)}
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
className="signup-info-callout"
|
||||
description="This will create an admin account. If you are not an admin, please ask your admin for an invite link"
|
||||
/>
|
||||
|
||||
{confirmPasswordError && (
|
||||
<Callout
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { InviteDetails } from 'types/api/user/getInviteDetails';
|
||||
import { SignupResponse } from 'types/api/v1/register/post';
|
||||
import { Token } from 'types/api/v2/sessions/email_password/post';
|
||||
|
||||
@@ -32,14 +31,8 @@ jest.mock('lib/history', () => ({
|
||||
|
||||
const REGISTER_ENDPOINT = '*/api/v1/register';
|
||||
const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
|
||||
const INVITE_DETAILS_ENDPOINT = '*/api/v1/invite/*';
|
||||
const ACCEPT_INVITE_ENDPOINT = '*/api/v1/invite/accept';
|
||||
|
||||
interface MockSignupResponse extends SignupResponse {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
const mockSignupResponse: MockSignupResponse = {
|
||||
const mockSignupResponse: SignupResponse = {
|
||||
orgId: 'test-org-id',
|
||||
createdAt: Date.now(),
|
||||
email: 'test@signoz.io',
|
||||
@@ -53,15 +46,6 @@ const mockTokenResponse: Token = {
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
const mockInviteDetails: InviteDetails = {
|
||||
email: 'invited@signoz.io',
|
||||
name: 'Invited User',
|
||||
organization: 'Test Org',
|
||||
createdAt: Date.now(),
|
||||
role: 'ADMIN',
|
||||
token: 'invite-token-123',
|
||||
};
|
||||
|
||||
describe('SignUp Component - Regular Signup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -288,242 +272,3 @@ describe('SignUp Component - Regular Signup', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SignUp Component - Accept Invite', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.history.pushState({}, '', '/signup?token=invite-token-123');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render with Invite', () => {
|
||||
it('pre-fills form fields from invite details', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
});
|
||||
|
||||
it('disables email field when invite details are loaded', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show admin account info callout for invite flow', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/this will create an admin account/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Invite Acceptance', () => {
|
||||
it('successfully accepts invite and logs in user', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockSignupResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockTokenResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAfterLogin).toHaveBeenCalledWith(
|
||||
'mock-access-token',
|
||||
'mock-refresh-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling for Invite', () => {
|
||||
it('displays error when invite details fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INVITE_NOT_FOUND',
|
||||
message: 'Invite not found',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invalid-token',
|
||||
});
|
||||
|
||||
// Verify form is still accessible and fields are enabled
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('displays error when accept invite API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INVALID_TOKEN',
|
||||
message: 'Invalid or expired invite token',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=expired-token',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/invalid or expired invite token/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { UserResponse } from './getUser';
|
||||
|
||||
export interface Props {
|
||||
token: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface LoginPrecheckResponse {
|
||||
sso: boolean;
|
||||
ssoUrl?: string;
|
||||
canSelfRegister?: boolean;
|
||||
isUser: boolean;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: UserResponse;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { User } from 'types/reducer/app';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import { Organization } from './getOrganization';
|
||||
|
||||
export interface Props {
|
||||
inviteId: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: InviteDetails;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface InviteDetails {
|
||||
createdAt: number;
|
||||
email: User['email'];
|
||||
name: User['displayName'];
|
||||
role: ROLES;
|
||||
token: string;
|
||||
organization: Organization['displayName'];
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { User } from 'types/reducer/app';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
export interface PendingInvite {
|
||||
createdAt: number;
|
||||
email: User['email'];
|
||||
name: User['displayName'];
|
||||
role: ROLES;
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
data: PendingInvite[];
|
||||
status: string;
|
||||
};
|
||||
@@ -7,14 +7,16 @@ export interface Props {
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
createdAt: number;
|
||||
createdAt: number | string;
|
||||
email: string;
|
||||
id: string;
|
||||
displayName: string;
|
||||
orgId: string;
|
||||
organization: string;
|
||||
role: ROLES;
|
||||
updatedAt?: number;
|
||||
updatedAt?: number | string;
|
||||
isRoot?: boolean;
|
||||
status?: 'active' | 'pending_invite' | 'deleted';
|
||||
}
|
||||
export interface PayloadProps {
|
||||
data: UserResponse;
|
||||
|
||||
Reference in New Issue
Block a user