mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-20 00:40:32 +01:00
Compare commits
17 Commits
alertmanag
...
no-auth-mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106a17c306 | ||
|
|
49f4d4d620 | ||
|
|
480239fac4 | ||
|
|
abe03b69b6 | ||
|
|
938accbe80 | ||
|
|
bc6521bf25 | ||
|
|
eab8d45611 | ||
|
|
cf1cf2695b | ||
|
|
6b25d82e77 | ||
|
|
e7ddb965ad | ||
|
|
819fce9cea | ||
|
|
de27746256 | ||
|
|
f6b7b90419 | ||
|
|
d115d54024 | ||
|
|
978788b544 | ||
|
|
88fedb5081 | ||
|
|
54a4b5efc1 |
@@ -166,6 +166,8 @@ function createMockAppContext(
|
||||
userPreferences: [],
|
||||
hostsData: null,
|
||||
isLoggedIn: true,
|
||||
isNoAuthMode: false,
|
||||
isPreflightLoading: false,
|
||||
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -59,6 +59,7 @@ function App(): JSX.Element {
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
isPreflightLoading,
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
@@ -386,6 +387,10 @@ function App(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCloudUser, isEnterpriseSelfHostedUser]);
|
||||
|
||||
if (isPreflightLoading) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
}
|
||||
|
||||
// if the user is in logged in state
|
||||
if (isLoggedInState) {
|
||||
// if the setup calls are loading then return a spinner
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from 'axios';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import { interceptorRejected } from '../index';
|
||||
|
||||
jest.mock('utils/noAuthMode', () => ({
|
||||
getIsNoAuthMode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/v2/sessions/rotate/post', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('AppRoutes/utils', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
Logout: jest.fn(),
|
||||
}));
|
||||
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const post = require('api/v2/sessions/rotate/post').default;
|
||||
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
|
||||
const { Logout } = require('../utils');
|
||||
|
||||
describe('interceptorRejected — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('does NOT call rotate or Logout when no-auth mode is enabled on 401', async () => {
|
||||
(getIsNoAuthMode as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(Logout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DOES attempt rotate when no-auth mode is disabled on 401', async () => {
|
||||
(getIsNoAuthMode as jest.Mock).mockReturnValue(false);
|
||||
(post as jest.Mock).mockResolvedValue({
|
||||
data: { accessToken: 'a', refreshToken: 'b' },
|
||||
});
|
||||
|
||||
const error = {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
config: { url: '/dashboards', method: 'get' },
|
||||
},
|
||||
config: { url: '/dashboards', headers: {} },
|
||||
};
|
||||
|
||||
await interceptorRejected(error as any).catch(() => {});
|
||||
|
||||
expect(post).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
import { getIsNoAuthMode } from 'utils/noAuthMode';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
import { Logout } from './utils';
|
||||
@@ -108,7 +109,10 @@ export const interceptorRejected = async (
|
||||
if (axios.isAxiosError(value) && value.response) {
|
||||
const { response } = value;
|
||||
|
||||
const isNoAuthMode = getIsNoAuthMode();
|
||||
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
@@ -140,16 +144,20 @@ export const interceptorRejected = async (
|
||||
return await Promise.resolve(reResponse);
|
||||
} catch (error) {
|
||||
if ((error as AxiosError)?.response?.status === 401) {
|
||||
Logout();
|
||||
void Logout();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logout();
|
||||
void Logout();
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401 && response.config.url === '/sessions/rotate') {
|
||||
Logout();
|
||||
if (
|
||||
!isNoAuthMode &&
|
||||
response.status === 401 &&
|
||||
response.config.url === '/sessions/rotate'
|
||||
) {
|
||||
void Logout();
|
||||
}
|
||||
}
|
||||
return await Promise.reject(value);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from 'api/generated/services/users';
|
||||
import { AxiosError } from 'axios';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
|
||||
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
|
||||
@@ -613,39 +614,43 @@ function EditMemberDrawer({
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-delete-member">
|
||||
<Button
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? getInviteButtonLabel(
|
||||
isLoadingTokenStatus,
|
||||
existingToken,
|
||||
isTokenExpired,
|
||||
tokenNotFound,
|
||||
)
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-generate-reset-link">
|
||||
<Button
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? getInviteButtonLabel(
|
||||
isLoadingTokenStatus,
|
||||
existingToken,
|
||||
isTokenExpired,
|
||||
tokenNotFound,
|
||||
)
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -656,15 +661,17 @@ function EditMemberDrawer({
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-save-member">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
useCreateResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetResetPasswordToken,
|
||||
useGetRolesByUserID,
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
} from 'api/generated/services/users';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { managedRoles } from 'mocks-server/__mockdata__/roles';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
import EditMemberDrawer from '../EditMemberDrawer';
|
||||
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useGetUser: jest.fn(),
|
||||
useGetRolesByUserID: jest.fn(),
|
||||
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useGetResetPasswordToken: jest.fn(),
|
||||
useCreateResetPasswordToken: jest.fn(),
|
||||
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
|
||||
`/api/v2/users/${id}/roles`,
|
||||
],
|
||||
}));
|
||||
|
||||
const activeMember = {
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
updatedAt: '1710000000000',
|
||||
};
|
||||
|
||||
function setupMocks(): void {
|
||||
(useGetUser as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
id: 'user-1',
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
status: 'active',
|
||||
userRoles: [
|
||||
{ id: 'ur-1', roleId: managedRoles[0].id, role: managedRoles[0] },
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
(useGetRolesByUserID as jest.Mock).mockReturnValue({
|
||||
data: { data: [managedRoles[0]] },
|
||||
isLoading: false,
|
||||
});
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUpdateUser as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUpdateMyUserV2 as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useDeleteUser as jest.Mock).mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useGetResetPasswordToken as jest.Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
(useCreateResetPasswordToken as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('EditMemberDrawer — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it('renders no-auth guard wrappers for all member mutation buttons', () => {
|
||||
renderWithNoAuth(
|
||||
<EditMemberDrawer
|
||||
member={activeMember}
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
onComplete={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('no-auth-delete-member')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-auth-generate-reset-link')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-auth-save-member')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
@@ -50,6 +52,14 @@ function HeaderRightSection({
|
||||
setOpenAnnouncementsModal(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleOpenAIAssistant = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: 'header',
|
||||
currentPage: normalizePage(location.pathname),
|
||||
});
|
||||
openAIAssistant();
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleOpenShareURLModal = useCallback((): void => {
|
||||
logEvent('Share: Clicked', {
|
||||
page: location.pathname,
|
||||
@@ -101,7 +111,7 @@ function HeaderRightSection({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={openAIAssistant}
|
||||
onClick={handleOpenAIAssistant}
|
||||
aria-label={
|
||||
showHeaderPendingBadge
|
||||
? pendingUserInputCount === 1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.banner {
|
||||
height: var(--spacing-20);
|
||||
|
||||
a {
|
||||
color: var(--callout-warning-title);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--callout-warning-title);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend/src/components/NoAuthBanner/NoAuthBanner.tsx
Normal file
26
frontend/src/components/NoAuthBanner/NoAuthBanner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PersistedAnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
|
||||
import styles from './NoAuthBanner.module.scss';
|
||||
|
||||
export function NoAuthBanner(): JSX.Element {
|
||||
return (
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey="no-auth-banner-v1"
|
||||
testId="no-auth-banner"
|
||||
className={styles.banner}
|
||||
>
|
||||
No-auth mode: authentication is disabled and you are currently signed in as
|
||||
an admin.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/configuration/no-auth-mode/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</PersistedAnnouncementBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export default NoAuthBanner;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { NoAuthBanner } from '../NoAuthBanner';
|
||||
|
||||
describe('NoAuthBanner', () => {
|
||||
it('renders the no-auth message', () => {
|
||||
render(<NoAuthBanner />);
|
||||
expect(
|
||||
screen.getByText(/No-auth mode: authentication is disabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the warning test id', () => {
|
||||
render(<NoAuthBanner />);
|
||||
expect(screen.getByTestId('no-auth-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a docs link that opens in a new tab', () => {
|
||||
render(<NoAuthBanner />);
|
||||
const link = screen.getByRole('link', { name: /learn more/i });
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
});
|
||||
52
frontend/src/components/NoAuthGuard/NoAuthGuard.tsx
Normal file
52
frontend/src/components/NoAuthGuard/NoAuthGuard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
export const DEFAULT_NO_AUTH_MESSAGE = 'Not available in no-auth mode';
|
||||
|
||||
interface NoAuthGuardProps {
|
||||
children: React.ReactElement;
|
||||
message?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function NoAuthGuard({
|
||||
children,
|
||||
message = DEFAULT_NO_AUTH_MESSAGE,
|
||||
disabled,
|
||||
testId,
|
||||
}: NoAuthGuardProps): JSX.Element {
|
||||
const { isNoAuthMode } = useAppContext();
|
||||
|
||||
if (!isNoAuthMode) {
|
||||
return disabled ? React.cloneElement(children, { disabled: true }) : children;
|
||||
}
|
||||
|
||||
const disabledChild = React.cloneElement(children, {
|
||||
disabled: true,
|
||||
style: { ...(children.props.style ?? {}), pointerEvents: 'none' },
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-no-auth-trigger
|
||||
data-testid={testId}
|
||||
style={{ display: 'inline-flex', cursor: 'not-allowed' }}
|
||||
>
|
||||
{disabledChild}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{message}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { NoAuthGuard } from '..';
|
||||
|
||||
describe('NoAuthGuard', () => {
|
||||
it('renders children unchanged when isNoAuthMode is false', () => {
|
||||
const { getByRole } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button">Action</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: false } },
|
||||
);
|
||||
expect(getByRole('button', { name: 'Action' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not intercept onClick when isNoAuthMode is false', () => {
|
||||
const handleClick = jest.fn();
|
||||
const { getByRole } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button" onClick={handleClick}>
|
||||
Action
|
||||
</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: false } },
|
||||
);
|
||||
getByRole('button', { name: 'Action' }).click();
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables children when isNoAuthMode is true', () => {
|
||||
const { getByRole } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button">Action</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: true } },
|
||||
);
|
||||
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders a tooltip trigger wrapper when isNoAuthMode is true', () => {
|
||||
const { container } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button">Action</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: true } },
|
||||
);
|
||||
expect(
|
||||
container.querySelector('span[data-no-auth-trigger]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks onClick when isNoAuthMode is true', () => {
|
||||
const handleClick = jest.fn();
|
||||
const { container } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button" onClick={handleClick}>
|
||||
Action
|
||||
</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: true } },
|
||||
);
|
||||
container
|
||||
.querySelector('span[data-no-auth-trigger]')
|
||||
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overrides existing disabled prop — no-auth always wins', () => {
|
||||
const { getByRole } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button" disabled={false}>
|
||||
Action
|
||||
</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: true } },
|
||||
);
|
||||
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('sets pointerEvents none on child when isNoAuthMode is true', () => {
|
||||
const { getByRole } = render(
|
||||
<NoAuthGuard>
|
||||
<button type="button">Action</button>
|
||||
</NoAuthGuard>,
|
||||
undefined,
|
||||
{ appContextOverrides: { isNoAuthMode: true } },
|
||||
);
|
||||
expect(getByRole('button', { name: 'Action' })).toHaveStyle({
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
1
frontend/src/components/NoAuthGuard/index.ts
Normal file
1
frontend/src/components/NoAuthGuard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DEFAULT_NO_AUTH_MESSAGE, NoAuthGuard } from './NoAuthGuard';
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
@@ -125,17 +126,19 @@ function KeyFormPhase({
|
||||
]}
|
||||
enabled={!!accountId}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-create-key">
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildAPIKeyUpdatePermission,
|
||||
@@ -174,10 +175,12 @@ function EditKeyForm({
|
||||
]}
|
||||
enabled={!!accountId && !!keyItem?.id}
|
||||
>
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-revoke-key">
|
||||
<Button variant="link" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
@@ -188,17 +191,19 @@ function EditKeyForm({
|
||||
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
|
||||
enabled={!!accountId && !!keyItem?.id}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-save-key">
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table } from 'antd';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import { DEFAULT_NO_AUTH_MESSAGE, NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
@@ -33,6 +35,7 @@ interface KeysTabProps {
|
||||
interface BuildColumnsParams {
|
||||
isDisabled: boolean;
|
||||
accountId: string;
|
||||
isNoAuthMode: boolean;
|
||||
onRevokeClick: (keyId: string) => void;
|
||||
handleformatLastObservedAt: (
|
||||
lastObservedAt: Date | null | undefined,
|
||||
@@ -53,6 +56,7 @@ function formatExpiry(expiresAt: number): JSX.Element {
|
||||
function buildColumns({
|
||||
isDisabled,
|
||||
accountId,
|
||||
isNoAuthMode,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
|
||||
@@ -110,28 +114,38 @@ function buildColumns({
|
||||
onClick: (e): void => e.stopPropagation(),
|
||||
style: { cursor: 'default' },
|
||||
}),
|
||||
render: (_, record): JSX.Element => (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
render: (_, record): JSX.Element => {
|
||||
const tooltipTitle = isDisabled
|
||||
? 'Service account disabled'
|
||||
: isNoAuthMode
|
||||
? DEFAULT_NO_AUTH_MESSAGE
|
||||
: 'Revoke Key';
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={[
|
||||
buildAPIKeyDeletePermission(record.id),
|
||||
buildSADetachPermission(accountId),
|
||||
]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
),
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled || isNoAuthMode}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</AuthZTooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -158,6 +172,7 @@ function KeysTab({
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
|
||||
const { isNoAuthMode } = useAppContext();
|
||||
|
||||
const handleformatLastObservedAt = useCallback(
|
||||
(lastObservedAt: Date | null | undefined): string =>
|
||||
@@ -177,10 +192,17 @@ function KeysTab({
|
||||
buildColumns({
|
||||
isDisabled,
|
||||
accountId,
|
||||
isNoAuthMode,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}),
|
||||
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
|
||||
[
|
||||
isDisabled,
|
||||
accountId,
|
||||
isNoAuthMode,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -210,16 +232,18 @@ function KeysTab({
|
||||
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
|
||||
enabled={!isDisabled && !!accountId}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-add-first-key">
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSADetachPermission,
|
||||
@@ -52,15 +53,17 @@ export function RevokeKeyFooter({
|
||||
]}
|
||||
enabled={!!accountId && !!keyId}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-confirm-revoke">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
@@ -436,18 +437,20 @@ function ServiceAccountDrawer({
|
||||
]}
|
||||
enabled={!isDeleted && !!selectedAccountId}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-add-key">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDeleted}
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -550,16 +553,18 @@ function ServiceAccountDrawer({
|
||||
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
|
||||
enabled={!!selectedAccountId}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-delete-service-account">
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
void setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
@@ -568,15 +573,17 @@ function ServiceAccountDrawer({
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-save-service-account">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
jest.mock('@signozhq/ui/drawer', () => ({
|
||||
...jest.requireActual('@signozhq/ui/drawer'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
function renderDrawer(
|
||||
searchParams: Record<string, string> = { account: 'sa-1' },
|
||||
): ReturnType<typeof renderWithNoAuth> {
|
||||
return renderWithNoAuth(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<ServiceAccountDrawer onSuccess={jest.fn()} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
function setupBaseHandlers(): void {
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
|
||||
),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: listRolesSuccessResponse.data.filter(
|
||||
(r) => r.name === 'signoz-admin',
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountDrawer — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setupBaseHandlers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no-auth guards in the Overview tab footer', async () => {
|
||||
renderDrawer();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('no-auth-delete-service-account'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('no-auth-save-service-account'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders no-auth guard on Add Key button in Keys tab header', async () => {
|
||||
renderDrawer({ account: 'sa-1', tab: 'keys' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-auth-add-key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render no-auth guards when drawer is closed', () => {
|
||||
renderDrawer({});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('no-auth-delete-service-account'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('no-auth-save-service-account'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import HistorySidebar from '../components/ConversationsList';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { AIAssistantEvents } from '../events';
|
||||
import {
|
||||
normalizePage,
|
||||
useAIAssistantAnalyticsContext,
|
||||
} from '../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { VariantContext } from '../VariantContext';
|
||||
|
||||
@@ -24,6 +31,7 @@ import styles from './AIAssistantModal.module.scss';
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function AIAssistantModal(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
@@ -36,6 +44,7 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const analyticsCtx = useAIAssistantAnalyticsContext();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
@@ -55,6 +64,10 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
} else {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: 'shortcut',
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openModal();
|
||||
}
|
||||
return;
|
||||
@@ -68,7 +81,7 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, openModal, closeModal, startNewConversation]);
|
||||
}, [isOpen, openModal, closeModal, startNewConversation, pathname]);
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,15 +90,28 @@ export default function AIAssistantModal(): JSX.Element | null {
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
|
||||
// the assistant was already open in the modal, so this is a surface
|
||||
// switch, not a new open.
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
{ fromInApp: true },
|
||||
);
|
||||
}, [activeConversationId, closeModal, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
void logEvent(AIAssistantEvents.NewChatClicked, {
|
||||
...analyticsCtx,
|
||||
// useAIAssistantAnalyticsContext() runs above this component's
|
||||
// VariantContext.Provider, so the hook reports the default 'page'
|
||||
// mode. Override here: the modal collapses to 'sidepane' in our
|
||||
// taxonomy alongside the drawer.
|
||||
mode: 'sidepane',
|
||||
source: 'header',
|
||||
});
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
}, [startNewConversation, analyticsCtx]);
|
||||
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
|
||||
@@ -5,8 +5,12 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import ConversationsList from '../components/ConversationsList';
|
||||
import ConversationView from '../ConversationView';
|
||||
import { AIAssistantEvents } from '../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { VariantContext } from '../VariantContext';
|
||||
|
||||
@@ -32,21 +36,35 @@ export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const analyticsCtx = useAIAssistantAnalyticsContext();
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
|
||||
// the assistant was already open in the drawer, so this is a surface
|
||||
// switch, not a new open.
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
{ fromInApp: true },
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
void logEvent(AIAssistantEvents.NewChatClicked, {
|
||||
...analyticsCtx,
|
||||
// useAIAssistantAnalyticsContext() runs above this component's
|
||||
// VariantContext.Provider, so the hook reports the default 'page'
|
||||
// mode. Override here: this handler only runs when the drawer
|
||||
// itself is mounted, which is unambiguously the sidepane surface.
|
||||
mode: 'sidepane',
|
||||
source: 'header',
|
||||
});
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
}, [startNewConversation, analyticsCtx]);
|
||||
|
||||
// When user picks a conversation from the list, close the sidebar
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Bot } from '@signozhq/icons';
|
||||
|
||||
import { AIAssistantEvents } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
@@ -25,6 +29,14 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const handleOpen = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: 'icon',
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
openAIAssistant();
|
||||
}, [pathname]);
|
||||
|
||||
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
@@ -35,7 +47,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={styles.trigger}
|
||||
onClick={openAIAssistant}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Bot size={20} />
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import cx from 'classnames';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import ChatInput, { autoContextKey } from '../components/ChatInput';
|
||||
import ConversationSkeleton from '../components/ConversationSkeleton';
|
||||
import VirtualizedMessages from '../components/VirtualizedMessages';
|
||||
import { AIAssistantEvents } from '../events';
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { MessageAttachment } from '../types';
|
||||
import { MessageContext } from '../../../api/ai-assistant/chat';
|
||||
@@ -39,6 +43,7 @@ export default function ConversationView({
|
||||
);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
|
||||
const analyticsCtx = useAIAssistantAnalyticsContext(conversationId);
|
||||
|
||||
// Auto-derived contexts come from the route the user is currently looking
|
||||
// at (dashboard detail, service metrics, an explorer, …). Skip when the
|
||||
@@ -82,14 +87,50 @@ export default function ConversationView({
|
||||
attachments?: MessageAttachment[],
|
||||
contexts?: MessageContext[],
|
||||
) => {
|
||||
const hasAuto = contexts?.some((c) => c.source === 'auto') ?? false;
|
||||
const hasManual = contexts?.some((c) => c.source === 'mention') ?? false;
|
||||
let contextType: 'manual' | 'auto' | 'both' | undefined;
|
||||
if (hasAuto && hasManual) {
|
||||
contextType = 'both';
|
||||
} else if (hasAuto) {
|
||||
contextType = 'auto';
|
||||
} else if (hasManual) {
|
||||
contextType = 'manual';
|
||||
}
|
||||
void logEvent(AIAssistantEvents.MessageSent, {
|
||||
...analyticsCtx,
|
||||
queryLength: text.length,
|
||||
hasContext: hasAuto || hasManual,
|
||||
contextType,
|
||||
respondingToClarification: Boolean(pendingClarificationHere),
|
||||
});
|
||||
void sendMessage(text, attachments, contexts);
|
||||
},
|
||||
[sendMessage],
|
||||
[sendMessage, analyticsCtx, pendingClarificationHere],
|
||||
);
|
||||
|
||||
// Wall-clock timestamp of the current streaming start, used to compute
|
||||
// `secondsSinceStart` on Cancel clicked. Cleared whenever streaming ends.
|
||||
const streamStartedAtRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isStreamingHere) {
|
||||
streamStartedAtRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (streamStartedAtRef.current === null) {
|
||||
streamStartedAtRef.current = Date.now();
|
||||
}
|
||||
}, [isStreamingHere]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
const startedAt = streamStartedAtRef.current;
|
||||
void logEvent(AIAssistantEvents.CancelClicked, {
|
||||
threadId: analyticsCtx.threadId,
|
||||
secondsSinceStart:
|
||||
startedAt !== null ? Math.round((Date.now() - startedAt) / 1000) : null,
|
||||
});
|
||||
cancelStream(conversationId);
|
||||
}, [cancelStream, conversationId]);
|
||||
}, [cancelStream, conversationId, analyticsCtx.threadId]);
|
||||
|
||||
const messages = conversation?.messages ?? [];
|
||||
const showDisclaimer = messages.length > 0;
|
||||
@@ -134,6 +175,7 @@ export default function ConversationView({
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
onSendSuggestedPrompt={(text): void => handleSend(text)}
|
||||
/>
|
||||
{showDisclaimer && (
|
||||
<div className={disclaimerClass} role="note" aria-live="polite">
|
||||
|
||||
@@ -41,12 +41,68 @@ import {
|
||||
Undo,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
interface ActionsSectionProps {
|
||||
actions: MessageActionDTO[];
|
||||
/** ID of the assistant message these actions belong to — used in analytics. */
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-type strings the backend uses for `open_resource` and rollback
|
||||
* actions. Centralized here so the route/module lookups below stay in sync.
|
||||
*/
|
||||
const ResourceType = {
|
||||
dashboard: 'dashboard',
|
||||
alert: 'alert',
|
||||
service: 'service',
|
||||
saved_view: 'saved_view',
|
||||
logs_explorer: 'logs_explorer',
|
||||
traces_explorer: 'traces_explorer',
|
||||
metrics_explorer: 'metrics_explorer',
|
||||
} as const;
|
||||
|
||||
/** Maps an open_resource action's resourceType to its product module name. */
|
||||
function targetModuleForResource(resourceType: string): string | null {
|
||||
switch (resourceType) {
|
||||
case ResourceType.dashboard:
|
||||
return 'dashboards';
|
||||
case ResourceType.alert:
|
||||
return 'alerts';
|
||||
case ResourceType.service:
|
||||
return 'apm';
|
||||
case ResourceType.saved_view:
|
||||
return 'savedViews';
|
||||
case ResourceType.logs_explorer:
|
||||
return 'logs';
|
||||
case ResourceType.traces_explorer:
|
||||
return 'traces';
|
||||
case ResourceType.metrics_explorer:
|
||||
return 'metrics';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps an apply_filter signal to its product module name. */
|
||||
function targetModuleForSignal(signal: ApplyFilterSignalDTO): string | null {
|
||||
switch (signal) {
|
||||
case ApplyFilterSignalDTO.logs:
|
||||
return 'logs';
|
||||
case ApplyFilterSignalDTO.traces:
|
||||
return 'traces';
|
||||
case ApplyFilterSignalDTO.metrics:
|
||||
return 'metrics';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type ChipState = 'idle' | 'loading' | 'success' | 'error';
|
||||
@@ -94,23 +150,23 @@ function resourceRoute(
|
||||
resourceId: string,
|
||||
): string | null {
|
||||
switch (resourceType) {
|
||||
case 'dashboard':
|
||||
case ResourceType.dashboard:
|
||||
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
|
||||
case 'alert': {
|
||||
case ResourceType.alert: {
|
||||
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
|
||||
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
|
||||
}
|
||||
case 'service':
|
||||
case ResourceType.service:
|
||||
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
|
||||
case 'saved_view':
|
||||
case ResourceType.saved_view:
|
||||
// No detail route — saved views land on the list page.
|
||||
// Caller may provide signal-aware metadata in future; default to logs.
|
||||
return ROUTES.LOGS_SAVE_VIEWS;
|
||||
case 'logs_explorer':
|
||||
case ResourceType.logs_explorer:
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'traces_explorer':
|
||||
case ResourceType.traces_explorer:
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case 'metrics_explorer':
|
||||
case ResourceType.metrics_explorer:
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
@@ -224,6 +280,24 @@ function actionKey(action: MessageActionDTO, index: number): string {
|
||||
: `${action.kind}:${action.label}:${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the prompt to send for a follow_up action. The chip's `label` is
|
||||
* the short display text (e.g. "Python setup"); the real prompt lives in
|
||||
* `input.intent` per the schema doc. Falls back to label defensively so a
|
||||
* malformed server payload doesn't drop the click silently. Both branches
|
||||
* are trimmed so whitespace-only payloads don't become whitespace messages.
|
||||
*/
|
||||
function followUpIntent(action: MessageActionDTO): string {
|
||||
const intent = action.input?.intent;
|
||||
if (typeof intent === 'string') {
|
||||
const trimmed = intent.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return action.label.trim();
|
||||
}
|
||||
|
||||
/** Maps a signal to its target explorer route. */
|
||||
function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
|
||||
switch (signal) {
|
||||
@@ -353,10 +427,12 @@ function rollbackCall(
|
||||
*/
|
||||
export default function ActionsSection({
|
||||
actions,
|
||||
messageId,
|
||||
}: ActionsSectionProps): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
// Per-chip click state, keyed by chip key (see `key` below). Persists
|
||||
@@ -430,13 +506,39 @@ export default function ActionsSection({
|
||||
switch (action.kind) {
|
||||
case MessageActionKindDTO.open_docs: {
|
||||
if (action.url) {
|
||||
void logEvent(AIAssistantEvents.DocOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
docPath: action.url,
|
||||
});
|
||||
openInNewTab(action.url);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.follow_up: {
|
||||
if (action.label) {
|
||||
void sendMessage(action.label);
|
||||
const intent = followUpIntent(action);
|
||||
if (intent) {
|
||||
// Fire SuggestedPromptClicked + MessageSent so analytics can compute
|
||||
// both the click-through rate against follow-ups offered *and* keep
|
||||
// the unified send funnel intact. `category` distinguishes server-
|
||||
// emitted follow-ups from the empty-state grid. `promptId` stays the
|
||||
// label so dashboards group identical chip texts together regardless
|
||||
// of the dynamic intent payload.
|
||||
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
|
||||
threadId,
|
||||
messageId,
|
||||
promptId: action.label,
|
||||
category: SuggestedPromptCategory.FollowUp,
|
||||
});
|
||||
void logEvent(AIAssistantEvents.MessageSent, {
|
||||
threadId,
|
||||
page,
|
||||
mode,
|
||||
queryLength: intent.length,
|
||||
hasContext: false,
|
||||
respondingToClarification: false,
|
||||
});
|
||||
void sendMessage(intent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -444,6 +546,12 @@ export default function ActionsSection({
|
||||
if (action.resourceType && action.resourceId) {
|
||||
const path = resourceRoute(action.resourceType, action.resourceId);
|
||||
if (path) {
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(action.resourceType),
|
||||
resourceId: action.resourceId,
|
||||
});
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
@@ -456,6 +564,13 @@ export default function ActionsSection({
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.apply_filter: {
|
||||
if (action.signal) {
|
||||
void logEvent(AIAssistantEvents.ApplyFilterClicked, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForSignal(action.signal),
|
||||
});
|
||||
}
|
||||
applyFilter(action, {
|
||||
history,
|
||||
pathname,
|
||||
|
||||
@@ -5,13 +5,17 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { UploadFile } from 'antd';
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -22,6 +26,8 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { AIAssistantEvents, getBrowserInfo } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../../types';
|
||||
import { MessageContext } from '../../../../api/ai-assistant/chat';
|
||||
@@ -137,6 +143,8 @@ function autoContextCategory(ctx: MessageContext): string {
|
||||
const MAX_INPUT_LENGTH = 20000;
|
||||
const WARNING_THRESHOLD = 15000;
|
||||
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
/** sessionStorage key for the "voice input failed this tab" flag. */
|
||||
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
|
||||
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
|
||||
|
||||
@@ -368,6 +376,28 @@ export default function ChatInput({
|
||||
|
||||
// ── Voice input ────────────────────────────────────────────────────────────
|
||||
|
||||
const analyticsCtx = useAIAssistantAnalyticsContext();
|
||||
// Captured at the start of a voice session, consumed when it ends.
|
||||
// Tracks both the trigger (button vs. PTT shortcut) and the wall-clock
|
||||
// start time so we can attribute `durationMs` on the Voice input used
|
||||
// event regardless of which control ended the session.
|
||||
const voiceStartedAtRef = useRef<number | null>(null);
|
||||
const voiceSourceRef = useRef<'button' | 'shortcut' | null>(null);
|
||||
// Set to true after a `network`, `not-allowed`, or `not-supported` failure
|
||||
// so we hide the mic button for the rest of the tab session — silent
|
||||
// retries don't help, and Chromium derivatives without the Google Speech
|
||||
// API key always fail with `network` no matter how many times the user
|
||||
// clicks. Persisted to sessionStorage so a page reload doesn't surface the
|
||||
// button again (closing the tab still resets, in case the user fixed
|
||||
// permissions or switched browsers).
|
||||
const [voiceUnavailable, setVoiceUnavailable] = useState(
|
||||
() => getSessionStorage(VOICE_UNAVAILABLE_KEY) === 'true',
|
||||
);
|
||||
const markVoiceUnavailable = useCallback((): void => {
|
||||
setVoiceUnavailable(true);
|
||||
setSessionStorage(VOICE_UNAVAILABLE_KEY, 'true');
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isListening,
|
||||
isSupported,
|
||||
@@ -388,9 +418,81 @@ export default function ChatInput({
|
||||
setText(capText(committedTextRef.current + separator + transcriptText));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Guard against double-fire: Chrome can fire `onerror` more than
|
||||
// once per session when `continuous = true` (it retries internally
|
||||
// before giving up). Only fire the analytics event for the first
|
||||
// error in a given session — voiceSourceRef being null means we've
|
||||
// already handled it.
|
||||
const source = voiceSourceRef.current;
|
||||
if (source === null) {
|
||||
return;
|
||||
}
|
||||
voiceStartedAtRef.current = null;
|
||||
voiceSourceRef.current = null;
|
||||
void logEvent(AIAssistantEvents.VoiceInputFailed, {
|
||||
...analyticsCtx,
|
||||
...getBrowserInfo(),
|
||||
source,
|
||||
errorType: error,
|
||||
});
|
||||
if (error === 'network') {
|
||||
markVoiceUnavailable();
|
||||
toast.error('Voice input unavailable in this browser', {
|
||||
description:
|
||||
'This browser cannot reach the speech recognition service. Try Google Chrome or Microsoft Edge.',
|
||||
});
|
||||
} else if (error === 'not-allowed') {
|
||||
markVoiceUnavailable();
|
||||
toast.error('Microphone access denied', {
|
||||
description:
|
||||
'Grant microphone permission in your browser settings to use voice input.',
|
||||
});
|
||||
} else if (error === 'not-supported') {
|
||||
markVoiceUnavailable();
|
||||
toast.error('Voice input is not supported in this browser.');
|
||||
}
|
||||
// `no-speech` is benign (just silence) — don't toast or hide.
|
||||
},
|
||||
});
|
||||
|
||||
const showMic = isSupported && micPermission !== 'denied';
|
||||
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
|
||||
|
||||
const startVoiceInput = useCallback(
|
||||
(source: 'button' | 'shortcut') => {
|
||||
// Defense in depth: the button is hidden when `voiceUnavailable` is
|
||||
// true, but the PTT shortcut listener can still call us. Bailing here
|
||||
// keeps a single source of truth and prevents repeat `Voice input
|
||||
// failed` events in the same session.
|
||||
if (voiceUnavailable) {
|
||||
return;
|
||||
}
|
||||
voiceStartedAtRef.current = Date.now();
|
||||
voiceSourceRef.current = source;
|
||||
start();
|
||||
},
|
||||
[start, voiceUnavailable],
|
||||
);
|
||||
|
||||
const fireVoiceInputEvent = useCallback(
|
||||
(outcome: 'sent' | 'discarded') => {
|
||||
const startedAt = voiceStartedAtRef.current;
|
||||
const source = voiceSourceRef.current;
|
||||
voiceStartedAtRef.current = null;
|
||||
voiceSourceRef.current = null;
|
||||
if (startedAt === null || source === null) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.VoiceInputUsed, {
|
||||
...analyticsCtx,
|
||||
...getBrowserInfo(),
|
||||
source,
|
||||
outcome,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
},
|
||||
[analyticsCtx],
|
||||
);
|
||||
|
||||
// Stop recording and immediately send whatever is in the textarea.
|
||||
const handleStopAndSend = useCallback(async () => {
|
||||
@@ -398,15 +500,17 @@ export default function ChatInput({
|
||||
committedTextRef.current = capText(text);
|
||||
// Stop recognition without triggering onTranscript again (would double-append).
|
||||
discard();
|
||||
fireVoiceInputEvent('sent');
|
||||
await handleSend();
|
||||
}, [text, discard, handleSend, capText]);
|
||||
}, [text, discard, handleSend, capText, fireVoiceInputEvent]);
|
||||
|
||||
// Stop recording and revert the textarea to what it was before voice started.
|
||||
const handleDiscard = useCallback(() => {
|
||||
discard();
|
||||
fireVoiceInputEvent('discarded');
|
||||
setText(committedTextRef.current);
|
||||
textareaRef.current?.focus();
|
||||
}, [discard]);
|
||||
}, [discard, fireVoiceInputEvent]);
|
||||
|
||||
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
|
||||
// Hold the combo to record; release Space to submit. We track which key
|
||||
@@ -415,7 +519,7 @@ export default function ChatInput({
|
||||
// "session active" ref so a held key only calls `start()` once.
|
||||
const pttActiveRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isSupported || micPermission === 'denied') {
|
||||
if (!isSupported || micPermission === 'denied' || voiceUnavailable) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -432,7 +536,7 @@ export default function ChatInput({
|
||||
return; // ignore auto-repeat
|
||||
}
|
||||
pttActiveRef.current = true;
|
||||
start();
|
||||
startVoiceInput('shortcut');
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent): void => {
|
||||
@@ -466,9 +570,10 @@ export default function ChatInput({
|
||||
}, [
|
||||
isSupported,
|
||||
micPermission,
|
||||
voiceUnavailable,
|
||||
disabled,
|
||||
isStreaming,
|
||||
start,
|
||||
startVoiceInput,
|
||||
handleStopAndSend,
|
||||
]);
|
||||
|
||||
@@ -903,7 +1008,7 @@ export default function ChatInput({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={start}
|
||||
onClick={(): void => startVoiceInput('button')}
|
||||
disabled={disabled}
|
||||
aria-label="Start voice input"
|
||||
className={styles.micBtn}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import type {
|
||||
ClarificationEventDTO,
|
||||
@@ -16,6 +17,8 @@ import type {
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { CircleHelp, Send, X } from '@signozhq/icons';
|
||||
|
||||
import { AIAssistantEvents } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import styles from './ClarificationForm.module.scss';
|
||||
@@ -44,6 +47,8 @@ export default function ClarificationForm({
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
const { threadId, page, mode } =
|
||||
useAIAssistantAnalyticsContext(conversationId);
|
||||
|
||||
const fields = clarification.fields ?? [];
|
||||
const initialAnswers = Object.fromEntries(
|
||||
@@ -60,6 +65,18 @@ export default function ClarificationForm({
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setSubmitted(true);
|
||||
// Approximate queryLength as the JSON encoding of the form answers — the
|
||||
// clarification API doesn't render a single user-visible string, but the
|
||||
// JSON size is a reasonable stand-in for "how much did the user provide".
|
||||
const queryLength = JSON.stringify(answers).length;
|
||||
void logEvent(AIAssistantEvents.MessageSent, {
|
||||
threadId,
|
||||
page,
|
||||
mode,
|
||||
queryLength,
|
||||
hasContext: false,
|
||||
respondingToClarification: true,
|
||||
});
|
||||
await submitClarification(
|
||||
conversationId,
|
||||
clarification.clarificationId,
|
||||
@@ -69,6 +86,10 @@ export default function ClarificationForm({
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setCancelled(true);
|
||||
void logEvent(AIAssistantEvents.CancelClicked, {
|
||||
threadId,
|
||||
secondsSinceStart: null,
|
||||
});
|
||||
cancelStream(conversationId);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Plus, Search } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents } from '../../events';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { Conversation } from '../../types';
|
||||
import { useVariant } from '../../VariantContext';
|
||||
@@ -136,6 +139,17 @@ export default function ConversationsList({
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
const conv = conversations[id];
|
||||
// Skip re-selecting the currently active thread — Notion-style click on
|
||||
// the highlighted row in the history list shouldn't inflate the funnel.
|
||||
const isReselectingActive = id === activeConversationId;
|
||||
if (conv?.threadId && !isReselectingActive) {
|
||||
void logEvent(AIAssistantEvents.ThreadOpenedFromHistory, {
|
||||
threadId: conv.threadId,
|
||||
threadAgeDays: Math.floor(
|
||||
(Date.now() - conv.createdAt) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (conv?.threadId) {
|
||||
// Always load from backend — refreshes messages and reconnects
|
||||
// to active execution if the thread is still busy.
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function MessageBubble({
|
||||
)}
|
||||
|
||||
{!isUser && message.actions && message.actions.length > 0 && (
|
||||
<ActionsSection actions={message.actions} />
|
||||
<ActionsSection actions={message.actions} messageId={message.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,10 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { FeedbackRating, Message } from '../../types';
|
||||
|
||||
@@ -54,6 +58,7 @@ export default function MessageFeedback({
|
||||
const submitMessageFeedback = useAIAssistantStore(
|
||||
(s) => s.submitMessageFeedback,
|
||||
);
|
||||
const { threadId } = useAIAssistantAnalyticsContext();
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -91,10 +96,21 @@ export default function MessageFeedback({
|
||||
}, [message.createdAt]);
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.MessageCopied, {
|
||||
role: message.role,
|
||||
messageId: message.id,
|
||||
hadToolCalls: Boolean(message.blocks?.some((b) => b.type === 'tool_call')),
|
||||
});
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [copyToClipboard, message.content]);
|
||||
}, [
|
||||
copyToClipboard,
|
||||
message.content,
|
||||
message.id,
|
||||
message.role,
|
||||
message.blocks,
|
||||
]);
|
||||
|
||||
const handleVote = useCallback(
|
||||
(rating: FeedbackRating): void => {
|
||||
@@ -107,20 +123,31 @@ export default function MessageFeedback({
|
||||
return;
|
||||
}
|
||||
setVote(rating);
|
||||
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
|
||||
messageId: message.id,
|
||||
threadId,
|
||||
rating: 'up',
|
||||
hasComment: false,
|
||||
commentLength: 0,
|
||||
});
|
||||
submitMessageFeedback(message.id, rating);
|
||||
},
|
||||
[vote, message.id, submitMessageFeedback],
|
||||
[vote, message.id, submitMessageFeedback, threadId],
|
||||
);
|
||||
|
||||
const handleSubmitNegative = useCallback((): void => {
|
||||
setVote('negative');
|
||||
setIsNegativeDialogOpen(false);
|
||||
submitMessageFeedback(
|
||||
message.id,
|
||||
'negative',
|
||||
negativeComment.trim() || undefined,
|
||||
);
|
||||
}, [message.id, negativeComment, submitMessageFeedback]);
|
||||
const trimmed = negativeComment.trim();
|
||||
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
|
||||
messageId: message.id,
|
||||
threadId,
|
||||
rating: 'down',
|
||||
hasComment: trimmed.length > 0,
|
||||
commentLength: trimmed.length,
|
||||
});
|
||||
submitMessageFeedback(message.id, 'negative', trimmed || undefined);
|
||||
}, [message.id, negativeComment, submitMessageFeedback, threadId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents } from '../../events';
|
||||
import { Message } from '../../types';
|
||||
|
||||
import styles from './UserMessageActions.module.scss';
|
||||
@@ -25,10 +28,15 @@ export default function UserMessageActions({
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
void logEvent(AIAssistantEvents.MessageCopied, {
|
||||
role: message.role,
|
||||
messageId: message.id,
|
||||
hadToolCalls: false,
|
||||
});
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [copyToClipboard, message.content]);
|
||||
}, [copyToClipboard, message.content, message.id, message.role]);
|
||||
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
Sparkles,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
|
||||
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { Message, StreamingEventItem } from '../../types';
|
||||
import MessageBubble from '../MessageBubble';
|
||||
@@ -46,17 +50,24 @@ interface VirtualizedMessagesProps {
|
||||
conversationId: string;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
/**
|
||||
* Called when a user clicks an empty-state suggested prompt. Routed
|
||||
* through the parent so analytics (Message sent) fire with the same
|
||||
* page/mode/context attribution as a normal send.
|
||||
*/
|
||||
onSendSuggestedPrompt: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function VirtualizedMessages({
|
||||
conversationId,
|
||||
messages,
|
||||
isStreaming,
|
||||
onSendSuggestedPrompt,
|
||||
}: VirtualizedMessagesProps): JSX.Element {
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const regenerateAssistantMessage = useAIAssistantStore(
|
||||
(s) => s.regenerateAssistantMessage,
|
||||
);
|
||||
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
);
|
||||
@@ -85,9 +96,13 @@ export default function VirtualizedMessages({
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.RegenerateClicked, {
|
||||
messageId,
|
||||
threadId,
|
||||
});
|
||||
void regenerateAssistantMessage(conversationId, messageId);
|
||||
},
|
||||
[conversationId, isStreaming, regenerateAssistantMessage],
|
||||
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
|
||||
);
|
||||
|
||||
// Scroll all the way to the actual bottom — including the 64px of bottom
|
||||
@@ -146,7 +161,11 @@ export default function VirtualizedMessages({
|
||||
color="secondary"
|
||||
className={styles.emptyChip}
|
||||
onClick={(): void => {
|
||||
sendMessage(s.text);
|
||||
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
|
||||
promptId: s.text,
|
||||
category: SuggestedPromptCategory.EmptyState,
|
||||
});
|
||||
onSendSuggestedPrompt(s.text);
|
||||
}}
|
||||
prefix={<s.icon size={14} />}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import { AIAssistantEvents } from '../../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../../MessageContext';
|
||||
|
||||
@@ -37,6 +40,7 @@ export default function ConfirmBlock({
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
|
||||
// Durable answered state — survives re-renders/remounts
|
||||
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
|
||||
@@ -47,6 +51,14 @@ export default function ConfirmBlock({
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, choice);
|
||||
}
|
||||
void logEvent(AIAssistantEvents.MessageSent, {
|
||||
threadId,
|
||||
page,
|
||||
mode,
|
||||
queryLength: responseText.length,
|
||||
hasContext: false,
|
||||
respondingToClarification: false,
|
||||
});
|
||||
sendMessage(responseText);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Checkbox, Radio } from 'antd';
|
||||
|
||||
import { AIAssistantEvents } from '../../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../../MessageContext';
|
||||
|
||||
@@ -36,6 +39,7 @@ export default function InteractiveQuestion({
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
|
||||
// Persist selected state locally only for the pending (not-yet-submitted) case
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
@@ -52,6 +56,14 @@ export default function InteractiveQuestion({
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, answer);
|
||||
}
|
||||
void logEvent(AIAssistantEvents.MessageSent, {
|
||||
threadId,
|
||||
page,
|
||||
mode,
|
||||
queryLength: answer.length,
|
||||
hasContext: false,
|
||||
respondingToClarification: false,
|
||||
});
|
||||
sendMessage(answer);
|
||||
};
|
||||
|
||||
|
||||
81
frontend/src/container/AIAssistant/events.ts
Normal file
81
frontend/src/container/AIAssistant/events.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Analytics event names for the AI Assistant feature. Backend-emitted events
|
||||
* (Execution finished, Approval resolved, Resource mutated, Clarification
|
||||
* requested, Limit hit) are not declared here — they fire from the AI service.
|
||||
*/
|
||||
|
||||
export interface BrowserInfo {
|
||||
browserName: string;
|
||||
browserVersion: string;
|
||||
}
|
||||
|
||||
type NavigatorWithBrandHints = Navigator & {
|
||||
userAgentData?: { brands: { brand: string; version: string }[] };
|
||||
brave?: { isBrave: () => Promise<boolean> };
|
||||
};
|
||||
|
||||
/**
|
||||
* We mainly need to distinguish Chrome / Edge (Speech API works) from Chromium
|
||||
* derivatives (no Google API key → voice fails with `network`). UA sniffing is
|
||||
* the source of truth for derivative identification; `userAgentData` is used
|
||||
* only as a fast happy path for Chrome / Edge. Brave needs its own probe — it
|
||||
* advertises Chrome in both UA and brand hints.
|
||||
*/
|
||||
export function getBrowserInfo(): BrowserInfo {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return { browserName: 'unknown', browserVersion: 'unknown' };
|
||||
}
|
||||
const nav = navigator as NavigatorWithBrandHints;
|
||||
const ua = nav.userAgent;
|
||||
|
||||
// Order matters: derivatives put "Chrome" in their UA; Chrome puts "Safari".
|
||||
const matchers: { name: string; re: RegExp }[] = [
|
||||
{ name: 'Edge', re: /Edg(?:e|A|iOS)?\/([\d.]+)/ },
|
||||
{ name: 'Opera', re: /OPR\/([\d.]+)/ },
|
||||
{ name: 'Vivaldi', re: /Vivaldi\/([\d.]+)/ },
|
||||
{ name: 'Chrome', re: /Chrome\/([\d.]+)/ },
|
||||
{ name: 'Firefox', re: /Firefox\/([\d.]+)/ },
|
||||
{ name: 'Safari', re: /Version\/([\d.]+).*Safari/ },
|
||||
];
|
||||
let browserName = 'unknown';
|
||||
let browserVersion = 'unknown';
|
||||
for (const { name, re } of matchers) {
|
||||
const m = ua.match(re);
|
||||
if (m) {
|
||||
browserName = name;
|
||||
browserVersion = m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Brave hides as Chrome in UA + brand hints; its probe is the only tell.
|
||||
if (nav.brave?.isBrave) {
|
||||
browserName = 'Brave';
|
||||
}
|
||||
|
||||
return { browserName, browserVersion };
|
||||
}
|
||||
|
||||
export const SuggestedPromptCategory = {
|
||||
FollowUp: 'follow_up',
|
||||
EmptyState: 'empty_state',
|
||||
} as const;
|
||||
export type SuggestedPromptCategory =
|
||||
(typeof SuggestedPromptCategory)[keyof typeof SuggestedPromptCategory];
|
||||
|
||||
export enum AIAssistantEvents {
|
||||
Opened = 'AI Assistant: Opened',
|
||||
MessageSent = 'AI Assistant: Message sent',
|
||||
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
|
||||
CancelClicked = 'AI Assistant: Cancel clicked',
|
||||
RegenerateClicked = 'AI Assistant: Regenerate clicked',
|
||||
MessageCopied = 'AI Assistant: Message copied',
|
||||
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
|
||||
ResourceOpened = 'AI Assistant: Resource opened',
|
||||
DocOpened = 'AI Assistant: Doc opened',
|
||||
ApplyFilterClicked = 'AI Assistant: Apply filter clicked',
|
||||
ThreadOpenedFromHistory = 'AI Assistant: Thread opened from history',
|
||||
VoiceInputUsed = 'AI Assistant: Voice input used',
|
||||
VoiceInputFailed = 'AI Assistant: Voice input failed',
|
||||
NewChatClicked = 'AI Assistant: New chat clicked',
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { useVariant } from '../VariantContext';
|
||||
|
||||
export interface AIAssistantAnalyticsContext {
|
||||
/** Backend thread ID for the resolved conversation; undefined before the first send. */
|
||||
threadId: string | undefined;
|
||||
/**
|
||||
* Normalised route template for the current page (e.g. `/dashboard/:dashboardId`).
|
||||
* Falls back to the raw pathname for routes not in ROUTES. We normalise to keep
|
||||
* analytics cardinality bounded and avoid leaking customer identifiers
|
||||
* (dashboard IDs, service names, trace IDs, conversation IDs) into the event.
|
||||
*/
|
||||
page: string;
|
||||
/** Surface the assistant is rendered on. `panel` / `modal` collapse to `sidepane`. */
|
||||
mode: 'sidepane' | 'full_screen';
|
||||
}
|
||||
|
||||
// Pre-sorted longest-first so more specific templates match before their
|
||||
// less specific siblings (e.g. `/services/:s/top-level-operations` wins
|
||||
// over `/services/:s`). Module-level — ROUTES is static.
|
||||
const ROUTE_TEMPLATES = Object.values(ROUTES).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
|
||||
export function normalizePage(pathname: string): string {
|
||||
for (const template of ROUTE_TEMPLATES) {
|
||||
if (matchPath(pathname, { path: template, exact: true })) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared base attributes for AI Assistant analytics events (Message sent,
|
||||
* Cancel clicked, Feedback submitted, Resource/Doc/Apply filter, …).
|
||||
*
|
||||
* Pass `conversationId` when the caller is scoped to a specific
|
||||
* conversation (e.g. `ClarificationForm`, `VirtualizedMessages`); omit
|
||||
* to fall back to the store's active conversation.
|
||||
*/
|
||||
export function useAIAssistantAnalyticsContext(
|
||||
conversationId?: string,
|
||||
): AIAssistantAnalyticsContext {
|
||||
const { pathname } = useLocation();
|
||||
const variant = useVariant();
|
||||
const threadId = useAIAssistantStore((s) => {
|
||||
const id = conversationId ?? s.activeConversationId;
|
||||
return id ? s.conversations[id]?.threadId : undefined;
|
||||
});
|
||||
return {
|
||||
threadId,
|
||||
page: normalizePage(pathname),
|
||||
mode: variant === 'page' ? 'full_screen' : 'sidepane',
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import NoAuthBanner from 'components/NoAuthBanner/NoAuthBanner';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -62,7 +63,7 @@ const homeInterval = 30 * 60 * 1000;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function Home(): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { user, isNoAuthMode } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -196,7 +197,7 @@ export default function Home(): JSX.Element {
|
||||
const { mutate: updateUserPreference } = useMutation(updateUserPreferenceAPI, {
|
||||
onSuccess: () => {
|
||||
setUpdatingUserPreferences(false);
|
||||
refetchUserPreferences();
|
||||
void refetchUserPreferences();
|
||||
},
|
||||
onError: () => {
|
||||
setUpdatingUserPreferences(false);
|
||||
@@ -204,7 +205,7 @@ export default function Home(): JSX.Element {
|
||||
});
|
||||
|
||||
const handleWillDoThisLater = (): void => {
|
||||
logEvent('Welcome Checklist: Will do this later clicked', {});
|
||||
void logEvent('Welcome Checklist: Will do this later clicked', {});
|
||||
setUpdatingUserPreferences(true);
|
||||
|
||||
updateUserPreference({
|
||||
@@ -271,11 +272,12 @@ export default function Home(): JSX.Element {
|
||||
}, [metricsOnboardingData, handleUpdateChecklistDoneItem]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('Homepage: Visited', {});
|
||||
void logEvent('Homepage: Visited', {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{isNoAuthMode && <NoAuthBanner />}
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
leftComponent={
|
||||
@@ -298,9 +300,9 @@ export default function Home(): JSX.Element {
|
||||
autoAdjustOverflow
|
||||
onOpenChange={(visible): void => {
|
||||
if (visible) {
|
||||
logEvent('Welcome Checklist: Expanded', {});
|
||||
void logEvent('Welcome Checklist: Expanded', {});
|
||||
} else {
|
||||
logEvent('Welcome Checklist: Minimized', {});
|
||||
void logEvent('Welcome Checklist: Minimized', {});
|
||||
}
|
||||
}}
|
||||
content={renderWelcomeChecklistModal()}
|
||||
@@ -353,7 +355,7 @@ export default function Home(): JSX.Element {
|
||||
className="active-ingestion-card-actions"
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
safeNavigate(ROUTES.LOGS_EXPLORER, {
|
||||
@@ -362,7 +364,7 @@ export default function Home(): JSX.Element {
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
history.push(ROUTES.LOGS_EXPLORER);
|
||||
@@ -396,7 +398,7 @@ export default function Home(): JSX.Element {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
safeNavigate(ROUTES.TRACES_EXPLORER, {
|
||||
@@ -405,7 +407,7 @@ export default function Home(): JSX.Element {
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
@@ -439,7 +441,7 @@ export default function Home(): JSX.Element {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
safeNavigate(ROUTES.METRICS_EXPLORER, {
|
||||
@@ -448,7 +450,7 @@ export default function Home(): JSX.Element {
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
void logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.METRICS_EXPLORER);
|
||||
@@ -496,7 +498,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Wrench size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
source: 'Logs',
|
||||
});
|
||||
safeNavigate(ROUTES.LOGS_EXPLORER, {
|
||||
@@ -513,7 +515,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Wrench size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
source: 'Traces',
|
||||
});
|
||||
safeNavigate(ROUTES.TRACES_EXPLORER, {
|
||||
@@ -530,7 +532,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Wrench size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
safeNavigate(ROUTES.METRICS_EXPLORER_EXPLORER, {
|
||||
@@ -569,7 +571,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
source: 'Dashboards',
|
||||
});
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD, {
|
||||
@@ -614,7 +616,7 @@ export default function Home(): JSX.Element {
|
||||
className="periscope-btn secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
void logEvent('Homepage: Explore clicked', {
|
||||
source: 'Alerts',
|
||||
});
|
||||
safeNavigate(ROUTES.ALERTS_NEW, {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { toISOString } from 'utils/app';
|
||||
|
||||
@@ -21,7 +22,6 @@ const PAGE_SIZE = 20;
|
||||
function MembersSettings(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
|
||||
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
|
||||
|
||||
@@ -146,7 +146,7 @@ function MembersSettings(): JSX.Element {
|
||||
: `Deleted ⎯ ${deletedCount}`;
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
void refetchUsers();
|
||||
}, [refetchUsers]);
|
||||
|
||||
const handleRowClick = useCallback((member: MemberRow): void => {
|
||||
@@ -158,7 +158,7 @@ function MembersSettings(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleMemberEditComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
void refetchUsers();
|
||||
}, [refetchUsers]);
|
||||
|
||||
return (
|
||||
@@ -201,14 +201,16 @@ function MembersSettings(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsInviteModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Invite member
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-invite-member">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={(): void => setIsInviteModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Invite member
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</div>
|
||||
</div>
|
||||
<MembersTable
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
import { screen } from 'tests/test-utils';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const USERS_ENDPOINT = '*/api/v2/users';
|
||||
|
||||
const mockUsers: TypesUserDTO[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
status: 'active',
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
describe('MembersSettings — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockUsers })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the no-auth sentinel and disables the Invite member button', async () => {
|
||||
renderWithNoAuth(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
expect(screen.getByTestId('no-auth-invite-member')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /invite member/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
updateMyPassword,
|
||||
useUpdateMyUserV2,
|
||||
} from 'api/generated/services/users';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Check, FileTerminal, Mail, User } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -80,10 +81,10 @@ function UserInfo(): JSX.Element {
|
||||
currentPassword === updatePassword;
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
logEvent('Account Settings: Name Updated', {
|
||||
void logEvent('Account Settings: Name Updated', {
|
||||
name: changedName,
|
||||
});
|
||||
logEvent(
|
||||
void logEvent(
|
||||
'Account Settings: Name Updated',
|
||||
{
|
||||
name: changedName,
|
||||
@@ -135,23 +136,27 @@ function UserInfo(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="user-info-update-section">
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<FileTerminal size={16} />}
|
||||
onClick={(): void => setIsUpdateNameModalOpen(true)}
|
||||
>
|
||||
Update name
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-update-name">
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<FileTerminal size={16} />}
|
||||
onClick={(): void => setIsUpdateNameModalOpen(true)}
|
||||
>
|
||||
Update name
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<FileTerminal size={16} />}
|
||||
onClick={(): void => setIsResetPasswordModalOpen(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-reset-password">
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<FileTerminal size={16} />}
|
||||
onClick={(): void => setIsResetPasswordModalOpen(true)}
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -161,16 +166,17 @@ function UserInfo(): JSX.Element {
|
||||
closable
|
||||
onCancel={hideUpdateNameModal}
|
||||
footer={[
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<Check size={16} />}
|
||||
onClick={onSaveHandler}
|
||||
disabled={isLoading}
|
||||
data-testid="update-name-btn"
|
||||
>
|
||||
Update name
|
||||
</Button>,
|
||||
<NoAuthGuard key="submit" testId="no-auth-update-name-submit">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={16} />}
|
||||
onClick={onSaveHandler}
|
||||
disabled={isLoading}
|
||||
data-testid="update-name-btn"
|
||||
>
|
||||
Update name
|
||||
</Button>
|
||||
</NoAuthGuard>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text>Name</Typography.Text>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import UserInfo from 'container/MySettings/UserInfo';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
...jest.requireActual('api/generated/services/users'),
|
||||
useUpdateMyUserV2: jest.fn(() => ({
|
||||
mutateAsync: jest.fn(),
|
||||
isLoading: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('UserInfo — no-auth mode', () => {
|
||||
it('renders no-auth guard wrappers for Update name and Reset password buttons', () => {
|
||||
renderWithNoAuth(<UserInfo />);
|
||||
expect(screen.getByTestId('no-auth-update-name')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('no-auth-reset-password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,9 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -140,6 +142,8 @@ function InviteTeamMembers({
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const { isNoAuthMode } = useAppContext();
|
||||
|
||||
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
|
||||
inviteUsers,
|
||||
{
|
||||
@@ -257,7 +261,7 @@ function InviteTeamMembers({
|
||||
const hasInvites =
|
||||
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
|
||||
const isButtonDisabled = isSendingInvites || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !hasInvites || isNoAuthMode;
|
||||
|
||||
return (
|
||||
<div className="questions-container">
|
||||
@@ -365,24 +369,26 @@ function InviteTeamMembers({
|
||||
)}
|
||||
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
disabled={isInviteButtonDisabled}
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-onboarding-invite">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
disabled={isInviteButtonDisabled}
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import InviteTeamMembers from 'container/OnboardingQuestionaire/InviteTeamMembers/InviteTeamMembers';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('OnboardingQuestionaire InviteTeamMembers — no-auth mode', () => {
|
||||
it('renders no-auth guard wrapper for the invite button', () => {
|
||||
renderWithNoAuth(
|
||||
<InviteTeamMembers
|
||||
isLoading={false}
|
||||
teamMembers={null}
|
||||
setTeamMembers={jest.fn()}
|
||||
onNext={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('no-auth-onboarding-invite')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
@@ -209,7 +210,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.STARTED}`,
|
||||
{},
|
||||
);
|
||||
@@ -253,7 +254,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
setSelectedFramework(null);
|
||||
setSelectedEnvironment(null);
|
||||
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SELECTED}`,
|
||||
{
|
||||
dataSource: dataSource.label,
|
||||
@@ -276,7 +277,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleSelectFramework = (option: any): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.FRAMEWORK_SELECTED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -309,7 +310,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
selectedEnvironment: any,
|
||||
baseURL?: string,
|
||||
): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.ENVIRONMENT_SELECTED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -351,7 +352,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
groupDataSourcesByTags(filteredDataSources as Entity[]),
|
||||
);
|
||||
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SEARCHED}`,
|
||||
{
|
||||
searchedDataSource: query,
|
||||
@@ -485,7 +486,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleShowInviteTeamMembersModal = (): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -498,7 +499,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleSubmitDataSourceRequest = (): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_REQUESTED}`,
|
||||
{
|
||||
requestedDataSource: dataSourceRequest,
|
||||
@@ -513,7 +514,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
};
|
||||
|
||||
const handleRaiseRequest = (): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_REQUESTED}`,
|
||||
{
|
||||
requestedDataSource: searchQuery,
|
||||
@@ -635,7 +636,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
size={14}
|
||||
className="onboarding-header-container-close-icon"
|
||||
onClick={(e): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CLOSE_ONBOARDING_CLICKED}`,
|
||||
{
|
||||
currentPage: setupStepItems[currentStep]?.title || '',
|
||||
@@ -649,14 +650,16 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="header-right-section">
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn invite-teammate-btn outlined"
|
||||
onClick={handleShowInviteTeamMembersModal}
|
||||
icon={<UserPlus size={16} />}
|
||||
>
|
||||
Invite a teammate
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-invite-teammate">
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn invite-teammate-btn outlined"
|
||||
onClick={handleShowInviteTeamMembersModal}
|
||||
icon={<UserPlus size={16} />}
|
||||
>
|
||||
Invite a teammate
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
@@ -970,7 +973,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
disabled={!selectedDataSource}
|
||||
shape="round"
|
||||
onClick={(e): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CONFIGURED_PRODUCT}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -1038,7 +1041,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
type="default"
|
||||
shape="round"
|
||||
onClick={(): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BACK_BUTTON_CLICKED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
@@ -1057,7 +1060,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
type="primary"
|
||||
shape="round"
|
||||
onClick={(e): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.CONTINUE_BUTTON_CLICKED}`,
|
||||
{
|
||||
dataSource: selectedDataSource?.label,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
import OnboardingAddDataSource from '../AddDataSource';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/global', () => ({
|
||||
useGetGlobalConfig: jest.fn(() => ({ data: undefined })),
|
||||
}));
|
||||
|
||||
jest.mock('components/LaunchChatSupport/LaunchChatSupport', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <button type="button">Contact Support</button>,
|
||||
}));
|
||||
|
||||
describe('OnboardingAddDataSource — no-auth mode', () => {
|
||||
it('renders no-auth guard wrapper for the invite teammate button', () => {
|
||||
renderWithNoAuth(<OnboardingAddDataSource />);
|
||||
expect(screen.getByTestId('no-auth-invite-teammate')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import InviteTeamMembers from 'container/OnboardingV2Container/InviteTeamMembers/InviteTeamMembers';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('OnboardingV2Container InviteTeamMembers — no-auth mode', () => {
|
||||
it('renders no-auth guard wrapper for the invite button', () => {
|
||||
renderWithNoAuth(
|
||||
<InviteTeamMembers
|
||||
isLoading={false}
|
||||
teamMembers={null}
|
||||
setTeamMembers={jest.fn()}
|
||||
onNext={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('no-auth-v2-invite')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { Button, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
|
||||
import {
|
||||
@@ -281,15 +282,17 @@ function InviteTeamMembers({
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="next-button periscope-btn primary"
|
||||
onClick={handleNext}
|
||||
loading={isSendingInvites || isLoading}
|
||||
>
|
||||
Send Invites
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-v2-invite">
|
||||
<Button
|
||||
type="primary"
|
||||
className="next-button periscope-btn primary"
|
||||
onClick={handleNext}
|
||||
loading={isSendingInvites || isLoading}
|
||||
>
|
||||
Send Invites
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
import CreateEdit from './CreateEdit';
|
||||
import { mockGoogleAuthDomain } from '../__tests__/mocks';
|
||||
|
||||
describe('CreateEdit — no-auth mode', () => {
|
||||
it('renders no-auth guard sentinel for Save Changes button', () => {
|
||||
renderWithNoAuth(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-auth-save-auth-domain')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no-auth guard sentinel for Save Changes button in create mode after selecting provider', async () => {
|
||||
renderWithNoAuth(<CreateEdit isCreate onClose={jest.fn()} />);
|
||||
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
fireEvent.click(configureButtons[0]);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('no-auth-save-auth-domain'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { AxiosError } from 'axios';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
@@ -257,14 +258,16 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSubmitHandler}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isCreating || isUpdating}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-save-auth-domain">
|
||||
<Button
|
||||
onClick={onSubmitHandler}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isCreating || isUpdating}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
|
||||
import {
|
||||
@@ -65,7 +66,9 @@ function SSOEnforcementToggle({
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
|
||||
<NoAuthGuard testId="no-auth-sso-toggle">
|
||||
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
|
||||
</NoAuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { screen, waitFor } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
import AuthDomain from '../index';
|
||||
import { AUTH_DOMAINS_LIST_ENDPOINT, mockEmptyDomainsResponse } from './mocks';
|
||||
|
||||
describe('AuthDomain — no-auth mode', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders no-auth guard sentinel for Add Domain button', async () => {
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
renderWithNoAuth(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-auth-add-domain')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
jest.mock('@signozhq/ui/switch', () => ({
|
||||
...jest.requireActual('@signozhq/ui/switch'),
|
||||
Switch: ({
|
||||
value,
|
||||
disabled,
|
||||
}: {
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
disabled={disabled}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import SSOEnforcementToggle from '../SSOEnforcementToggle';
|
||||
import { mockGoogleAuthDomain } from './mocks';
|
||||
|
||||
describe('SSOEnforcementToggle — no-auth mode', () => {
|
||||
it('renders no-auth guard sentinel when isNoAuthMode is true', () => {
|
||||
renderWithNoAuth(
|
||||
<SSOEnforcementToggle
|
||||
isDefaultChecked={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('no-auth-sso-toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -75,7 +76,7 @@ function AuthDomain(): JSX.Element {
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Domain deleted successfully');
|
||||
refetchAuthDomainListResponse();
|
||||
void refetchAuthDomainListResponse();
|
||||
hideDeleteModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -153,20 +154,24 @@ function AuthDomain(): JSX.Element {
|
||||
width: 100,
|
||||
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
|
||||
<section className="auth-domain-list-column-action">
|
||||
<Button
|
||||
className="auth-domain-list-action-link"
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
>
|
||||
Configure {SSOType.get(record.config?.ssoType || '')}
|
||||
</Button>
|
||||
<Button
|
||||
className="auth-domain-list-action-link delete"
|
||||
onClick={(): void => showDeleteModal(record)}
|
||||
variant="link"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-configure-sso">
|
||||
<Button
|
||||
className="auth-domain-list-action-link"
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
>
|
||||
Configure {SSOType.get(record.config?.ssoType || '')}
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
<NoAuthGuard testId="no-auth-delete-domain">
|
||||
<Button
|
||||
className="auth-domain-list-action-link delete"
|
||||
onClick={(): void => showDeleteModal(record)}
|
||||
variant="link"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
@@ -178,17 +183,19 @@ function AuthDomain(): JSX.Element {
|
||||
<div className="auth-domain">
|
||||
<section className="auth-domain-header">
|
||||
<h3 className="auth-domain-title">Authenticated Domains</h3>
|
||||
<Button
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => {
|
||||
setAddDomain(true);
|
||||
}}
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-add-domain">
|
||||
<Button
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => {
|
||||
setAddDomain(true);
|
||||
}}
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</section>
|
||||
{formattedError && <ErrorContent error={formattedError} />}
|
||||
{!errorFetchingAuthDomainListResponse && (
|
||||
@@ -231,15 +238,16 @@ function AuthDomain(): JSX.Element {
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
prefix={<Trash2 size={16} />}
|
||||
onClick={handleDeleteDomain}
|
||||
className="delete-btn"
|
||||
loading={isLoading}
|
||||
>
|
||||
Delete Domain
|
||||
</Button>,
|
||||
<NoAuthGuard key="submit" testId="no-auth-delete-domain-confirm">
|
||||
<Button
|
||||
prefix={<Trash2 size={16} />}
|
||||
onClick={handleDeleteDomain}
|
||||
className="delete-btn"
|
||||
loading={isLoading}
|
||||
>
|
||||
Delete Domain
|
||||
</Button>
|
||||
</NoAuthGuard>,
|
||||
]}
|
||||
>
|
||||
<p className="delete-text">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Form } from 'antd';
|
||||
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/invite/create', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
function TestWrapper(): JSX.Element {
|
||||
const [form] = Form.useForm();
|
||||
return (
|
||||
<InviteUserModal
|
||||
isInviteTeamMemberModalOpen
|
||||
toggleModal={jest.fn()}
|
||||
form={form}
|
||||
onClose={jest.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('InviteUserModal — no-auth mode', () => {
|
||||
it('renders no-auth guard wrapper for the invite submit button', () => {
|
||||
renderWithNoAuth(<TestWrapper />);
|
||||
expect(screen.getByTestId('no-auth-invite-user')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, FormInstance, Modal } from 'antd';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -85,16 +86,20 @@ function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||
ns: 'common',
|
||||
})}
|
||||
</Button>,
|
||||
<Button
|
||||
<NoAuthGuard
|
||||
key={t('invite_team_members').toString()}
|
||||
onClick={modalForm.submit}
|
||||
data-testid="invite-team-members-button"
|
||||
type="primary"
|
||||
disabled={isInvitingMembers}
|
||||
loading={isInvitingMembers}
|
||||
testId="no-auth-invite-user"
|
||||
>
|
||||
{t('invite_team_members')}
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={modalForm.submit}
|
||||
data-testid="invite-team-members-button"
|
||||
type="primary"
|
||||
disabled={isInvitingMembers}
|
||||
loading={isInvitingMembers}
|
||||
>
|
||||
{t('invite_team_members')}
|
||||
</Button>
|
||||
</NoAuthGuard>,
|
||||
]}
|
||||
>
|
||||
<InviteTeamMembers form={modalForm} onFinish={onInviteClickHandler} />
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { screen } from 'tests/test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
|
||||
import CreateRoleModal from './CreateRoleModal';
|
||||
|
||||
describe('CreateRoleModal — no-auth mode', () => {
|
||||
it('renders no-auth guard sentinel for Create Role button', () => {
|
||||
renderWithNoAuth(<CreateRoleModal isOpen onClose={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('no-auth-save-role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { handleApiError } from 'utils/errorUtils';
|
||||
|
||||
@@ -148,16 +149,17 @@ function CreateRoleModal({
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSubmit}
|
||||
loading={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
{isEditMode ? 'Save Changes' : 'Create Role'}
|
||||
</Button>,
|
||||
<NoAuthGuard key="submit" testId="no-auth-save-role">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSubmit}
|
||||
loading={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
{isEditMode ? 'Save Changes' : 'Create Role'}
|
||||
</Button>
|
||||
</NoAuthGuard>,
|
||||
]}
|
||||
destroyOnClose
|
||||
className="create-role-modal"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
|
||||
import CreateRoleModal from './RolesComponents/CreateRoleModal';
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
@@ -42,15 +43,17 @@ function RolesSettings(): JSX.Element {
|
||||
/>
|
||||
{isRolesEnabled && (
|
||||
<AuthZTooltip checks={[RoleCreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-create-custom-role">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
import { screen } from 'tests/test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
describe('RolesSettings — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the no-auth sentinel for the Custom role button', async () => {
|
||||
renderWithNoAuth(<RolesSettings />);
|
||||
|
||||
await screen.findByText('signoz-admin');
|
||||
|
||||
expect(screen.getByTestId('no-auth-create-custom-role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,8 @@ export function getAppContextMockState(
|
||||
userPreferences: null,
|
||||
hostsData: null,
|
||||
isLoggedIn: false,
|
||||
isNoAuthMode: false,
|
||||
isPreflightLoading: false,
|
||||
org: null,
|
||||
isFetchingUser: false,
|
||||
isFetchingActiveLicense: false,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import './ServiceAccountsSettings.styles.scss';
|
||||
import { NoAuthGuard } from 'components/NoAuthGuard';
|
||||
|
||||
function ServiceAccountsSettings(): JSX.Element {
|
||||
const [currentPage, setPage] = useQueryState(
|
||||
@@ -264,16 +265,18 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<AuthZTooltip checks={[SACreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
<NoAuthGuard testId="no-auth-new-service-account">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</NoAuthGuard>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { setupAuthzAdmin } from 'tests/authz-test-utils';
|
||||
import { renderWithNoAuth } from 'tests/no-auth-test-utils';
|
||||
import { screen } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
const mockServiceAccountsAPI = [
|
||||
{
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1700000001,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ServiceAccountsSettings — no-auth mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
setupAuthzAdmin(),
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (req, res, ctx) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const account = mockServiceAccountsAPI.find((a) => a.id === id);
|
||||
return account
|
||||
? res(ctx.status(200), ctx.json({ data: account }))
|
||||
: res(ctx.status(404), ctx.json({ message: 'Not found' }));
|
||||
}),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the no-auth sentinel for the New Service Account button', async () => {
|
||||
renderWithNoAuth(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
expect(screen.getByTestId('no-auth-new-service-account')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1120,6 +1120,7 @@
|
||||
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
featureFlags,
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
isNoAuthMode,
|
||||
userPreferences,
|
||||
changelog,
|
||||
toggleChangelogModal,
|
||||
@@ -408,7 +409,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
);
|
||||
|
||||
const handleReorderShortcutNavItems = (): void => {
|
||||
logEvent('Sidebar V2: Save shortcuts clicked', {
|
||||
void logEvent('Sidebar V2: Save shortcuts clicked', {
|
||||
shortcuts: tempPinnedMenuItems.map((item) => item.key),
|
||||
});
|
||||
setPinnedMenuItems(tempPinnedMenuItems);
|
||||
@@ -436,7 +437,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
const onClickGetStarted = (event: MouseEvent): void => {
|
||||
logEvent('Sidebar: Menu clicked', {
|
||||
void logEvent('Sidebar: Menu clicked', {
|
||||
menuRoute: '/get-started',
|
||||
menuLabel: 'Get Started',
|
||||
});
|
||||
@@ -489,12 +490,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
isNoAuthMode,
|
||||
}),
|
||||
[
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
user.email,
|
||||
isWorkspaceBlocked,
|
||||
isNoAuthMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -651,7 +654,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
} else if (item) {
|
||||
onClickHandler(item?.key as string, event);
|
||||
}
|
||||
logEvent('Sidebar V2: Menu clicked', {
|
||||
void logEvent('Sidebar V2: Menu clicked', {
|
||||
menuRoute: item?.key,
|
||||
menuLabel: item?.label,
|
||||
});
|
||||
@@ -794,7 +797,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
onTogglePin={
|
||||
allowPin
|
||||
? (item): void => {
|
||||
logEvent(
|
||||
void logEvent(
|
||||
`Sidebar V2: Menu item ${item.isPinned ? 'unpinned' : 'pinned'}`,
|
||||
{
|
||||
menuRoute: item.key,
|
||||
@@ -841,7 +844,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
const event = (info as SidebarItem & { domEvent?: MouseEvent }).domEvent;
|
||||
|
||||
if (item && !('type' in item)) {
|
||||
logEvent('Help Popover: Item clicked', {
|
||||
void logEvent('Help Popover: Item clicked', {
|
||||
menuRoute: item.key,
|
||||
menuLabel: String(item.label),
|
||||
});
|
||||
@@ -890,7 +893,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
menuLabel = item.label;
|
||||
}
|
||||
|
||||
logEvent('Settings Popover: Item clicked', {
|
||||
void logEvent('Settings Popover: Item clicked', {
|
||||
menuRoute: item?.key,
|
||||
menuLabel,
|
||||
});
|
||||
@@ -927,7 +930,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
}
|
||||
break;
|
||||
case 'logout':
|
||||
Logout();
|
||||
void Logout();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
@@ -1081,7 +1084,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
<div
|
||||
className="nav-section-title-icon reorder"
|
||||
onClick={(): void => {
|
||||
logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
void logEvent('Sidebar V2: Manage shortcuts clicked', {});
|
||||
setIsReorderShortcutNavItemsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -1128,7 +1131,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
return;
|
||||
}
|
||||
const newCollapsedState = !isMoreMenuCollapsed;
|
||||
logEvent('Sidebar V2: More menu clicked', {
|
||||
void logEvent('Sidebar V2: More menu clicked', {
|
||||
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
|
||||
});
|
||||
setIsMoreMenuCollapsed(newCollapsedState);
|
||||
@@ -1234,14 +1237,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
open={isReorderShortcutNavItemsModalOpen}
|
||||
closable
|
||||
onCancel={(): void => {
|
||||
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
hideReorderShortcutNavItemsModal();
|
||||
}}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => {
|
||||
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
|
||||
hideReorderShortcutNavItemsModal();
|
||||
}}
|
||||
className="periscope-btn cancel-btn secondary-btn"
|
||||
|
||||
@@ -5,6 +5,7 @@ const BASE_PARAMS = {
|
||||
isWorkspaceBlocked: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
isNoAuthMode: false,
|
||||
};
|
||||
|
||||
describe('getUserSettingsDropdownMenuItems', () => {
|
||||
@@ -71,4 +72,15 @@ describe('getUserSettingsDropdownMenuItems', () => {
|
||||
expect(keys[3]).toBe('account');
|
||||
expect(keys[keys.length - 1]).toBe('logout');
|
||||
});
|
||||
|
||||
it('omits sign out and its preceding divider when isNoAuthMode=true', () => {
|
||||
const items =
|
||||
getUserSettingsDropdownMenuItems({ ...BASE_PARAMS, isNoAuthMode: true }) ??
|
||||
[];
|
||||
const keys = items.map((item: any) => item.key ?? item.type);
|
||||
|
||||
expect(keys).not.toContain('logout');
|
||||
// the trailing divider before logout should also be gone
|
||||
expect(keys[keys.length - 1]).toBe('keyboard-shortcuts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MenuProps } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BarChart,
|
||||
@@ -35,15 +37,13 @@ import {
|
||||
Users,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { MenuProps } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
SecondaryMenuItemKey,
|
||||
SettingsNavSection,
|
||||
SidebarItem,
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -487,6 +487,7 @@ export interface UserSettingsMenuItemsParams {
|
||||
isWorkspaceBlocked: boolean;
|
||||
isEnterpriseSelfHostedUser: boolean;
|
||||
isCommunityEnterpriseUser: boolean;
|
||||
isNoAuthMode: boolean;
|
||||
}
|
||||
|
||||
export const getUserSettingsDropdownMenuItems = ({
|
||||
@@ -494,6 +495,7 @@ export const getUserSettingsDropdownMenuItems = ({
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
isNoAuthMode,
|
||||
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
|
||||
[
|
||||
{
|
||||
@@ -537,21 +539,25 @@ export const getUserSettingsDropdownMenuItems = ({
|
||||
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'keyboard-shortcuts-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
icon: (
|
||||
<LogOut
|
||||
size={14}
|
||||
className="user-settings-dropdown-logout-section"
|
||||
color={Style.DANGER_BACKGROUND}
|
||||
/>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
...(isNoAuthMode
|
||||
? []
|
||||
: [
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
icon: (
|
||||
<LogOut
|
||||
size={14}
|
||||
className="user-settings-dropdown-logout-section"
|
||||
color={Style.DANGER_BACKGROUND}
|
||||
/>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
]),
|
||||
].filter(Boolean);
|
||||
|
||||
/** Mapping of some newly added routes and their corresponding active sidebar menu key */
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import ConversationView from 'container/AIAssistant/ConversationView';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { VariantContext } from 'container/AIAssistant/VariantContext';
|
||||
import { Sparkles } from '@signozhq/icons';
|
||||
@@ -17,8 +20,27 @@ interface RouteParams {
|
||||
|
||||
export default function AIAssistantPage(): JSX.Element {
|
||||
const history = useHistory();
|
||||
const location = useLocation<{ fromInApp?: boolean } | undefined>();
|
||||
const { pathname } = location;
|
||||
const { conversationId } = useParams<RouteParams>();
|
||||
|
||||
// Skip the mount-time Opened fire when the user expanded an already-open
|
||||
// drawer/modal — that surface already emitted Opened with the right source.
|
||||
// Router state (vs a module flag) survives StrictMode double-mount and
|
||||
// aborted navigations.
|
||||
const fromInApp = location.state?.fromInApp === true;
|
||||
useEffect(() => {
|
||||
if (fromInApp) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.Opened, {
|
||||
source: 'deeplink',
|
||||
currentPage: normalizePage(pathname),
|
||||
});
|
||||
// Only on mount; route param changes inside the same page aren't a re-open.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
@@ -71,9 +93,14 @@ export default function AIAssistantPage(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
void logEvent(AIAssistantEvents.NewChatClicked, {
|
||||
page: normalizePage(pathname),
|
||||
mode: 'full_screen',
|
||||
source: 'history_list',
|
||||
});
|
||||
const newId = startNewConversation();
|
||||
history.push(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
|
||||
}, [startNewConversation, history]);
|
||||
}, [startNewConversation, history, pathname]);
|
||||
|
||||
// Prefer the URL param, but fall back to the store's `activeConversationId`
|
||||
// for the brief render after a re-key (client UUID → backend threadId), so
|
||||
|
||||
@@ -321,7 +321,7 @@ function SettingsPage(): JSX.Element {
|
||||
isDisabled={false}
|
||||
showIcon={false}
|
||||
onClick={(event): void => {
|
||||
logEvent('Settings V2: Menu clicked', {
|
||||
void logEvent('Settings V2: Menu clicked', {
|
||||
menuLabel: item.label,
|
||||
menuRoute: item.key,
|
||||
});
|
||||
|
||||
@@ -13,8 +13,11 @@ import { useQuery } from 'react-query';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { useGetHosts } from 'api/generated/services/zeus';
|
||||
import { useGetGlobalConfig } from 'api/generated/services/global';
|
||||
import { useGetMyUser } from 'api/generated/services/users';
|
||||
import listOrgPreferences from 'api/v1/org/preferences/list';
|
||||
import { clearAuthStorage } from 'utils/clearAuthStorage';
|
||||
import { setNoAuthMode } from 'utils/noAuthMode';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
@@ -70,11 +73,51 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(
|
||||
(): boolean => getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true',
|
||||
);
|
||||
const [isNoAuthMode, setIsNoAuthMode] = useState<boolean>(false);
|
||||
const [isPreflightLoading, setIsPreflightLoading] = useState<boolean>(true);
|
||||
const [org, setOrg] = useState<Organization[] | null>(null);
|
||||
const [changelog, setChangelog] = useState<ChangelogSchema | null>(null);
|
||||
|
||||
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
|
||||
|
||||
// Pre-flight: discover auth mode from public global config.
|
||||
// On success: in impersonation mode → clear stale tokens, force isLoggedIn=true,
|
||||
// set noAuthMode singleton so the axios interceptor (outside React)
|
||||
// can skip the rotate-logout chain.
|
||||
// On failure: fail-safe to normal auth flow (treat as not no-auth).
|
||||
const { data: globalConfigData, isLoading: isFetchingGlobalConfig } =
|
||||
useGetGlobalConfig({
|
||||
query: {
|
||||
retry: 2,
|
||||
retryDelay: 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingGlobalConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const impersonationEnabled =
|
||||
globalConfigData?.data?.identN?.impersonation?.enabled === true;
|
||||
|
||||
if (impersonationEnabled) {
|
||||
clearAuthStorage();
|
||||
setDefaultUser(getUserDefaults());
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
setNoAuthMode(true);
|
||||
setIsNoAuthMode(true);
|
||||
setIsLoggedIn(true);
|
||||
} else {
|
||||
setNoAuthMode(false);
|
||||
setIsNoAuthMode(false);
|
||||
}
|
||||
|
||||
setIsPreflightLoading(false);
|
||||
}, [globalConfigData, isFetchingGlobalConfig]);
|
||||
|
||||
// fetcher for current user
|
||||
// user will only be fetched if the user id and token is present
|
||||
// if logged out and trying to hit any route none of these calls will trigger
|
||||
@@ -366,6 +409,9 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
|
||||
// global event listener for LOGOUT event to clean the app context state
|
||||
useGlobalEventListener('LOGOUT', () => {
|
||||
if (isNoAuthMode) {
|
||||
return;
|
||||
} // logout is meaningless in no-auth; defensively no-op
|
||||
setIsLoggedIn(false);
|
||||
setDefaultUser(getUserDefaults());
|
||||
setActiveLicense(null);
|
||||
@@ -385,6 +431,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
orgPreferences,
|
||||
hostsData,
|
||||
isLoggedIn,
|
||||
isNoAuthMode,
|
||||
isPreflightLoading,
|
||||
org,
|
||||
isFetchingUser,
|
||||
isFetchingActiveLicense,
|
||||
@@ -425,6 +473,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
isLoggedIn,
|
||||
hostsData,
|
||||
hostsFetchError,
|
||||
isNoAuthMode,
|
||||
isPreflightLoading,
|
||||
org,
|
||||
orgPreferences,
|
||||
activeLicenseRefetch,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import { getIsNoAuthMode, setNoAuthMode } from 'utils/noAuthMode';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
@@ -13,6 +14,7 @@ import { AppProvider, useAppContext } from '../App';
|
||||
|
||||
const MY_USER_URL = 'http://localhost/api/v2/users/me';
|
||||
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
|
||||
const GLOBAL_CONFIG_URL = 'http://localhost/api/v1/global/config';
|
||||
|
||||
jest.mock('constants/env', () => ({
|
||||
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
|
||||
@@ -336,3 +338,127 @@ describe('AppProvider when authz/check fails', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppProvider no-auth preflight', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setNoAuthMode(false);
|
||||
});
|
||||
|
||||
it('sets isNoAuthMode=true and noAuthMode singleton when impersonation is enabled', async () => {
|
||||
server.use(
|
||||
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { identN: { impersonation: { enabled: true } } },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.isNoAuthMode).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
expect(getIsNoAuthMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves isNoAuthMode=false and clears noAuthMode singleton when impersonation is disabled', async () => {
|
||||
server.use(
|
||||
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { identN: { impersonation: { enabled: false } } },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.isPreflightLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
expect(result.current.isNoAuthMode).toBe(false);
|
||||
expect(getIsNoAuthMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('clears stale auth tokens from localStorage and resets in-memory JWT state when impersonation is enabled', async () => {
|
||||
setLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN, 'stale-access-token');
|
||||
setLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'stale-refresh-token');
|
||||
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
|
||||
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'Old Name');
|
||||
|
||||
server.use(
|
||||
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { identN: { impersonation: { enabled: true } } },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.isNoAuthMode).toBe(true);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
// localStorage cleared
|
||||
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
|
||||
|
||||
// in-memory JWTs reset so stale tokens don't linger in context or React Query keys
|
||||
expect(result.current.user.accessJwt).toBe('');
|
||||
expect(result.current.user.refreshJwt).toBe('');
|
||||
});
|
||||
|
||||
it('transitions isPreflightLoading from true to false once preflight resolves', async () => {
|
||||
server.use(
|
||||
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: { identN: { impersonation: { enabled: false } } },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
expect(result.current.isPreflightLoading).toBe(true);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.isPreflightLoading).toBe(false);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface IAppContext {
|
||||
userPreferences: UserPreference[] | null;
|
||||
hostsData: GetHosts200 | null;
|
||||
isLoggedIn: boolean;
|
||||
isNoAuthMode: boolean;
|
||||
isPreflightLoading: boolean;
|
||||
org: Organization[] | null;
|
||||
isFetchingUser: boolean;
|
||||
isFetchingActiveLicense: boolean;
|
||||
|
||||
25
frontend/src/tests/no-auth-test-utils.ts
Normal file
25
frontend/src/tests/no-auth-test-utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IAppContext } from 'providers/App/types';
|
||||
|
||||
import { render } from './test-utils';
|
||||
|
||||
export const NO_AUTH_CONTEXT: Partial<IAppContext> = {
|
||||
isNoAuthMode: true,
|
||||
isPreflightLoading: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a component with no-auth mode enabled in the app context.
|
||||
* Mirrors the authz-test-utils pattern for consistent no-auth test setup.
|
||||
*/
|
||||
export function renderWithNoAuth(
|
||||
...args: Parameters<typeof render>
|
||||
): ReturnType<typeof render> {
|
||||
const [ui, options, providerProps = {}] = args;
|
||||
return render(ui, options, {
|
||||
...providerProps,
|
||||
appContextOverrides: {
|
||||
...providerProps.appContextOverrides,
|
||||
...NO_AUTH_CONTEXT,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -243,6 +243,8 @@ export function getAppContextMock(
|
||||
isFetchingOrgPreferences: false,
|
||||
orgPreferencesFetchError: null,
|
||||
isLoggedIn: true,
|
||||
isNoAuthMode: false,
|
||||
isPreflightLoading: false,
|
||||
showChangelogModal: false,
|
||||
updateUser: jest.fn(),
|
||||
updateOrg: jest.fn(),
|
||||
|
||||
39
frontend/src/utils/__tests__/clearAuthStorage.test.ts
Normal file
39
frontend/src/utils/__tests__/clearAuthStorage.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { clearAuthStorage } from '../clearAuthStorage';
|
||||
|
||||
describe('clearAuthStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('removes all auth-related localStorage keys', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
|
||||
localStorage.setItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'refresh');
|
||||
localStorage.setItem(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
|
||||
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'old');
|
||||
localStorage.setItem(LOCALSTORAGE.IS_IDENTIFIED_USER, 'true');
|
||||
localStorage.setItem(LOCALSTORAGE.USER_ID, 'abc');
|
||||
|
||||
clearAuthStorage();
|
||||
|
||||
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.IS_LOGGED_IN)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.IS_IDENTIFIED_USER)).toBeNull();
|
||||
expect(localStorage.getItem(LOCALSTORAGE.USER_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves non-auth localStorage keys', () => {
|
||||
localStorage.setItem(LOCALSTORAGE.THEME, 'dark');
|
||||
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
|
||||
|
||||
clearAuthStorage();
|
||||
|
||||
expect(localStorage.getItem(LOCALSTORAGE.THEME)).toBe('dark');
|
||||
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
|
||||
});
|
||||
});
|
||||
16
frontend/src/utils/clearAuthStorage.ts
Normal file
16
frontend/src/utils/clearAuthStorage.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
const AUTH_KEYS: LOCALSTORAGE[] = [
|
||||
LOCALSTORAGE.AUTH_TOKEN,
|
||||
LOCALSTORAGE.REFRESH_AUTH_TOKEN,
|
||||
LOCALSTORAGE.IS_LOGGED_IN,
|
||||
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
|
||||
LOCALSTORAGE.LOGGED_IN_USER_NAME,
|
||||
LOCALSTORAGE.IS_IDENTIFIED_USER,
|
||||
LOCALSTORAGE.USER_ID,
|
||||
];
|
||||
|
||||
export const clearAuthStorage = (): void => {
|
||||
AUTH_KEYS.forEach((key) => deleteLocalStorageKey(key));
|
||||
};
|
||||
7
frontend/src/utils/noAuthMode.ts
Normal file
7
frontend/src/utils/noAuthMode.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
let _isNoAuthMode = false;
|
||||
|
||||
export const setNoAuthMode = (value: boolean): void => {
|
||||
_isNoAuthMode = value;
|
||||
};
|
||||
|
||||
export const getIsNoAuthMode = (): boolean => _isNoAuthMode;
|
||||
Reference in New Issue
Block a user