Compare commits

..

6 Commits

Author SHA1 Message Date
Nikhil Soni
0160a526fa fix: switch to new derived attributes for ip filtering 2026-02-19 20:11:52 +05:30
Nikhil Soni
deeb690621 fix: add missing filtering for ip address for scalar data
In domain listing api for external api monitoring,
we have option to filter out the IP address but
it only handles timeseries and raw type data while
domain list handler returns scalar data.
2026-02-19 20:11:52 +05:30
SagarRajput-7
5cc562ba35 feat: added roles page and listing view (#10329)
* feat: added roles page and listing view

* feat: refactored to use usetimezone hook and scss refactor

* feat: added page in url params and refactors

* feat: used semantic tokens for scss change
2026-02-19 13:45:42 +00:00
Abhi kumar
22099962be fix: added fix for jerky chart change on panel switch (#10360) 2026-02-19 13:31:21 +00:00
SagarRajput-7
2559b52bb1 feat: enhancement in the authn providers with new fields and new ui (#10276)
* feat: enhancement in the authn providers with new fields and new ui

* feat: added error handling, integrated generate apis and form validation

* feat: error handling and code refactor

* feat: cleanup and refactor

* feat: cleanup and refactor

* feat: added test cases for the auth domain flow

* feat: used signozhq instead of antd and lucide icons

* feat: toggle consistency fix

* feat: added redirect uri field in google auth

* feat: addressed comments and feedback

* feat: addressed comments and feedback

* feat: removed redirecturi and added error helper for collapsed sections

* feat: refactored code and added email field

* feat: addressed comments and feedback

* feat: added delete confirmation modal for domain list

* feat: addressed comments and feedback
2026-02-19 13:12:11 +00:00
Abhi kumar
7523596043 fix: added fix for rendering single point (#10344)
* fix: added fix for rendering single point

* fix: minor changes

* chore: addded tests for timeseries util

* chore: pr review changes

* fix: fixed tests
2026-02-19 18:16:47 +05:30
64 changed files with 5151 additions and 2141 deletions

View File

@@ -58,6 +58,7 @@
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",

View File

@@ -12,5 +12,6 @@
"pipeline": "Pipeline",
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics"
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
}

View File

@@ -12,5 +12,6 @@
"pipeline": "Pipeline",
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics"
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
}

View File

@@ -73,5 +73,6 @@
"API_MONITORING": "SigNoz | External APIs",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter"
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles"
}

View File

@@ -1,19 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<null>(`/domains/${id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDomain;

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
const put = async (
props: UpdatableAuthDomain,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<RawSuccessResponse<null>>(
`/domains/${props.id}`,
{ config: props.config },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default put;

View File

@@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
const listAllDomain = async (): Promise<
SuccessResponseV2<GettableAuthDomain[]>
> => {
try {
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
`/domains`,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listAllDomain;

View File

@@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
const post = async (
props: PostableAuthDomain,
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
try {
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
`/domains`,
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -24,5 +24,6 @@ import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/tooltip';

View File

@@ -1,332 +0,0 @@
import { ReactElement } from 'react-markdown/lib/react-markdown';
import { ENVIRONMENT } from 'constants/env';
import { BrandedPermission, buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { GuardAuthZ } from './GuardAuthZ';
const BASE_URL = ENVIRONMENT.baseURL || '';
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
const ErrorFallback = (error: Error): ReactElement => (
<div>Error occurred: {error.message}</div>
);
const NoPermissionFallback = (_response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => <div>Access denied</div>;
const NoPermissionFallbackWithSuggestions = (response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => (
<div>
Access denied. Required permission: {response.requiredPermissionName}
</div>
);
it('should render children when permission is granted', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
it('should render fallbackOnLoading when loading', () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnLoading={<LoadingFallback />}
>
<TestChild />
</GuardAuthZ>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when loading and no fallbackOnLoading provided', () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
expect(container.firstChild).toBeNull();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render fallbackOnError when API error occurs', async () => {
const errorMessage = 'Internal Server Error';
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: errorMessage }));
}),
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={ErrorFallback}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText(/Error occurred:/)).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should pass error object to fallbackOnError function', async () => {
const errorMessage = 'Network request failed';
let receivedError: Error | null = null;
const errorFallbackWithCapture = (error: Error): ReactElement => {
receivedError = error;
return <div>Captured error: {error.message}</div>;
};
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: errorMessage }));
}),
);
render(
<GuardAuthZ
relation="read"
object="dashboard:*"
fallbackOnError={errorFallbackWithCapture}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(receivedError).not.toBeNull();
});
expect(receivedError).toBeInstanceOf(Error);
expect(screen.getByText(/Captured error:/)).toBeInTheDocument();
});
it('should render null when error occurs and no fallbackOnError provided', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render fallbackOnNoPermissions when permission is denied', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
render(
<GuardAuthZ
relation="update"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallback}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Access denied')).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when permission is denied and no fallbackOnNoPermissions provided', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
const { container } = render(
<GuardAuthZ relation="update" object="dashboard:123">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render null when permissions object is null', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json(null));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
render(
<GuardAuthZ
relation="update"
object="dashboard:123"
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(
screen.getByText(/Access denied. Required permission:/),
).toBeInTheDocument();
});
expect(screen.getByText(new RegExp(permission))).toBeInTheDocument();
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should handle different relation and object combinations', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('delete', 'dashboard:456');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (req, res, ctx) => {
const body = req.body as {
permissions: Array<{ relation: string; object: string }>;
};
const perm = body.permissions[0];
const permKey = `${perm.relation}/${perm.object}`;
if (permKey === permission1) {
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
}),
);
}
return res(
ctx.status(200),
ctx.json({
[permission2]: true,
}),
);
}),
);
const { rerender } = render(
<GuardAuthZ relation="read" object="dashboard:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
rerender(
<GuardAuthZ relation="delete" object="dashboard:456">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
});
});
});

View File

@@ -1,50 +0,0 @@
import { ReactElement } from 'react';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
buildPermission,
} from 'hooks/useAuthZ/utils';
export type GuardAuthZProps<R extends AuthZRelation> = {
children: ReactElement;
relation: R;
object: AuthZObject<R>;
fallbackOnLoading?: JSX.Element;
fallbackOnError?: (error: Error) => JSX.Element;
fallbackOnNoPermissions?: (response: {
requiredPermissionName: BrandedPermission;
}) => JSX.Element;
};
export function GuardAuthZ<R extends AuthZRelation>({
children,
relation,
object,
fallbackOnLoading,
fallbackOnError,
fallbackOnNoPermissions,
}: GuardAuthZProps<R>): JSX.Element | null {
const permission = buildPermission<R>(relation, object);
const { permissions, isLoading, error } = useAuthZ([permission]);
if (isLoading) {
return fallbackOnLoading ?? null;
}
if (error) {
return fallbackOnError?.(error) ?? null;
}
if (!permissions?.[permission]?.isGranted) {
return (
fallbackOnNoPermissions?.({
requiredPermissionName: permission,
}) ?? null
);
}
return children;
}

View File

@@ -1,46 +0,0 @@
.guard-authz-error-no-authz {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 24px;
.guard-authz-error-no-authz-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
max-width: 500px;
}
img {
width: 32px;
height: 32px;
}
h3 {
font-size: 18px;
color: var(--bg-vanilla-100);
line-height: 18px;
}
p {
font-size: 14px;
color: var(--bg-vanilla-400);
line-height: 18px;
pre {
display: flex;
align-items: center;
background-color: var(--bg-slate-400);
cursor: pointer;
svg {
margin-left: 2px;
}
}
}
}

View File

@@ -1,489 +0,0 @@
import { ReactElement } from 'react';
import type { RouteComponentProps } from 'react-router-dom';
import { ENVIRONMENT } from 'constants/env';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { createGuardedRoute } from './createGuardedRoute';
const BASE_URL = ENVIRONMENT.baseURL || '';
describe('createGuardedRoute', () => {
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
<div>Test Component: {testProp}</div>
);
it('should render component when permission is granted', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should substitute route parameters in object string', async () => {
const permission = buildPermission('read', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
);
const mockMatch = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should handle multiple route parameters', async () => {
const permission = buildPermission('update', 'dashboard:123:456');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'dashboard:{id}:{version}',
);
const mockMatch = {
params: { id: '123', version: '456' },
isExact: true,
path: '/dashboard/:id/:version',
url: '/dashboard/123/456',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should keep placeholder when route parameter is missing', async () => {
const permission = buildPermission('read', 'dashboard:{id}');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
it('should render loading fallback when loading', () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
expect(screen.getByText('SigNoz')).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should render error fallback when API error occurs', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
});
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should render no permissions fallback when permission is denied', async () => {
const permission = buildPermission('update', 'dashboard:123');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: false,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'update',
'dashboard:{id}',
);
const mockMatch = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
const heading = document.querySelector('h3');
expect(heading).toBeInTheDocument();
expect(heading?.textContent).toMatch(/permission to view/i);
});
expect(screen.getByText(permission, { exact: false })).toBeInTheDocument();
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should pass all props to wrapped component', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const ComponentWithMultipleProps = ({
prop1,
prop2,
prop3,
}: {
prop1: string;
prop2: number;
prop3: boolean;
}): ReactElement => (
<div>
{prop1} - {prop2} - {prop3.toString()}
</div>
);
const GuardedComponent = createGuardedRoute(
ComponentWithMultipleProps,
'read',
'dashboard:*',
);
const mockMatch = {
params: {},
isExact: true,
path: '/dashboard',
url: '/dashboard',
};
const props = {
prop1: 'value1',
prop2: 42,
prop3: true,
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
});
});
it('should memoize resolved object based on route params', async () => {
const permission1 = buildPermission('read', 'dashboard:123');
const permission2 = buildPermission('read', 'dashboard:456');
let requestCount = 0;
const requestedPermissions: string[] = [];
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (req, res, ctx) => {
requestCount++;
const body = req.body as {
permissions: Array<{ relation: string; object: string }>;
};
const perm = body.permissions[0];
requestedPermissions.push(`${perm.relation}/${perm.object}`);
if (perm.object === 'dashboard:123') {
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
}),
);
}
return res(
ctx.status(200),
ctx.json({
[permission2]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'read',
'dashboard:{id}',
);
const mockMatch1 = {
params: { id: '123' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/123',
};
const props1 = {
testProp: 'test-value-1',
match: mockMatch1,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
const { unmount } = render(<GuardedComponent {...props1} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
});
expect(requestCount).toBe(1);
expect(requestedPermissions).toContain('read/dashboard:123');
unmount();
const mockMatch2 = {
params: { id: '456' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/456',
};
const props2 = {
testProp: 'test-value-2',
match: mockMatch2,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props2} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
});
expect(requestCount).toBe(2);
expect(requestedPermissions).toContain('read/dashboard:456');
});
it('should handle different relation types', async () => {
const permission = buildPermission('delete', 'dashboard:789');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
[permission]: true,
}),
);
}),
);
const GuardedComponent = createGuardedRoute(
TestComponent,
'delete',
'dashboard:{id}',
);
const mockMatch = {
params: { id: '789' },
isExact: true,
path: '/dashboard/:id',
url: '/dashboard/789',
};
const props = {
testProp: 'test-value',
match: mockMatch,
location: ({} as unknown) as RouteComponentProps['location'],
history: ({} as unknown) as RouteComponentProps['history'],
};
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
});
});
});

View File

@@ -1,79 +0,0 @@
import { ComponentType, ReactElement, useMemo } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { toast } from '@signozhq/sonner';
import {
AuthZObject,
AuthZRelation,
BrandedPermission,
} from 'hooks/useAuthZ/utils';
import { Copy } from 'lucide-react';
import { useCopyToClipboard } from '../../hooks/useCopyToClipboard';
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import AppLoading from '../AppLoading/AppLoading';
import { GuardAuthZ } from './GuardAuthZ';
import './createGuardedRoute.styles.scss';
const onErrorFallback = (): JSX.Element => <ErrorBoundaryFallback />;
function OnNoPermissionsFallback(response: {
requiredPermissionName: BrandedPermission;
}): ReactElement {
const { copyToClipboard } = useCopyToClipboard();
const onClickToCopy = (): void => {
copyToClipboard(response.requiredPermissionName, 'required-permission');
toast.success('Permission copied to clipboard');
};
return (
<div className="guard-authz-error-no-authz">
<div className="guard-authz-error-no-authz-content">
<img src="/Icons/no-data.svg" alt="No permission" />
<h3>Uh-oh! You dont have permission to view this page.</h3>
<p>
You need the following permission to view this page:
<br />
<pre onClick={onClickToCopy}>
{response.requiredPermissionName} <Copy size={12} />
</pre>
Ask your SigNoz administrator to grant access.
</p>
</div>
</div>
);
}
// eslint-disable-next-line @typescript-eslint/ban-types
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
Component: ComponentType<P>,
relation: R,
object: AuthZObject<R>,
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
return function GuardedRouteComponent(
props: P & RouteComponentProps<Record<string, string>>,
): ReactElement {
const resolvedObject = useMemo(() => {
const paramPattern = /\{([^}]+)\}/g;
return object.replace(paramPattern, (match, paramName) => {
const paramValue = props.match?.params?.[paramName];
return paramValue !== undefined ? paramValue : match;
}) as AuthZObject<R>;
}, [props.match?.params]);
return (
<GuardAuthZ
relation={relation}
object={resolvedObject}
fallbackOnLoading={<AppLoading />}
fallbackOnError={onErrorFallback}
fallbackOnNoPermissions={(response): ReactElement => (
<OnNoPermissionsFallback {...response} />
)}
>
<Component {...props} />
</GuardAuthZ>
);
};
}

View File

@@ -1,3 +0,0 @@
export { createGuardedRoute } from './createGuardedRoute';
export type { GuardAuthZProps } from './GuardAuthZ';
export { GuardAuthZ } from './GuardAuthZ';

View File

@@ -55,6 +55,7 @@ const ROUTES = {
LOGS_INDEX_FIELDS: '/logs-explorer/index-fields',
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',

View File

@@ -0,0 +1,325 @@
import { Widgets } from 'types/api/dashboard/getAll';
import {
MetricRangePayloadProps,
MetricRangePayloadV3,
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PanelMode } from '../../types';
import { prepareChartData, prepareUPlotConfig } from '../utils';
jest.mock(
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
() => ({
getStoredSeriesVisibility: jest.fn(),
}),
);
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ name: 'onClickPlugin' }),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
getLegend: jest.fn(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
),
}));
jest.mock('lib/getLabelName', () => ({
__esModule: true,
default: jest.fn(
(_metric: unknown, _queryName: string, _legend: string) => 'baseLabel',
),
}));
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
.getLegend as jest.Mock;
const getLabelNameMock = jest.requireMock('lib/getLabelName')
.default as jest.Mock;
const createApiResponse = (
result: MetricRangePayloadProps['data']['result'] = [],
): MetricRangePayloadProps => ({
data: {
result,
resultType: 'matrix',
newResult: (null as unknown) as MetricRangePayloadV3,
},
});
const createWidget = (overrides: Partial<Widgets> = {}): Widgets =>
({
id: 'widget-1',
yAxisUnit: 'ms',
isLogScale: false,
thresholds: [],
customLegendColors: {},
...overrides,
} as Widgets);
const defaultTimezone = {
name: 'UTC',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
};
describe('TimeSeriesPanel utils', () => {
beforeEach(() => {
jest.clearAllMocks();
getLabelNameMock.mockReturnValue('baseLabel');
getLegendMock.mockImplementation(
(_queryData: unknown, _query: unknown, labelName: string) =>
`legend-${labelName}`,
);
});
describe('prepareChartData', () => {
it('returns aligned data with timestamps and empty series when result is empty', () => {
const apiResponse = createApiResponse([]);
const data = prepareChartData(apiResponse);
expect(data).toHaveLength(1);
expect(data[0]).toEqual([]);
});
it('returns timestamps and one series of y values for single series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
legend: 'Series A',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const data = prepareChartData(apiResponse);
expect(data).toHaveLength(2);
expect(data[0]).toEqual([1000, 2000]);
expect(data[1]).toEqual([10, 20]);
});
it('merges timestamps and fills missing values with null for multiple series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[3000, '3'],
],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [
[1000, '10'],
[2000, '20'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const data = prepareChartData(apiResponse);
expect(data[0]).toEqual([1000, 2000, 3000]);
// First series: 1, null, 3
expect(data[1]).toEqual([1, null, 3]);
// Second series: 10, 20, null
expect(data[2]).toEqual([10, 20, null]);
});
});
describe('prepareUPlotConfig', () => {
const baseParams = {
widget: createWidget(),
isDarkMode: true,
currentQuery: {} as Query,
onClick: jest.fn(),
onDragSelect: jest.fn(),
apiResponse: createApiResponse(),
timezone: defaultTimezone,
panelMode: PanelMode.DASHBOARD_VIEW,
};
it('adds no series when apiResponse has empty result', () => {
const builder = prepareUPlotConfig(baseParams);
const config = builder.getConfig();
// Base series (timestamp) only
expect(config.series).toHaveLength(1);
});
it('adds one series per result item with label from getLabelName when no currentQuery', () => {
getLegendMock.mockReset();
const apiResponse = createApiResponse([
{
metric: { __name__: 'cpu' },
queryName: 'Q1',
legend: 'CPU',
values: [
[1000, '1'],
[2000, '2'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const builder = prepareUPlotConfig({
...baseParams,
apiResponse,
currentQuery: (null as unknown) as Query,
});
expect(getLabelNameMock).toHaveBeenCalled();
expect(getLegendMock).not.toHaveBeenCalled();
const config = builder.getConfig();
expect(config.series).toHaveLength(2);
expect(config.series?.[1]).toMatchObject({
label: 'baseLabel',
scale: 'y',
});
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
legend: 'L1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
prepareUPlotConfig({
...baseParams,
apiResponse,
currentQuery: {} as Query,
});
expect(getLegendMock).toHaveBeenCalledWith(
{
legend: 'L1',
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
},
{},
'baseLabel',
);
const config = prepareUPlotConfig({
...baseParams,
apiResponse,
currentQuery: {} as Query,
}).getConfig();
expect(config.series?.[1]).toMatchObject({
label: 'legend-baseLabel',
});
});
it('uses DrawStyle.Line and VisibilityMode.Never when series has multiple valid points', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
values: [
[1000, '1'],
[2000, '2'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const builder = prepareUPlotConfig({ ...baseParams, apiResponse });
const config = builder.getConfig();
const series = config.series?.[1];
expect(config.series).toHaveLength(2);
// Line style and points never for multi-point series (checked via builder API)
const legendItems = builder.getLegendItems();
expect(Object.keys(legendItems)).toHaveLength(1);
// multi-point series → points hidden
expect(series).toBeDefined();
expect(series!.points?.show).toBe(false);
});
it('uses DrawStyle.Points and shows points when series has only one valid point', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
values: [
[1000, '1'],
[2000, 'NaN'],
[3000, 'invalid'],
],
} as MetricRangePayloadProps['data']['result'][0],
]);
const builder = prepareUPlotConfig({ ...baseParams, apiResponse });
const config = builder.getConfig();
expect(config.series).toHaveLength(2);
const seriesConfig = config.series?.[1];
expect(seriesConfig).toBeDefined();
// Single valid point -> Points draw style (asserted via series config)
expect(seriesConfig).toMatchObject({
scale: 'y',
spanGaps: true,
});
// single-point series → points shown
expect(seriesConfig).toBeDefined();
expect(seriesConfig!.points?.show).toBe(true);
});
it('uses widget customLegendColors to set series stroke color', () => {
const widget = createWidget({
customLegendColors: { 'legend-baseLabel': '#ff0000' },
});
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const builder = prepareUPlotConfig({
...baseParams,
widget,
apiResponse,
});
const config = builder.getConfig();
const seriesConfig = config.series?.[1];
expect(seriesConfig).toBeDefined();
expect(seriesConfig!.stroke).toBe('#ff0000');
});
it('adds multiple series when result has multiple items', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [[1000, '1']],
} as MetricRangePayloadProps['data']['result'][0],
{
metric: {},
queryName: 'Q2',
values: [[1000, '2']],
} as MetricRangePayloadProps['data']['result'][0],
]);
const builder = prepareUPlotConfig({ ...baseParams, apiResponse });
const config = builder.getConfig();
expect(config.series).toHaveLength(3);
});
});
});

View File

@@ -15,10 +15,12 @@ import {
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { isInvalidPlotValue } from 'lib/uPlotV2/utils/dataUtils';
import get from 'lodash-es/get';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { PanelMode } from '../types';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
@@ -33,6 +35,22 @@ export const prepareChartData = (
return [timestampArr, ...yAxisValuesArr];
};
function hasSingleVisiblePointForSeries(series: QueryData): boolean {
const rawValues = series.values ?? [];
let validPointCount = 0;
for (const [, rawValue] of rawValues) {
if (!isInvalidPlotValue(rawValue)) {
validPointCount += 1;
if (validPointCount > 1) {
return false;
}
}
}
return true;
}
export const prepareUPlotConfig = ({
widget,
isDarkMode,
@@ -77,9 +95,8 @@ export const prepareUPlotConfig = ({
stepInterval: minStepInterval,
});
const seriesList = apiResponse.data?.result || [];
seriesList.forEach((series) => {
apiResponse.data?.result?.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
@@ -92,13 +109,15 @@ export const prepareUPlotConfig = ({
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Line,
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,
showPoints: hasSingleValidPoint
? VisibilityMode.Always
: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,

View File

@@ -51,28 +51,6 @@ function WidgetGraphContainer({
return <Spinner size="large" tip="Loading..." />;
}
if (
selectedGraph !== PANEL_TYPES.LIST &&
selectedGraph !== PANEL_TYPES.VALUE &&
queryResponse.data?.payload.data?.result?.length === 0
) {
return (
<NotFoundContainer>
<Typography>No Data</Typography>
</NotFoundContainer>
);
}
if (
(selectedGraph === PANEL_TYPES.LIST || selectedGraph === PANEL_TYPES.VALUE) &&
queryResponse.data?.payload?.data?.newResult?.data?.result?.length === 0
) {
return (
<NotFoundContainer>
<Typography>No Data</Typography>
</NotFoundContainer>
);
}
if (queryResponse.isIdle) {
return (
<NotFoundContainer>

View File

@@ -7,6 +7,12 @@
display: flex;
align-items: center;
justify-content: space-between;
.auth-domain-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
}
}
@@ -15,5 +21,36 @@
display: flex;
flex-direction: row;
gap: 24px;
.auth-domain-list-action-link {
cursor: pointer;
color: var(--primary);
transition: color 0.3s;
border: none;
background: none;
padding: 0;
font-size: inherit;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
&.delete {
color: var(--destructive);
}
}
}
.auth-domain-list-na {
padding-left: 6px;
color: var(--text-secondary);
}
}
.delete-ingestion-key-modal {
.delete-text {
color: var(--text);
margin: 0;
}
}

View File

@@ -1,28 +1,38 @@
import { useState } from 'react';
import { Button, Form, Modal } from 'antd';
import put from 'api/v1/domains/id/put';
import post from 'api/v1/domains/post';
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/button';
import { toast } from '@signozhq/sonner';
import { Form, Modal } from 'antd';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
useCreateAuthDomain,
useUpdateAuthDomain,
} from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { FeatureKeys } from 'constants/features';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
import AuthnProviderSelector from './AuthnProviderSelector';
import {
convertDomainMappingsToRecord,
convertGroupMappingsToRecord,
FormValues,
prepareInitialValues,
} from './CreateEdit.utils';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
import './CreateEdit.styles.scss';
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: GettableAuthDomain;
}
function configureAuthnProvider(
authnProvider: string,
isCreate: boolean,
@@ -39,64 +49,186 @@ function configureAuthnProvider(
}
}
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: AuthtypesGettableAuthDomainDTO;
}
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<PostableAuthDomain>();
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<string>(
record?.ssoType || '',
);
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
const handleError = useCallback(
(error: AxiosError<RenderErrorResponseDTO>): void => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
[showErrorModal],
);
const samlEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
const onSubmitHandler = async (): Promise<void> => {
const {
mutate: createAuthDomain,
isLoading: isCreating,
} = useCreateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const {
mutate: updateAuthDomain,
isLoading: isUpdating,
} = useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
/**
* Prepares Google Auth config for API payload
*/
const getGoogleAuthConfig = useCallback(():
| AuthtypesGoogleConfigDTO
| undefined => {
const config = form.getFieldValue('googleAuthConfig');
if (!config) {
return undefined;
}
const { domainToAdminEmailList, ...rest } = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(domainToAdminEmail && { domainToAdminEmail }),
};
}, [form]);
// Prepares role mapping for API payload
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
const roleMapping = form.getFieldValue('roleMapping');
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
// Only return roleMapping if there's meaningful content
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
...(groupMappings && { groupMappings }),
};
}, [form]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
} catch {
return;
}
const name = form.getFieldValue('name');
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const roleMapping = getRoleMapping();
try {
if (isCreate) {
await post({
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
if (isCreate) {
createAuthDomain(
{
data: {
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
},
});
} else {
await put({
id: record?.id || '',
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
{
onSuccess: () => {
toast.success('Domain created successfully');
onClose();
},
});
onError: handleError,
},
);
} else {
if (!record?.id) {
return;
}
onClose();
} catch (error) {
showErrorModal(error as APIError);
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
},
},
{
onSuccess: () => {
toast.success('Domain updated successfully');
onClose();
},
onError: handleError,
},
);
}
};
}, [
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getRoleMapping,
handleError,
isCreate,
const onBackHandler = (): void => {
onClose,
record,
updateAuthDomain,
]);
const onBackHandler = useCallback((): void => {
form.resetFields();
setAuthnProvider('');
};
}, [form]);
return (
<Modal open footer={null} onCancel={onClose}>
<Modal
open
footer={null}
onCancel={onClose}
width={authnProvider ? 980 : undefined}
>
<Form
name="auth-domain"
initialValues={defaultTo(record, {
initialValues={defaultTo(prepareInitialValues(record), {
name: '',
ssoEnabled: false,
ssoType: '',
@@ -114,9 +246,22 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
<div className="auth-domain-configure">
{configureAuthnProvider(authnProvider, isCreate)}
<section className="action-buttons">
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
<Button onClick={onSubmitHandler} type="primary">
{isCreate && (
<Button onClick={onBackHandler} variant="solid" color="secondary">
Back
</Button>
)}
{!isCreate && (
<Button onClick={onClose} variant="solid" color="secondary">
Cancel
</Button>
)}
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
</section>

View File

@@ -0,0 +1,134 @@
import {
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
AuthtypesRoleMappingDTO,
AuthtypesSamlConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
// Form values interface for internal use (includes array-based fields for UI)
export interface FormValues {
name?: string;
ssoEnabled?: boolean;
ssoType?: string;
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
};
samlConfig?: AuthtypesSamlConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO & {
groupMappingsList?: Array<{ groupName?: string; role?: string }>;
};
}
/**
* Converts groupMappingsList array to groupMappings Record for API
*/
export function convertGroupMappingsToRecord(
groupMappingsList?: Array<{ groupName?: string; role?: string }>,
): Record<string, string> | undefined {
if (!Array.isArray(groupMappingsList) || groupMappingsList.length === 0) {
return undefined;
}
const groupMappings: Record<string, string> = {};
groupMappingsList.forEach((item) => {
if (item.groupName && item.role) {
groupMappings[item.groupName] = item.role;
}
});
return Object.keys(groupMappings).length > 0 ? groupMappings : undefined;
}
/**
* Converts groupMappings Record to groupMappingsList array for form
*/
export function convertGroupMappingsToList(
groupMappings?: Record<string, string> | null,
): Array<{ groupName: string; role: string }> {
if (!groupMappings) {
return [];
}
return Object.entries(groupMappings).map(([groupName, role]) => ({
groupName,
role,
}));
}
/**
* Converts domainToAdminEmailList array to domainToAdminEmail Record for API
*/
export function convertDomainMappingsToRecord(
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>,
): Record<string, string> | undefined {
if (
!Array.isArray(domainToAdminEmailList) ||
domainToAdminEmailList.length === 0
) {
return undefined;
}
const domainToAdminEmail: Record<string, string> = {};
domainToAdminEmailList.forEach((item) => {
if (item.domain && item.adminEmail) {
domainToAdminEmail[item.domain] = item.adminEmail;
}
});
return Object.keys(domainToAdminEmail).length > 0
? domainToAdminEmail
: undefined;
}
/**
* Converts domainToAdminEmail Record to domainToAdminEmailList array for form
*/
export function convertDomainMappingsToList(
domainToAdminEmail?: Record<string, string>,
): Array<{ domain: string; adminEmail: string }> {
if (!domainToAdminEmail) {
return [];
}
return Object.entries(domainToAdminEmail).map(([domain, adminEmail]) => ({
domain,
adminEmail,
}));
}
/**
* Prepares initial form values from API record
*/
export function prepareInitialValues(
record?: AuthtypesGettableAuthDomainDTO,
): FormValues {
if (!record) {
return {
name: '',
ssoEnabled: false,
ssoType: '',
};
}
return {
...record,
googleAuthConfig: record.googleAuthConfig
? {
...record.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
record.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
? {
...record.roleMapping,
groupMappingsList: convertGroupMappingsToList(
record.roleMapping.groupMappings,
),
}
: undefined,
};
}

View File

@@ -1,20 +1,67 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Form, Input, Typography } from 'antd';
import { Checkbox } from '@signozhq/checkbox';
import { Color, Style } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import DomainMappingList from './components/DomainMappingList';
import EmailTagInput from './components/EmailTagInput';
import RoleMappingSection from './components/RoleMappingSection';
import './Providers.styles.scss';
type ExpandedSection = 'workspace-groups' | 'role-mapping' | null;
function ConfigureGoogleAuthAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const fetchGroups = Form.useWatch(['googleAuthConfig', 'fetchGroups'], form);
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleWorkspaceGroupsChange = useCallback(
(keys: string | string[]): void => {
const isExpanding = Array.isArray(keys) ? keys.length > 0 : !!keys;
setExpandedSection(isExpanding ? 'workspace-groups' : null);
},
[],
);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
const {
hasErrors: hasWorkspaceGroupsErrors,
errorMessages: workspaceGroupsErrorMessages,
} = useCollapseSectionErrors(
['googleAuthConfig'],
[
['googleAuthConfig', 'fetchGroups'],
['googleAuthConfig', 'serviceAccountJson'],
['googleAuthConfig', 'domainToAdminEmailList'],
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
['googleAuthConfig', 'allowedGroups'],
],
);
return (
<div className="google-auth">
<section className="header">
<Typography.Text className="title">
Edit Google Authentication
</Typography.Text>
<Typography.Paragraph className="description">
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit Google Authentication</h3>
<p className="authn-provider__description">
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
@@ -25,50 +72,247 @@ function ConfigureGoogleAuthAuthnProvider({
docs
</a>{' '}
for more information.
</Typography.Paragraph>
</p>
</section>
<Form.Item
label="Domain"
name="name"
className="field"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__columns">
{/* Left Column - Core OAuth Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="google-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Client ID"
name={['googleAuthConfig', 'clientId']}
className="field"
tooltip={{
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-id">
Client ID
<Tooltip title="ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientId']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="google-client-id" />
</Form.Item>
</div>
<Form.Item
label="Client Secret"
name={['googleAuthConfig', 'clientSecret']}
className="field"
tooltip={{
title: `It is the application's secret.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-secret">
Client Secret
<Tooltip title="It is the application's secret.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientSecret']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'Client Secret is required',
whitespace: true,
},
]}
>
<Input id="google-client-secret" />
</Form.Item>
</div>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 wont be enabled unless you enter all the attributes above"
className="callout"
/>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-skip-email-verification"
labelName="Skip Email Verification"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'insecureSkipEmailVerified'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
{/* Right Column - Google Workspace Groups (Advanced) */}
<div className="authn-provider__right">
<Collapse
bordered={false}
activeKey={
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
}
onChange={handleWorkspaceGroupsChange}
className="authn-provider__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="workspace-groups"
header={
<div className="authn-provider__collapse-header">
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
<ChevronDown size={16} />
)}
<div className="authn-provider__collapse-header-text">
<h4 className="authn-provider__section-title">
Google Workspace Groups (Advanced)
</h4>
<p className="authn-provider__section-description">
Enable group fetching to retrieve user groups from Google Workspace.
Requires a Service Account with domain-wide delegation.
</p>
</div>
{expandedSection !== 'workspace-groups' && hasWorkspaceGroupsErrors && (
<Tooltip
title={
<div>
{workspaceGroupsErrorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div className="authn-provider__group-content">
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'fetchGroups']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-fetch-groups"
labelName="Fetch Groups"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
}}
/>
</Form.Item>
<Tooltip title="Enable fetching Google Workspace groups for the user. Requires service account configuration.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
{fetchGroups && (
<div className="authn-provider__group-fields">
<div className="authn-provider__field-group">
<label
className="authn-provider__label"
htmlFor="google-service-account-json"
>
Service Account JSON
<Tooltip title="The JSON content of the Google Service Account credentials file. Required for group fetching.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'serviceAccountJson']}
className="authn-provider__form-item"
>
<TextArea
id="google-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<DomainMappingList
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
/>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'fetchTransitiveGroupMembership']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-transitive-membership"
labelName="Fetch Transitive Group Membership"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title="If enabled, recursively fetch groups that contain other groups (transitive membership).">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
<div className="authn-provider__field-group">
<label
className="authn-provider__label"
htmlFor="google-allowed-groups"
>
Allowed Groups
<Tooltip title="Optional list of allowed groups. If configured, only users belonging to one of these groups will be allowed to login.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__form-item"
>
<EmailTagInput placeholder="Type a group email and press Enter" />
</Form.Item>
</div>
</div>
)}
</div>
</Collapse.Panel>
</Collapse>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,110 +1,212 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox, Form, Input, Typography } from 'antd';
import { Checkbox } from '@signozhq/checkbox';
import { Style } from '@signozhq/design-tokens';
import { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import ClaimMappingSection from './components/ClaimMappingSection';
import RoleMappingSection from './components/RoleMappingSection';
import './Providers.styles.scss';
type ExpandedSection = 'claim-mapping' | 'role-mapping' | null;
function ConfigureOIDCAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleClaimMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'claim-mapping' : null);
}, []);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
return (
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit OIDC Authentication
</Typography.Text>
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit OIDC Authentication</h3>
<p className="authn-provider__description">
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</p>
</section>
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__columns">
{/* Left Column - Core OIDC Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="oidc-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Issuer URL"
name={['oidcConfig', 'issuer']}
tooltip={{
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-issuer">
Issuer URL
<Tooltip title='The URL identifier for the OIDC provider. For example: "https://accounts.google.com" or "https://login.salesforce.com".'>
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuer']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Issuer URL is required', whitespace: true },
]}
>
<Input id="oidc-issuer" />
</Form.Item>
</div>
<Form.Item
label="Issuer Alias"
name={['oidcConfig', 'issuerAlias']}
tooltip={{
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-issuer-alias">
Issuer Alias
<Tooltip title="Optional: Override the issuer URL from .well-known/openid-configuration for providers like Azure or Oracle IDCS.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuerAlias']}
className="authn-provider__form-item"
>
<Input id="oidc-issuer-alias" />
</Form.Item>
</div>
<Form.Item
label="Client ID"
name={['oidcConfig', 'clientId']}
tooltip={{ title: `It is the application's ID.` }}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-id">
Client ID
<Tooltip title="The application's client ID from your OIDC provider.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientId']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="oidc-client-id" />
</Form.Item>
</div>
<Form.Item
label="Client Secret"
name={['oidcConfig', 'clientSecret']}
tooltip={{ title: `It is the application's secret.` }}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-secret">
Client Secret
<Tooltip title="The application's client secret from your OIDC provider.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientSecret']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'Client Secret is required',
whitespace: true,
},
]}
>
<Input id="oidc-client-secret" />
</Form.Item>
</div>
<Form.Item
label="Email Claim Mapping"
name={['oidcConfig', 'claimMapping', 'email']}
tooltip={{
title: `Mapping of email claims to the corresponding email field in the token.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
noStyle
>
<Checkbox
id="oidc-skip-email-verification"
labelName="Skip Email Verification"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['oidcConfig', 'insecureSkipEmailVerified'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
<Form.Item
label="Skip Email Verification"
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip email verification. Defaults to "false"`,
}}
>
<Checkbox />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
noStyle
>
<Checkbox
id="oidc-get-user-info"
labelName="Get User Info"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(['oidcConfig', 'getUserInfo'], checked);
}}
/>
</Form.Item>
<Tooltip title="Use the userinfo endpoint to get additional claims. Useful when providers return thin ID tokens.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
<Form.Item
label="Get User Info"
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
className="field"
tooltip={{
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="OIDC won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
<Callout
type="warning"
size="small"
showIcon
description="OIDC wont be enabled unless you enter all the attributes above"
className="callout"
/>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<ClaimMappingSection
fieldNamePrefix={['oidcConfig', 'claimMapping']}
isExpanded={expandedSection === 'claim-mapping'}
onExpandChange={handleClaimMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,82 +1,191 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox, Form, Input, Typography } from 'antd';
import { Checkbox } from '@signozhq/checkbox';
import { Style } from '@signozhq/design-tokens';
import { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import AttributeMappingSection from './components/AttributeMappingSection';
import RoleMappingSection from './components/RoleMappingSection';
import './Providers.styles.scss';
type ExpandedSection = 'attribute-mapping' | 'role-mapping' | null;
function ConfigureSAMLAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleAttributeMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'attribute-mapping' : null);
}, []);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
return (
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit SAML Authentication
</Typography.Text>
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit SAML Authentication</h3>
<p className="authn-provider__description">
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</p>
</section>
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__columns">
{/* Left Column - Core SAML Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="saml-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="SAML ACS URL"
name={['samlConfig', 'samlIdp']}
tooltip={{
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-acs-url">
SAML ACS URL
<Tooltip title="The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlIdp']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML ACS URL is required',
whitespace: true,
},
]}
>
<Input id="saml-acs-url" />
</Form.Item>
</div>
<Form.Item
label="SAML Entity ID"
name={['samlConfig', 'samlEntity']}
tooltip={{
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-entity-id">
SAML Entity ID
<Tooltip title="The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlEntity']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Entity ID is required',
whitespace: true,
},
]}
>
<Input id="saml-entity-id" />
</Form.Item>
</div>
<Form.Item
label="SAML X.509 Certificate"
name={['samlConfig', 'samlCert']}
tooltip={{
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
}}
>
<Input.TextArea rows={4} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-certificate">
SAML X.509 Certificate
<Tooltip title="The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlCert']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Certificate is required',
whitespace: true,
},
]}
>
<TextArea
id="saml-certificate"
rows={3}
placeholder="Paste X.509 certificate"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<Form.Item
label="Skip Signing AuthN Requests"
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
}}
>
<Checkbox />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
noStyle
>
<Checkbox
id="saml-skip-signing"
labelName="Skip Signing AuthN Requests"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['samlConfig', 'insecureSkipAuthNRequestsSigned'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title="Whether to skip signing the SAML requests. For providers like JumpCloud, this should be enabled.">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
<Callout
type="warning"
size="small"
showIcon
description="SAML wont be enabled unless you enter all the attributes above"
className="callout"
/>
<Callout
type="warning"
size="small"
showIcon
description="SAML won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<AttributeMappingSection
fieldNamePrefix={['samlConfig', 'attributeMapping']}
isExpanded={expandedSection === 'attribute-mapping'}
onExpandChange={handleAttributeMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,24 +1,240 @@
.google-auth {
.authn-provider {
display: flex;
flex-direction: column;
.ant-form-item {
margin-bottom: 12px !important;
&__header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
}
.header {
&__title {
margin: 0;
color: var(--l1-foreground);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
&__description {
margin: 0;
color: var(--l2-foreground);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
a {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__columns {
display: grid;
grid-template-columns: 0.9fr 1fr;
gap: 24px;
}
&__left {
display: flex;
flex-direction: column;
}
&__right {
border-left: 1px solid var(--l3-border);
padding-left: 24px;
display: flex;
flex-direction: column;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.title {
font-weight: bold;
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__form-item {
margin-bottom: 0 !important;
}
&__checkbox-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
input,
textarea {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
.description {
margin-bottom: 0px !important;
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--primary) !important;
box-shadow: none !important;
outline: none;
}
}
textarea {
height: auto;
}
&__textarea {
min-height: 60px !important;
max-height: 200px;
resize: vertical;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
font-family: 'SF Mono', monospace;
font-size: 12px;
line-height: 18px;
&::placeholder {
color: var(--l3-foreground) !important;
font-family: Inter, sans-serif;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--primary) !important;
box-shadow: none !important;
outline: none;
}
}
button[role='checkbox'] {
border: 1px solid var(--l2-foreground) !important;
border-radius: 2px;
&[data-state='checked'] {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
}
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__group-content {
display: flex;
flex-direction: column;
gap: 12px;
}
&__group-fields {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
.authn-provider__field-group,
.authn-provider__checkbox-row {
margin-bottom: 0;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
.callout {

View File

@@ -0,0 +1,128 @@
.attribute-mapping-section {
display: flex;
flex-direction: column;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__form-item {
margin-bottom: 0 !important;
}
// todo: https://github.com/SigNoz/components/issues/116
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--primary) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -0,0 +1,175 @@
import { useCallback, useState } from 'react';
import { Color, Style } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './AttributeMappingSection.styles.scss';
interface AttributeMappingSectionProps {
fieldNamePrefix: string[];
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
function AttributeMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: AttributeMappingSectionProps): JSX.Element {
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['attribute-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="attribute-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="attribute-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="attribute-mapping"
header={
<div
className="attribute-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="attribute-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="attribute-mapping-section__collapse-header-text">
<h4 className="attribute-mapping-section__section-title">
Attribute Mapping (Advanced)
</h4>
<p className="attribute-mapping-section__section-description">
Configure how SAML assertion attributes from your Identity Provider map
to SigNoz user attributes. Leave empty to use default values.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div
id="attribute-mapping-content"
className="attribute-mapping-section__content"
>
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="email-attribute"
>
Email Attribute
<Tooltip title="The SAML attribute key that contains the user's email. Default: 'email'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="attribute-mapping-section__form-item"
>
<Input id="email-attribute" placeholder="Email" />
</Form.Item>
</div>
{/* Name Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="name-attribute"
>
Name Attribute
<Tooltip title="The SAML attribute key that contains the user's display name. Default: 'name'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'name']}
className="attribute-mapping-section__form-item"
>
<Input id="name-attribute" placeholder="Name" />
</Form.Item>
</div>
{/* Groups Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="groups-attribute"
>
Groups Attribute
<Tooltip title="The SAML attribute key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'groups']}
className="attribute-mapping-section__form-item"
>
<Input id="groups-attribute" placeholder="Groups" />
</Form.Item>
</div>
{/* Role Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="role-attribute"
>
Role Attribute
<Tooltip title="The SAML attribute key that contains the user's role directly from the IDP. Default: 'role'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'role']}
className="attribute-mapping-section__form-item"
>
<Input id="role-attribute" placeholder="Role" />
</Form.Item>
</div>
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default AttributeMappingSection;

View File

@@ -0,0 +1,128 @@
.claim-mapping-section {
display: flex;
flex-direction: column;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__form-item {
margin-bottom: 0 !important;
}
// todo: https://github.com/SigNoz/components/issues/116
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--primary) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useState } from 'react';
import { Color, Style } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './ClaimMappingSection.styles.scss';
interface ClaimMappingSectionProps {
fieldNamePrefix: string[];
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
function ClaimMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: ClaimMappingSectionProps): JSX.Element {
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['claim-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="claim-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="claim-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="claim-mapping"
header={
<div
className="claim-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="claim-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="claim-mapping-section__collapse-header-text">
<h4 className="claim-mapping-section__section-title">
Claim Mapping (Advanced)
</h4>
<p className="claim-mapping-section__section-description">
Configure how claims from your Identity Provider map to SigNoz user
attributes. Leave empty to use default values.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="claim-mapping-content" className="claim-mapping-section__content">
{/* Email Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="email-claim">
Email Claim
<Tooltip title="The claim key that contains the user's email address. Default: 'email'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="claim-mapping-section__form-item"
>
<Input id="email-claim" placeholder="Email" />
</Form.Item>
</div>
{/* Name Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="name-claim">
Name Claim
<Tooltip title="The claim key that contains the user's display name. Default: 'name'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'name']}
className="claim-mapping-section__form-item"
>
<Input id="name-claim" placeholder="Name" />
</Form.Item>
</div>
{/* Groups Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="groups-claim">
Groups Claim
<Tooltip title="The claim key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'groups']}
className="claim-mapping-section__form-item"
>
<Input id="groups-claim" placeholder="Groups" />
</Form.Item>
</div>
{/* Role Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="role-claim">
Role Claim
<Tooltip title="The claim key that contains the user's role directly from the IDP. Default: 'role'">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'role']}
className="claim-mapping-section__form-item"
>
<Input id="role-claim" placeholder="Role" />
</Form.Item>
</div>
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default ClaimMappingSection;

View File

@@ -0,0 +1,103 @@
.domain-mapping-list {
display: flex;
flex-direction: column;
gap: 8px;
&__header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
&__title {
margin: 0;
color: var(--l1-foreground);
}
&__description {
margin: 0;
color: var(--l3-foreground);
}
&__items {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
align-items: flex-start;
gap: 8px;
.ant-form-item {
margin-bottom: 0;
}
}
&__field {
flex: 1;
}
&__remove-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--destructive) !important;
opacity: 0.6 !important;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--destructive) !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: var(--destructive) !important;
svg {
color: var(--destructive) !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
}

View File

@@ -0,0 +1,87 @@
import { Button } from '@signozhq/button';
import { Plus, Trash2 } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form } from 'antd';
import './DomainMappingList.styles.scss';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const validateEmail = (_: unknown, value: string): Promise<void> => {
if (!value) {
return Promise.reject(new Error('Admin email is required'));
}
if (!EMAIL_REGEX.test(value)) {
return Promise.reject(new Error('Please enter a valid email'));
}
return Promise.resolve();
};
interface DomainMappingListProps {
fieldNamePrefix: string[];
}
function DomainMappingList({
fieldNamePrefix,
}: DomainMappingListProps): JSX.Element {
return (
<div className="domain-mapping-list">
<div className="domain-mapping-list__header">
<span className="domain-mapping-list__title">
Domain to Admin Email Mapping
</span>
<p className="domain-mapping-list__description">
Map workspace domains to admin emails for service account impersonation.
Use &quot;*&quot; as a wildcard for any domain.
</p>
</div>
<Form.List name={fieldNamePrefix}>
{(fields, { add, remove }): JSX.Element => (
<div className="domain-mapping-list__items">
{fields.map((field) => (
<div key={field.key} className="domain-mapping-list__row">
<Form.Item
name={[field.name, 'domain']}
className="domain-mapping-list__field"
rules={[{ required: true, message: 'Domain is required' }]}
>
<Input placeholder="Domain (e.g., example.com or *)" />
</Form.Item>
<Form.Item
name={[field.name, 'adminEmail']}
className="domain-mapping-list__field"
rules={[{ validator: validateEmail }]}
>
<Input placeholder="Admin Email" />
</Form.Item>
<Button
variant="ghost"
color="secondary"
className="domain-mapping-list__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
>
<Trash2 size={12} />
</Button>
</div>
))}
<Button
variant="dashed"
onClick={(): void => add({ domain: '', adminEmail: '' })}
prefixIcon={<Plus size={14} />}
className="domain-mapping-list__add-btn"
>
Add Domain Mapping
</Button>
</div>
)}
</Form.List>
</div>
);
}
export default DomainMappingList;

View File

@@ -0,0 +1,28 @@
.email-tag-input {
display: flex;
flex-direction: column;
gap: 4px;
&__select {
width: 100%;
.ant-select-selector {
.ant-select-selection-search {
input {
height: 32px !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
font-family: inherit !important;
}
}
}
}
&__error {
margin: 0;
color: var(--destructive);
}
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useState } from 'react';
import { Select, Tooltip } from 'antd';
import './EmailTagInput.styles.scss';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface EmailTagInputProps {
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
}
function EmailTagInput({
value = [],
onChange,
placeholder = 'Type an email and press Enter',
}: EmailTagInputProps): JSX.Element {
const [validationError, setValidationError] = useState('');
const handleChange = useCallback(
(newValues: string[]): void => {
const addedValues = newValues.filter((v) => !value.includes(v));
const invalidEmail = addedValues.find((v) => !EMAIL_REGEX.test(v));
if (invalidEmail) {
setValidationError(`"${invalidEmail}" is not a valid email`);
return;
}
setValidationError('');
onChange?.(newValues);
},
[onChange, value],
);
return (
<div className="email-tag-input">
<Tooltip
title={validationError}
open={!!validationError}
placement="topRight"
>
<Select
mode="tags"
value={value}
onChange={handleChange}
placeholder={placeholder}
tokenSeparators={[',', ' ']}
className="email-tag-input__select"
allowClear
status={validationError ? 'error' : undefined}
/>
</Tooltip>
</div>
);
}
export default EmailTagInput;

View File

@@ -0,0 +1,288 @@
.role-mapping-section {
display: flex;
flex-direction: column;
margin-top: 24px;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__form-item {
margin-bottom: 0 !important;
}
&__checkbox-row {
display: flex;
align-items: center;
gap: 6px;
}
&__select {
width: 100%;
&.ant-select {
.ant-select-selector {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
.ant-select-selection-item {
color: var(--l1-foreground) !important;
}
}
&:hover .ant-select-selector {
border-color: var(--l3-border) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--primary) !important;
box-shadow: none !important;
}
.ant-select-arrow {
color: var(--l3-foreground);
}
}
}
&__group-mappings {
display: flex;
flex-direction: column;
gap: 8px;
}
&__group-header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
&__group-title {
margin: 0;
color: var(--l1-foreground);
}
&__group-description {
margin: 0;
color: var(--l3-foreground);
}
&__items {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
align-items: flex-start;
gap: 8px;
.ant-form-item {
margin-bottom: 0;
}
}
&__field {
&--group {
flex: 2;
}
&--role {
flex: 1;
min-width: 120px;
}
}
&__remove-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--destructive) !important;
opacity: 0.6 !important;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--destructive) !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: var(--destructive) !important;
svg {
color: var(--destructive) !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
// --- Checkbox border visibility ---
button[role='checkbox'] {
border: 1px solid var(--l2-foreground) !important;
border-radius: 2px;
&[data-state='checked'] {
background-color: var(--primary) !important;
border-color: var(--primary) !important;
}
}
// todo: https://github.com/SigNoz/components/issues/116
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--primary) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -0,0 +1,216 @@
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/button';
import { Checkbox } from '@signozhq/checkbox';
import { Color, Style } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
Plus,
Trash2,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './RoleMappingSection.styles.scss';
const ROLE_OPTIONS = [
{ value: 'VIEWER', label: 'VIEWER' },
{ value: 'EDITOR', label: 'EDITOR' },
{ value: 'ADMIN', label: 'ADMIN' },
];
interface RoleMappingSectionProps {
fieldNamePrefix: string[];
isExpanded?: boolean;
onExpandChange?: (expanded: boolean) => void;
}
function RoleMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: RoleMappingSectionProps): JSX.Element {
const form = Form.useFormInstance();
const useRoleAttribute = Form.useWatch(
[...fieldNamePrefix, 'useRoleAttribute'],
form,
);
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['role-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="role-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="role-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="role-mapping"
header={
<div
className="role-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="role-mapping-section__collapse-header-text">
<h4 className="role-mapping-section__section-title">
Role Mapping (Advanced)
</h4>
<p className="role-mapping-section__section-description">
Configure how user roles are determined from your Identity Provider.
You can either use a direct role attribute or map IDP groups to SigNoz
roles.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="role-mapping-content" className="role-mapping-section__content">
<div className="role-mapping-section__field-group">
<label className="role-mapping-section__label" htmlFor="default-role">
Default Role
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'defaultRole']}
className="role-mapping-section__form-item"
initialValue="VIEWER"
>
<Select
id="default-role"
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
</div>
<div className="role-mapping-section__checkbox-row">
<Form.Item
name={[...fieldNamePrefix, 'useRoleAttribute']}
valuePropName="checked"
noStyle
>
<Checkbox
id="use-role-attribute"
labelName="Use Role Attribute Directly"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
}}
/>
</Form.Item>
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
</Tooltip>
</div>
{!useRoleAttribute && (
<div className="role-mapping-section__group-mappings">
<div className="role-mapping-section__group-header">
<span className="role-mapping-section__group-title">
Group to Role Mappings
</span>
<p className="role-mapping-section__group-description">
Map IDP group names to SigNoz roles. If a user belongs to multiple
groups, the highest privilege role will be assigned.
</p>
</div>
<Form.List name={[...fieldNamePrefix, 'groupMappingsList']}>
{(fields, { add, remove }): JSX.Element => (
<div className="role-mapping-section__items">
{fields.map((field) => (
<div key={field.key} className="role-mapping-section__row">
<Form.Item
name={[field.name, 'groupName']}
className="role-mapping-section__field role-mapping-section__field--group"
rules={[{ required: true, message: 'Group name is required' }]}
>
<Input placeholder="IDP Group Name" />
</Form.Item>
<Form.Item
name={[field.name, 'role']}
className="role-mapping-section__field role-mapping-section__field--role"
rules={[{ required: true, message: 'Role is required' }]}
initialValue="VIEWER"
>
<Select
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
<Button
variant="ghost"
color="secondary"
className="role-mapping-section__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
>
<Trash2 size={12} />
</Button>
</div>
))}
<Button
variant="dashed"
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
prefixIcon={<Plus size={14} />}
className="role-mapping-section__add-btn"
>
Add Group Mapping
</Button>
</div>
)}
</Form.List>
</div>
)}
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default RoleMappingSection;

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/switch';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
interface SSOEnforcementToggleProps {
isDefaultChecked: boolean;
record: AuthtypesGettableAuthDomainDTO;
}
function SSOEnforcementToggle({
isDefaultChecked,
record,
}: SSOEnforcementToggleProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const { showErrorModal } = useErrorModal();
useEffect(() => {
setIsChecked(isDefaultChecked);
}, [isDefaultChecked]);
const { mutate: updateAuthDomain, isLoading } = useUpdateAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id) {
return;
}
setIsChecked(checked);
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
},
},
},
{
onError: (error) => {
setIsChecked(!checked);
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
};
return (
<Switch
disabled={isLoading}
checked={isChecked}
onCheckedChange={onChangeHandler}
/>
);
}
export default SSOEnforcementToggle;

View File

@@ -1,45 +0,0 @@
import { useState } from 'react';
import { Switch } from 'antd';
import put from 'api/v1/domains/id/put';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
const onChangeHandler = async (checked: boolean): Promise<void> => {
setIsLoading(true);
try {
await put({
id: record.id,
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
},
});
setIsChecked(checked);
} catch (error) {
showErrorModal(error as APIError);
}
setIsLoading(false);
};
return (
<Switch loading={isLoading} checked={isChecked} onChange={onChangeHandler} />
);
}
interface ToggleProps {
isDefaultChecked: boolean;
record: GettableAuthDomain;
}
export default Toggle;

View File

@@ -0,0 +1,138 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AuthDomain from '../index';
import {
AUTH_DOMAINS_LIST_ENDPOINT,
mockDomainsListResponse,
mockEmptyDomainsResponse,
mockErrorResponse,
} from './mocks';
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
describe('AuthDomain', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
describe('List View', () => {
it('renders page header and add button', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
expect(screen.getByText(/authenticated domains/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add domain/i }),
).toBeInTheDocument();
});
it('renders list of auth domains successfully', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
expect(screen.getByText('corp.io')).toBeInTheDocument();
});
});
it('renders empty state when no domains exist', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
});
it('displays error content when API fails', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json(mockErrorResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(
screen.getByText(/failed to perform operation/i),
).toBeInTheDocument();
});
});
});
describe('Add Domain', () => {
it('opens create modal when Add Domain button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
const addButton = await screen.findByRole('button', { name: /add domain/i });
await user.click(addButton);
await waitFor(() => {
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
});
});
});
describe('Configure Domain', () => {
it('opens edit modal when configure action is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
});
const configureLinks = await screen.findAllByText(/configure google auth/i);
await user.click(configureLinks[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,354 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
mockDomainWithRoleMapping,
mockGoogleAuthDomain,
mockGoogleAuthWithWorkspaceGroups,
mockOidcAuthDomain,
mockOidcWithClaimMapping,
mockSamlAuthDomain,
mockSamlWithAttributeMapping,
} from './mocks';
const mockOnClose = jest.fn();
describe('CreateEdit Modal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Provider Selection (Create Mode)', () => {
it('renders provider selection when creating new domain', () => {
render(<CreateEdit isCreate onClose={mockOnClose} />);
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
expect(screen.getByText(/google apps authentication/i)).toBeInTheDocument();
expect(screen.getByText(/saml authentication/i)).toBeInTheDocument();
expect(screen.getByText(/oidc authentication/i)).toBeInTheDocument();
});
it('returns to provider selection when back button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
});
const backButton = screen.getByRole('button', { name: /back/i });
await user.click(backButton);
await waitFor(() => {
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
});
});
});
describe('Edit Mode', () => {
it('shows provider form directly when editing existing domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
expect(
screen.queryByText(/configure authentication method/i),
).not.toBeInTheDocument();
});
it('pre-fills form with existing domain values', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByDisplayValue('signoz.io')).toBeInTheDocument();
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument();
});
it('disables domain field when editing', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
const domainInput = screen.getByDisplayValue('signoz.io');
expect(domainInput).toBeDisabled();
});
it('shows cancel button instead of back when editing', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /back/i }),
).not.toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('shows validation error when submitting without required fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
const saveButton = await screen.findByRole('button', {
name: /save changes/i,
});
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/domain is required/i)).toBeInTheDocument();
});
});
});
describe('Google Auth Provider', () => {
it('shows Google Auth form fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
expect(screen.getByLabelText(/domain/i)).toBeInTheDocument();
expect(screen.getByLabelText(/client id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/client secret/i)).toBeInTheDocument();
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
});
});
it('shows workspace groups section when expanded', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthWithWorkspaceGroups}
onClose={mockOnClose}
/>,
);
const workspaceHeader = screen.getByText(/google workspace groups/i);
await user.click(workspaceHeader);
await waitFor(() => {
expect(screen.getByText(/fetch groups/i)).toBeInTheDocument();
expect(screen.getByText(/service account json/i)).toBeInTheDocument();
});
});
});
describe('SAML Provider', () => {
it('shows SAML-specific fields when editing SAML domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit saml authentication/i)).toBeInTheDocument();
expect(
screen.getByDisplayValue('https://idp.example.com/sso'),
).toBeInTheDocument();
expect(screen.getByDisplayValue('urn:example:idp')).toBeInTheDocument();
});
it('shows attribute mapping section for SAML', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockSamlWithAttributeMapping}
onClose={mockOnClose}
/>,
);
expect(
screen.getByText(/attribute mapping \(advanced\)/i),
).toBeInTheDocument();
const attributeHeader = screen.getByText(/attribute mapping \(advanced\)/i);
await user.click(attributeHeader);
await waitFor(() => {
expect(screen.getByLabelText(/name attribute/i)).toBeInTheDocument();
expect(screen.getByLabelText(/groups attribute/i)).toBeInTheDocument();
expect(screen.getByLabelText(/role attribute/i)).toBeInTheDocument();
});
});
});
describe('OIDC Provider', () => {
it('shows OIDC-specific fields when editing OIDC domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockOidcAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit oidc authentication/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('https://oidc.corp.io')).toBeInTheDocument();
expect(screen.getByDisplayValue('oidc-client-id')).toBeInTheDocument();
});
it('shows claim mapping section for OIDC', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockOidcWithClaimMapping}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/claim mapping \(advanced\)/i)).toBeInTheDocument();
const claimHeader = screen.getByText(/claim mapping \(advanced\)/i);
await user.click(claimHeader);
await waitFor(() => {
expect(screen.getByLabelText(/email claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/name claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/groups claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/role claim/i)).toBeInTheDocument();
});
});
it('shows OIDC options checkboxes', () => {
render(
<CreateEdit
isCreate={false}
record={mockOidcAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
expect(screen.getByText(/get user info/i)).toBeInTheDocument();
});
});
describe('Role Mapping', () => {
it('shows role mapping section in provider forms', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/role mapping \(advanced\)/i)).toBeInTheDocument();
});
});
it('expands role mapping section to show default role selector', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={mockOnClose}
/>,
);
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
await user.click(roleMappingHeader);
await waitFor(() => {
expect(screen.getByText(/default role/i)).toBeInTheDocument();
expect(
screen.getByText(/use role attribute directly/i),
).toBeInTheDocument();
});
});
it('shows group mappings section when useRoleAttribute is false', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={mockOnClose}
/>,
);
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
await user.click(roleMappingHeader);
await waitFor(() => {
expect(screen.getByText(/group to role mappings/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add group mapping/i }),
).toBeInTheDocument();
});
});
});
describe('Modal Actions', () => {
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,130 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import SSOEnforcementToggle from '../SSOEnforcementToggle';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockErrorResponse,
mockGoogleAuthDomain,
mockUpdateSuccessResponse,
} from './mocks';
describe('SSOEnforcementToggle', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
it('renders switch with correct initial state', () => {
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={mockGoogleAuthDomain}
/>,
);
const switchElement = screen.getByRole('switch');
expect(switchElement).toBeChecked();
});
it('renders unchecked switch when SSO is disabled', () => {
render(
<SSOEnforcementToggle
isDefaultChecked={false}
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
/>,
);
const switchElement = screen.getByRole('switch');
expect(switchElement).not.toBeChecked();
});
it('calls update API when toggle is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockUpdateAPI = jest.fn();
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
mockUpdateAPI(body);
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={mockGoogleAuthDomain}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
await waitFor(() => {
expect(switchElement).not.toBeChecked();
});
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
ssoEnabled: false,
}),
}),
);
});
it('shows error modal when update fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json(mockErrorResponse)),
),
);
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={mockGoogleAuthDomain}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
await waitFor(() => {
expect(screen.getByText(/failed to perform operation/i)).toBeInTheDocument();
});
});
it('does not call API when record has no id', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let apiCalled = false;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) => {
apiCalled = true;
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={{ ...mockGoogleAuthDomain, id: undefined }}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
// Wait a bit to ensure no API call was made
await new Promise((resolve) => setTimeout(resolve, 100));
expect(apiCalled).toBe(false);
});
});

View File

@@ -0,0 +1,220 @@
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
// Mock Auth Domain with Google Auth
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-1',
},
};
// Mock Auth Domain with SAML
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-2',
},
};
// Mock Auth Domain with OIDC
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-3',
},
};
// Mock Auth Domain with Role Mapping
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-4',
},
};
// Mock Auth Domain with useRoleAttribute enabled
export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO = {
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
},
};
// Mock OIDC domain with claim mapping
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-6',
},
};
// Mock SAML domain with attribute mapping
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-7',
},
};
// Mock Google Auth with workspace groups
export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO = {
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-8',
},
};
// Mock empty list response
export const mockEmptyDomainsResponse = {
status: 'success',
data: [],
};
// Mock list response with domains
export const mockDomainsListResponse = {
status: 'success',
data: [mockGoogleAuthDomain, mockSamlAuthDomain, mockOidcAuthDomain],
};
// Mock single domain list response
export const mockSingleDomainResponse = {
status: 'success',
data: [mockGoogleAuthDomain],
};
// Mock success responses
export const mockCreateSuccessResponse = {
status: 'success',
data: mockGoogleAuthDomain,
};
export const mockUpdateSuccessResponse = {
status: 'success',
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
};
export const mockDeleteSuccessResponse = {
status: 'success',
data: 'Domain deleted successfully',
};
// Mock error responses
export const mockErrorResponse = {
error: {
code: 'internal_error',
message: 'Failed to perform operation',
url: '',
},
};
export const mockValidationErrorResponse = {
error: {
code: 'invalid_input',
message: 'Domain name is required',
url: '',
},
};

View File

@@ -1,151 +1,214 @@
import { useState } from 'react';
import { useQuery } from 'react-query';
import { useCallback, useMemo, useState } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Table, Typography } from 'antd';
import { Button } from '@signozhq/button';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Modal } from 'antd';
import { Table } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import deleteDomain from 'api/v1/domains/id/delete';
import listAllDomain from 'api/v1/domains/list';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
useDeleteAuthDomain,
useListAuthDomains,
} from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { GettableAuthDomain, SSOType } from 'types/api/v1/domains/list';
import CreateEdit from './CreateEdit/CreateEdit';
import Toggle from './Toggle';
import SSOEnforcementToggle from './SSOEnforcementToggle';
import './AuthDomain.styles.scss';
import '../../IngestionSettings/IngestionSettings.styles.scss';
const columns: ColumnsType<GettableAuthDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <Typography.Text>{val}</Typography.Text>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (value: boolean, record: GettableAuthDomain): JSX.Element => (
<Toggle isDefaultChecked={value} record={record} />
),
},
{
title: 'IDP Initiated SSO URL',
dataIndex: 'relayState',
key: 'relayState',
width: 80,
render: (_, record: GettableAuthDomain): JSX.Element => {
const relayPath = record.authNProviderInfo.relayStatePath;
if (!relayPath) {
return (
<Typography.Text style={{ paddingLeft: '6px' }}>N/A</Typography.Text>
);
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: GettableAuthDomain): JSX.Element => (
<section className="auth-domain-list-column-action">
<Typography.Link data-column-action="configure">
Configure {SSOType.get(record.ssoType)}
</Typography.Link>
<Typography.Link type="danger" data-column-action="delete">
Delete
</Typography.Link>
</section>
),
},
];
async function deleteDomainById(
id: string,
showErrorModal: (error: APIError) => void,
refetchAuthDomainListResponse: () => void,
): Promise<void> {
try {
await deleteDomain(id);
refetchAuthDomainListResponse();
} catch (error) {
showErrorModal(error as APIError);
}
}
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
]);
function AuthDomain(): JSX.Element {
const [record, setRecord] = useState<GettableAuthDomain>();
const [record, setRecord] = useState<AuthtypesGettableAuthDomainDTO>();
const [addDomain, setAddDomain] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [
activeDomain,
setActiveDomain,
] = useState<AuthtypesGettableAuthDomainDTO | null>(null);
const { showErrorModal } = useErrorModal();
const {
data: authDomainListResponse,
isLoading: isLoadingAuthDomainListResponse,
isFetching: isFetchingAuthDomainListResponse,
error: errorFetchingAuthDomainListResponse,
refetch: refetchAuthDomainListResponse,
} = useQuery({
queryFn: listAllDomain,
queryKey: ['/api/v1/domains', 'list'],
enabled: true,
});
} = useListAuthDomains();
const { mutate: deleteAuthDomain, isLoading } = useDeleteAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const showDeleteModal = useCallback(
(domain: AuthtypesGettableAuthDomainDTO): void => {
setActiveDomain(domain);
setIsDeleteModalOpen(true);
},
[],
);
const hideDeleteModal = useCallback((): void => {
setIsDeleteModalOpen(false);
setActiveDomain(null);
}, []);
const handleDeleteDomain = useCallback((): void => {
if (!activeDomain?.id) {
return;
}
deleteAuthDomain(
{ pathParams: { id: activeDomain.id } },
{
onSuccess: () => {
toast.success('Domain deleted successfully');
refetchAuthDomainListResponse();
hideDeleteModal();
},
onError: (error) => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
}, [
activeDomain,
deleteAuthDomain,
hideDeleteModal,
refetchAuthDomainListResponse,
showErrorModal,
]);
const formattedError = useMemo(() => {
if (!errorFetchingAuthDomainListResponse) {
return null;
}
let errorResult: APIError | null = null;
try {
ErrorResponseHandlerV2(
errorFetchingAuthDomainListResponse as AxiosError<ErrorV2Resp>,
);
} catch (error) {
errorResult = error as APIError;
}
return errorResult;
}, [errorFetchingAuthDomainListResponse]);
const columns: ColumnsType<AuthtypesGettableAuthDomainDTO> = useMemo(
() => [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <span>{val}</span>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (
value: boolean,
record: AuthtypesGettableAuthDomainDTO,
): JSX.Element => (
<SSOEnforcementToggle isDefaultChecked={value} record={record} />
),
},
{
title: 'IDP Initiated SSO URL',
dataIndex: 'relayState',
key: 'relayState',
width: 80,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => {
const relayPath = record.authNProviderInfo?.relayStatePath;
if (!relayPath) {
return <span className="auth-domain-list-na">N/A</span>;
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
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.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</section>
),
},
],
[showDeleteModal],
);
return (
<div className="auth-domain">
<section className="auth-domain-header">
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Button
type="primary"
icon={<PlusOutlined />}
prefixIcon={<PlusOutlined />}
onClick={(): void => {
setAddDomain(true);
}}
className="button"
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
</section>
{(errorFetchingAuthDomainListResponse as APIError) && (
<ErrorContent error={errorFetchingAuthDomainListResponse as APIError} />
)}
{!(errorFetchingAuthDomainListResponse as APIError) && (
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={(record): any => ({
onClick: (
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
): void => {
const target = event.target as HTMLLinkElement;
const { columnAction } = target.dataset;
switch (columnAction) {
case 'configure':
setRecord(record);
break;
case 'delete':
deleteDomainById(
record.id,
showErrorModal,
refetchAuthDomainListResponse,
);
break;
default:
console.error('Unknown action:', columnAction);
}
},
})}
dataSource={authDomainListResponse?.data?.data}
onRow={undefined}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
rowKey="id"
/>
)}
{(addDomain || record) && (
@@ -159,6 +222,39 @@ function AuthDomain(): JSX.Element {
}}
/>
)}
<Modal
className="delete-ingestion-key-modal"
title={<span className="title">Delete Domain</span>}
open={isDeleteModalOpen}
closable
onCancel={hideDeleteModal}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={hideDeleteModal}
className="cancel-btn"
prefixIcon={<X size={16} />}
>
Cancel
</Button>,
<Button
key="submit"
prefixIcon={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>,
]}
>
<p className="delete-text">
Are you sure you want to delete the domain{' '}
<strong>{activeDomain?.name}</strong>? This action cannot be undone.
</p>
</Modal>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { FC } from 'react';
import Spinner from 'components/Spinner';
import { PanelTypeVsPanelWrapper } from './constants';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -32,6 +33,11 @@ function PanelWrapper({
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
if (queryResponse.isFetching || queryResponse.isLoading) {
return <Spinner height="100%" size="large" tip="Loading..." />;
}
return (
<Component
panelMode={panelMode}

View File

@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { toAPIError } from 'utils/errorUtils';
import '../RolesSettings.styles.scss';
const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: RoletypesRoleDTO };
interface RolesListingTableProps {
searchQuery: string;
}
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { data, isLoading, isError, error } = useListRoles();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const setCurrentPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
const roles = useMemo(() => data?.data?.data ?? [], [data]);
const formatTimestamp = (date?: Date | string): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const filteredRoles = useMemo(() => {
if (!searchQuery.trim()) {
return roles;
}
const query = searchQuery.toLowerCase();
return roles.filter(
(role) =>
role.name?.toLowerCase().includes(query) ||
role.description?.toLowerCase().includes(query),
);
}, [roles, searchQuery]);
const managedRoles = useMemo(
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'managed'),
[filteredRoles],
);
const customRoles = useMemo(
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'custom'),
[filteredRoles],
);
// Combine managed + custom into a flat display list for pagination
const displayList = useMemo((): DisplayItem[] => {
const result: DisplayItem[] = [];
if (managedRoles.length > 0) {
result.push({ type: 'section', label: 'Managed roles' });
managedRoles.forEach((role) => result.push({ type: 'role', role }));
}
if (customRoles.length > 0) {
result.push({
type: 'section',
label: 'Custom roles',
count: customRoles.length,
});
customRoles.forEach((role) => result.push({ type: 'role', role }));
}
return result;
}, [managedRoles, customRoles]);
const totalRoleCount = managedRoles.length + customRoles.length;
// Ensure current page is valid; if out of bounds, redirect to last available page
useEffect(() => {
if (isLoading || totalRoleCount === 0) {
return;
}
const maxPage = Math.ceil(totalRoleCount / PAGE_SIZE);
if (currentPage > maxPage) {
setCurrentPage(maxPage);
}
}, [isLoading, totalRoleCount, currentPage, setCurrentPage]);
// Paginate: count only role items, but include section headers contextually
const paginatedItems = useMemo((): DisplayItem[] => {
const startRole = (currentPage - 1) * PAGE_SIZE;
const endRole = startRole + PAGE_SIZE;
let roleIndex = 0;
let lastSection: DisplayItem | null = null;
const result: DisplayItem[] = [];
for (const item of displayList) {
if (item.type === 'section') {
lastSection = item;
} else {
if (roleIndex >= startRole && roleIndex < endRole) {
// Insert section header before first role in that section on this page
if (lastSection) {
result.push(lastSection);
lastSection = null;
}
result.push(item);
}
roleIndex++;
}
}
return result;
}, [displayList, currentPage]);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
{range[0]} &#8212; {range[1]}
</span>
<span className="total"> of {total}</span>
</>
);
if (isLoading) {
return (
<div className="roles-listing-table">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
);
}
if (isError) {
return (
<div className="roles-listing-table">
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching roles.',
)}
/>
</div>
);
}
if (filteredRoles.length === 0) {
return (
<div className="roles-listing-table">
<div className="roles-table-empty">
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
</div>
);
}
// todo: use table from periscope when its available for consumption
const renderRow = (role: RoletypesRoleDTO): JSX.Element => (
<div key={role.id} className="roles-table-row">
<div className="roles-table-cell roles-table-cell--name">
{role.name ?? '—'}
</div>
<div className="roles-table-cell roles-table-cell--description">
<LineClampedText
text={role.description ?? '—'}
tooltipProps={{ overlayClassName: 'roles-description-tooltip' }}
/>
</div>
<div className="roles-table-cell roles-table-cell--updated-at">
{formatTimestamp(role.updatedAt)}
</div>
<div className="roles-table-cell roles-table-cell--created-at">
{formatTimestamp(role.createdAt)}
</div>
</div>
);
return (
<div className="roles-listing-table">
<div className="roles-table-scroll-container">
<div className="roles-table-inner">
<div className="roles-table-header">
<div className="roles-table-header-cell roles-table-header-cell--name">
Name
</div>
<div className="roles-table-header-cell roles-table-header-cell--description">
Description
</div>
<div className="roles-table-header-cell roles-table-header-cell--updated-at">
Updated At
</div>
<div className="roles-table-header-cell roles-table-header-cell--created-at">
Created At
</div>
</div>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className="roles-table-section-header">
{item.label}
{item.count !== undefined && (
<span className="roles-table-section-header__count">{item.count}</span>
)}
</h3>
) : (
renderRow(item.role)
),
)}
</div>
</div>
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
showTotal={showPaginationItem}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className="roles-table-pagination"
/>
</div>
);
}
export default RolesListingTable;

View File

@@ -0,0 +1,238 @@
.roles-settings {
.roles-settings-header {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 16px;
.roles-settings-header-title {
color: var(--text-base-white);
font-family: Inter;
font-style: normal;
font-size: 18px;
font-weight: 500;
line-height: 28px;
letter-spacing: -0.09px;
}
.roles-settings-header-description {
color: var(--foreground);
font-family: Inter;
font-style: normal;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.roles-settings-content {
padding: 0 16px;
}
// todo: https://github.com/SigNoz/components/issues/116
.roles-search-wrapper {
input {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l3-border);
border-radius: 2px;
padding: 6px 6px 6px 8px;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
outline: none;
height: 32px;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--input);
}
}
}
}
.roles-description-tooltip {
max-height: none;
overflow-y: visible;
}
.roles-listing-table {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.roles-table-scroll-container {
overflow-x: auto;
}
.roles-table-inner {
min-width: 850px;
}
.roles-table-header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 24px;
}
.roles-table-header-cell {
font-family: Inter;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.44px;
color: var(--foreground);
line-height: 16px;
&--name {
flex: 0 0 180px;
}
&--description {
flex: 1;
min-width: 0;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
}
}
.roles-table-section-header {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: rgba(171, 189, 255, 0.04);
border-top: 1px solid var(--secondary);
border-bottom: 1px solid var(--secondary);
font-family: Inter;
font-size: 12px;
font-weight: 400;
color: var(--foreground);
line-height: 16px;
margin: 0;
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--l3-background);
font-size: 12px;
font-weight: 500;
color: var(--foreground);
line-height: 1;
}
}
.roles-table-row {
display: flex;
align-items: center;
padding: 8px 16px;
background: rgba(171, 189, 255, 0.02);
border-bottom: 1px solid var(--secondary);
gap: 24px;
}
.roles-table-cell {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
line-height: 20px;
&--name {
flex: 0 0 180px;
font-weight: 500;
}
&--description {
flex: 1;
min-width: 0;
overflow: hidden;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
}
.roles-table-empty {
padding: 24px 16px;
text-align: center;
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.roles-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
.ant-pagination-total-text {
margin-right: auto;
.numbers {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
}
.total {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
opacity: 0.5;
}
}
}
.lightMode {
.roles-settings {
.roles-settings-header {
.roles-settings-header-title {
color: var(--text-base-black);
}
}
}
.roles-table-section-header {
background: rgba(0, 0, 0, 0.03);
}
.roles-table-row {
background: rgba(0, 0, 0, 0.01);
}
}

View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import { Input } from '@signozhq/input';
import RolesListingTable from './RolesComponents/RolesListingTable';
import './RolesSettings.styles.scss';
function RolesSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
return (
<div className="roles-settings" data-testid="roles-settings">
<div className="roles-settings-header">
<h3 className="roles-settings-header-title">Roles</h3>
<p className="roles-settings-header-description">
Create and manage custom roles for your team.
</p>
</div>
<div className="roles-settings-content">
<div className="roles-search-wrapper">
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>
</div>
);
}
export default RolesSettings;

View File

@@ -0,0 +1,232 @@
import {
allRoles,
listRolesSuccessResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import RolesSettings from '../RolesSettings';
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the header and search input', () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(
screen.getByText('Create and manage custom roles for your team.'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for roles...'),
).toBeInTheDocument();
});
it('displays roles grouped by managed and custom sections', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
// Section headers
expect(screen.getByText('Managed roles')).toBeInTheDocument();
expect(screen.getByText('Custom roles')).toBeInTheDocument();
// Managed roles
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('signoz-editor')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
// Custom roles
expect(screen.getByText('billing-manager')).toBeInTheDocument();
expect(screen.getByText('dashboard-creator')).toBeInTheDocument();
// Custom roles count badge
expect(screen.getByText('2')).toBeInTheDocument();
// Column headers
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Created At')).toBeInTheDocument();
});
it('filters roles by search query on name', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'billing');
expect(await screen.findByText('billing-manager')).toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
expect(screen.queryByText('signoz-editor')).not.toBeInTheDocument();
expect(screen.queryByText('dashboard-creator')).not.toBeInTheDocument();
});
it('filters roles by search query on description', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'read-only');
expect(await screen.findByText('signoz-viewer')).toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
expect(screen.queryByText('billing-manager')).not.toBeInTheDocument();
});
it('shows empty state when search matches nothing', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'nonexistentrole');
expect(
await screen.findByText('No roles match your search.'),
).toBeInTheDocument();
});
it('shows loading skeleton while fetching', () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.delay(200), ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when API fails', async () => {
const errorMessage = 'Failed to fetch roles';
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'INTERNAL_ERROR',
message: errorMessage,
url: '',
errors: [],
},
}),
),
),
);
render(<RolesSettings />);
expect(await screen.findByText(errorMessage)).toBeInTheDocument();
});
it('shows empty state when API returns no roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: [] })),
),
);
render(<RolesSettings />);
expect(await screen.findByText('No roles found.')).toBeInTheDocument();
});
it('renders descriptions for all roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
for (const role of allRoles) {
if (role.description) {
expect(screen.getByText(role.description)).toBeInTheDocument();
}
}
});
it('handles invalid dates gracefully by showing fallback', async () => {
const invalidRole = {
id: 'edge-0009',
createdAt: ('invalid-date' as unknown) as Date,
updatedAt: ('not-a-date' as unknown) as Date,
name: 'invalid-date-role',
description: 'Tests date parsing fallback.',
type: 'custom',
orgId: 'org-001',
};
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: [invalidRole],
}),
),
),
);
render(<RolesSettings />);
expect(await screen.findByText('invalid-date-role')).toBeInTheDocument();
// Verify the "—" (em-dash) fallback is shown for both cells
const dashFallback = screen.getAllByText('—');
// In renderRow: name, description, updatedAt, createdAt.
// Total dashes expected: 2 (for both dates)
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1 @@
export { default } from './RolesSettings';

View File

@@ -28,6 +28,7 @@ import {
ScrollText,
Search,
Settings,
Shield,
Slack,
Unplug,
User,
@@ -312,6 +313,13 @@ export const settingsMenuItems: SidebarItem[] = [
isEnabled: false,
itemKey: 'billing',
},
{
key: ROUTES.ROLES_SETTINGS,
label: 'Roles',
icon: <Shield size={16} />,
isEnabled: false,
itemKey: 'roles',
},
{
key: ROUTES.ORG_SETTINGS,
label: 'Members & SSO',

View File

@@ -159,6 +159,7 @@ export const routesToSkip = [
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,
ROUTES.BILLING,
ROUTES.ROLES_SETTINGS,
ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,

View File

@@ -1,444 +0,0 @@
import { ReactElement } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { AllTheProviders } from 'tests/test-utils';
import { useAuthZ } from './useAuthZ';
import { BrandedPermission, buildPermission } from './utils';
const BASE_URL = ENVIRONMENT.baseURL || '';
const wrapper = ({ children }: { children: ReactElement }): ReactElement => (
<AllTheProviders>{children}</AllTheProviders>
);
describe('useAuthZ', () => {
it('should fetch and return permissions successfully', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const mockApiResponse = {
[permission1]: true,
[permission2]: false,
};
const expectedResponse = {
[permission1]: {
isGranted: true,
},
[permission2]: {
isGranted: false,
},
};
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockApiResponse));
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
expect(result.current.isLoading).toBe(true);
expect(result.current.permissions).toBeNull();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeNull();
expect(result.current.permissions).toEqual(expectedResponse);
});
it('should handle API errors', async () => {
const permission = buildPermission('read', 'dashboard:*');
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const { result } = renderHook(() => useAuthZ([permission]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).not.toBeNull();
expect(result.current.permissions).toBeNull();
});
it('should refetch when permissions array changes', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (req, res, ctx) => {
requestCount++;
const body = req.body as {
permissions: Array<{ relation: string; object: string }>;
};
if (body.permissions.length === 1) {
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
}),
);
}
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
[permission2]: false,
[permission3]: true,
}),
);
}),
);
const { result, rerender } = renderHook<
ReturnType<typeof useAuthZ>,
BrandedPermission[]
>((permissions) => useAuthZ(permissions), {
wrapper,
initialProps: [permission1],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
expect(result.current.permissions).toEqual({
[permission1]: {
isGranted: true,
},
});
rerender([permission1, permission2, permission3]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(2);
expect(result.current.permissions).toEqual({
[permission1]: {
isGranted: true,
},
[permission2]: {
isGranted: false,
},
[permission3]: {
isGranted: true,
},
});
});
it('should not refetch when permissions array order changes but content is the same', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let requestCount = 0;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
requestCount++;
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
[permission2]: false,
}),
);
}),
);
const { result, rerender } = renderHook<
ReturnType<typeof useAuthZ>,
BrandedPermission[]
>((permissions) => useAuthZ(permissions), {
wrapper,
initialProps: [permission1, permission2],
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
rerender([permission2, permission1]);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(requestCount).toBe(1);
});
it('should handle empty permissions array', async () => {
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
);
const { result } = renderHook(() => useAuthZ([]), {
wrapper,
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.permissions).toEqual({});
});
it('should send correct payload format to API', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
let receivedPayload: any = null;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
receivedPayload = await req.json();
return res(
ctx.status(200),
ctx.json({
[permission1]: true,
[permission2]: false,
}),
);
}),
);
const { result } = renderHook(() => useAuthZ([permission1, permission2]), {
wrapper,
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(receivedPayload).toEqual({
permissions: [
{ relation: 'read', object: 'dashboard:*' },
{ relation: 'update', object: 'dashboard:123' },
],
});
});
it('should batch multiple hooks into single flight request', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
const permission4 = buildPermission('create', 'dashboards');
let requestCount = 0;
const receivedPayloads: any[] = [];
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
receivedPayloads.push(payload);
const allPermissions = [permission1, permission2, permission3, permission4];
const response: Record<string, boolean> = {};
payload.permissions.forEach((p: { relation: string; object: string }) => {
const perm = `${p.relation}/${p.object}` as BrandedPermission;
if (allPermissions.includes(perm)) {
response[perm] = perm === permission1 || perm === permission3;
}
});
return res(ctx.status(200), ctx.json(response));
}),
);
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
wrapper,
});
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
wrapper,
});
const { result: result3 } = renderHook(() => useAuthZ([permission3]), {
wrapper,
});
await waitFor(
() => {
expect(result1.current.isLoading).toBe(false);
expect(result2.current.isLoading).toBe(false);
expect(result3.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(1);
expect(receivedPayloads).toHaveLength(1);
expect(receivedPayloads[0].permissions).toHaveLength(3);
expect(receivedPayloads[0].permissions).toEqual(
expect.arrayContaining([
{ relation: 'read', object: 'dashboard:*' },
{ relation: 'update', object: 'dashboard:123' },
{ relation: 'delete', object: 'dashboard:456' },
]),
);
expect(result1.current.permissions).toEqual({
[permission1]: { isGranted: true },
});
expect(result2.current.permissions).toEqual({
[permission2]: { isGranted: false },
});
expect(result3.current.permissions).toEqual({
[permission3]: { isGranted: true },
});
});
it('should create separate batches for calls after single flight window', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
const receivedPayloads: any[] = [];
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
receivedPayloads.push(payload);
const response: Record<string, boolean> = {};
payload.permissions.forEach((p: { relation: string; object: string }) => {
const perm = `${p.relation}/${p.object}` as BrandedPermission;
response[perm] = perm === permission1 || perm === permission3;
});
return res(ctx.status(200), ctx.json(response));
}),
);
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
wrapper,
});
await waitFor(
() => {
expect(result1.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(1);
expect(receivedPayloads[0].permissions).toHaveLength(1);
await new Promise((resolve) => setTimeout(resolve, 100));
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
wrapper,
});
const { result: result3 } = renderHook(() => useAuthZ([permission3]), {
wrapper,
});
await waitFor(
() => {
expect(result2.current.isLoading).toBe(false);
expect(result3.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(2);
expect(receivedPayloads).toHaveLength(2);
expect(receivedPayloads[1].permissions).toHaveLength(2);
expect(receivedPayloads[1].permissions).toEqual(
expect.arrayContaining([
{ relation: 'update', object: 'dashboard:123' },
{ relation: 'delete', object: 'dashboard:456' },
]),
);
});
it('should not leak state between separate batches', async () => {
const permission1 = buildPermission('read', 'dashboard:*');
const permission2 = buildPermission('update', 'dashboard:123');
const permission3 = buildPermission('delete', 'dashboard:456');
let requestCount = 0;
server.use(
rest.post(`${BASE_URL}/api/v1/permissions/check`, async (req, res, ctx) => {
requestCount++;
const payload = await req.json();
const response: Record<string, boolean> = {};
payload.permissions.forEach((p: { relation: string; object: string }) => {
const perm = `${p.relation}/${p.object}` as BrandedPermission;
response[perm] = perm === permission1 || perm === permission3;
});
return res(ctx.status(200), ctx.json(response));
}),
);
const { result: result1 } = renderHook(() => useAuthZ([permission1]), {
wrapper,
});
await waitFor(
() => {
expect(result1.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(1);
expect(result1.current.permissions).toEqual({
[permission1]: { isGranted: true },
});
await new Promise((resolve) => setTimeout(resolve, 100));
const { result: result2 } = renderHook(() => useAuthZ([permission2]), {
wrapper,
});
await waitFor(
() => {
expect(result2.current.isLoading).toBe(false);
},
{ timeout: 200 },
);
expect(requestCount).toBe(2);
expect(result1.current.permissions).toEqual({
[permission1]: { isGranted: true },
});
expect(result2.current.permissions).toEqual({
[permission2]: { isGranted: false },
});
expect(result1.current.permissions).not.toHaveProperty(permission2);
expect(result2.current.permissions).not.toHaveProperty(permission1);
});
});

View File

@@ -1,136 +0,0 @@
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import api from 'api';
import { BrandedPermission } from './utils';
export type UseAuthZPermissionResult = {
isGranted: boolean;
};
export type AuthZCheckResponse = Record<
BrandedPermission,
UseAuthZPermissionResult
>;
export type UseAuthZResult = {
isLoading: boolean;
error: Error | null;
permissions: AuthZCheckResponse | null;
};
let ctx: Promise<AuthZCheckResponse> | null;
let pendingPermissions: BrandedPermission[] = [];
const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
const AUTHZ_CACHE_TIME = 20_000;
function dispatchPermission(
permission: BrandedPermission,
): Promise<AuthZCheckResponse> {
pendingPermissions.push(permission);
if (!ctx) {
let resolve: (v: AuthZCheckResponse) => void, reject: (reason?: any) => void;
ctx = new Promise<AuthZCheckResponse>((r, re) => {
resolve = r;
reject = re;
});
setTimeout(() => {
const copiedPermissions = pendingPermissions.slice();
pendingPermissions = [];
ctx = null;
fetchManyPermissions(copiedPermissions).then(resolve).catch(reject);
}, SINGLE_FLIGHT_WAIT_TIME_MS);
}
return ctx;
}
async function fetchManyPermissions(
permissions: BrandedPermission[],
): Promise<AuthZCheckResponse> {
const permissionsPayload = permissions.map((permission) => {
const [relation, object] = permission.split('/');
return {
relation,
object,
};
});
const permissionsCheckResponse = await api
.post<Record<string, boolean>>('/permissions/check', {
permissions: permissionsPayload,
})
.then((response) => response.data);
return Object.keys(permissionsCheckResponse).reduce<AuthZCheckResponse>(
(acc, permission): AuthZCheckResponse => {
acc[permission as BrandedPermission] = {
isGranted: !!permissionsCheckResponse[permission],
};
return acc;
},
{} as AuthZCheckResponse,
);
}
export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
const queryResults = useQueries(
permissions.map((permission) => {
return {
queryKey: ['authz', permission],
cacheTime: AUTHZ_CACHE_TIME,
refetchOnMount: false,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
queryFn: async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
return {
[permission]: {
isGranted: response[permission].isGranted,
},
};
},
};
}),
);
const isLoading = useMemo(() => queryResults.some((q) => q.isLoading), [
queryResults,
]);
const error = useMemo(
() =>
!isLoading
? (queryResults.find((q) => !!q.error)?.error as Error) || null
: null,
[isLoading, queryResults],
);
const data = useMemo(() => {
if (isLoading || error) {
return null;
}
return queryResults.reduce((acc, q) => {
if (!q.data) {
return acc;
}
for (const [key, value] of Object.entries(q.data)) {
acc[key as BrandedPermission] = value;
}
return acc;
}, {} as AuthZCheckResponse);
}, [isLoading, error, queryResults]);
return {
isLoading,
error,
permissions: data ?? null,
};
}

View File

@@ -1,32 +0,0 @@
export type AuthZRelation =
| 'list'
| 'create'
| 'read'
| 'update'
| 'delete'
| 'assignee';
export type AuthZResource = 'dashboard';
export type AuthZObject<R extends AuthZRelation> = R extends 'list' | 'create'
? `${AuthZResource}s`
: `${AuthZResource}:${string}`;
export type AuthZPermissionCheck<R extends AuthZRelation> = {
object: AuthZObject<R>;
relation: R;
};
export type BrandedPermission = string & { __brandedPermission: true };
export function buildPermission<R extends AuthZRelation>(
relation: R,
object: AuthZObject<R>,
): BrandedPermission {
return `${relation}/${object}` as BrandedPermission;
}
export function buildObjectString(
resource: AuthZResource,
objectId: string,
): `${AuthZResource}:${string}` {
return `${resource}:${objectId}`;
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { Form } from 'antd';
export interface CollapseSectionErrors {
hasErrors: boolean;
errorMessages: string[];
}
/**
* Detects validation errors in a form section
* @param fieldNamePrefix - Field path prefix for the section
* @param specificFields - Optional specific field prefixes to check (uses prefix matching)
*/
export function useCollapseSectionErrors(
fieldNamePrefix: string[],
specificFields?: string[][],
): CollapseSectionErrors {
const form = Form.useFormInstance();
// Refer: https://github.com/SigNoz/signoz/pull/10276#discussion_r2819372174
Form.useWatch([], form);
// eslint-disable-next-line sonarjs/cognitive-complexity
return useMemo(() => {
const fieldErrors = form.getFieldsError();
const messages: string[] = [];
if (specificFields?.length) {
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name) ? field.name : [field.name];
const isMatch = specificFields.some((specificField) => {
if (fieldPath.length < specificField.length) {
return false;
}
return specificField.every((part, idx) => fieldPath[idx] === part);
});
if (isMatch && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
} else {
const prefixPath = fieldNamePrefix.join('.');
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name)
? field.name.join('.')
: String(field.name);
if (fieldPath.startsWith(prefixPath) && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
}
return {
hasErrors: messages.length > 0,
errorMessages: messages,
};
}, [form, fieldNamePrefix, specificFields]);
}

View File

@@ -0,0 +1,64 @@
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: new Date('2026-02-03T18:00:55.624356Z'),
updatedAt: new Date('2026-02-03T18:00:55.624356Z'),
name: 'signoz-admin',
description:
'Role assigned to users who have full administrative access to SigNoz resources.',
type: 'managed',
orgId,
},
{
id: '019c24aa-2248-757c-9faf-7b1e899751e0',
createdAt: new Date('2026-02-03T18:00:55.624359Z'),
updatedAt: new Date('2026-02-03T18:00:55.624359Z'),
name: 'signoz-editor',
description:
'Role assigned to users who can create, edit, and manage SigNoz resources but do not have full administrative privileges.',
type: 'managed',
orgId,
},
{
id: '019c24aa-2248-7585-a129-4188b3473c27',
createdAt: new Date('2026-02-03T18:00:55.624362Z'),
updatedAt: new Date('2026-02-03T18:00:55.624362Z'),
name: 'signoz-viewer',
description:
'Role assigned to users who have read-only access to SigNoz resources.',
type: 'managed',
orgId,
},
];
export const customRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: new Date('2026-02-10T10:30:00.000Z'),
updatedAt: new Date('2026-02-12T14:20:00.000Z'),
name: 'billing-manager',
description: 'Custom role for managing billing and invoices.',
type: 'custom',
orgId,
},
{
id: '019c24aa-3333-0002-bbbb-222222222222',
createdAt: new Date('2026-02-11T09:00:00.000Z'),
updatedAt: new Date('2026-02-13T11:45:00.000Z'),
name: 'dashboard-creator',
description: 'Custom role allowing users to create and manage dashboards.',
type: 'custom',
orgId,
},
];
export const allRoles: RoletypesRoleDTO[] = [...managedRoles, ...customRoles];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};

View File

@@ -77,6 +77,7 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.CUSTOM_DOMAIN_SETTINGS ||
item.key === ROUTES.API_KEYS ||
@@ -107,6 +108,7 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
@@ -134,7 +136,9 @@ function SettingsPage(): JSX.Element {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled:
item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -12,6 +12,7 @@ import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
import MultiIngestionSettings from 'container/IngestionSettings/MultiIngestionSettings';
import MySettings from 'container/MySettings';
import OrganizationSettings from 'container/OrganizationSettings';
import RolesSettings from 'container/RolesSettings';
import { TFunction } from 'i18next';
import {
Backpack,
@@ -24,6 +25,7 @@ import {
KeySquare,
Pencil,
Plus,
Shield,
User,
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
@@ -148,6 +150,19 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: RolesSettings,
name: (
<div className="periscope-tab">
<Shield size={16} /> {t('routes:roles').toString()}
</div>
),
route: ROUTES.ROLES_SETTINGS,
key: ROUTES.ROLES_SETTINGS,
},
];
export const keyboardShortcuts = (t: TFunction): RouteTabProps['routes'] => [
{
Component: Shortcuts,

View File

@@ -15,6 +15,7 @@ import {
multiIngestionSettings,
mySettings,
organizationSettings,
rolesSettings,
} from './config';
export const getRoutes = (
@@ -66,6 +67,10 @@ export const getRoutes = (
settings.push(...customDomainSettings(t), ...billingSettings(t));
}
if (isAdmin) {
settings.push(...rolesSettings(t));
}
settings.push(
...mySettings(t),
...createAlertChannels(t),

View File

@@ -1,3 +1,8 @@
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
/**
* Extracts HTTP status code from various error types
* @param error - The error object (could be APIError, AxiosError, or other error types)
@@ -28,3 +33,25 @@ export const isRetryableError = (error: any): boolean => {
// If no status code is available, default to retryable
return !statusCode || statusCode >= 500;
};
export function toAPIError(
error: unknown,
defaultMessage = 'An unexpected error occurred.',
): APIError {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
if (apiError instanceof APIError) {
return apiError;
}
}
return new APIError({
httpStatusCode: 500,
error: {
code: 'UNKNOWN_ERROR',
message: defaultMessage,
url: '',
errors: [],
},
});
}

View File

@@ -97,6 +97,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -4479,6 +4479,19 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.1.4":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -5164,6 +5177,20 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/switch@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/switch/-/switch-0.0.2.tgz#58003ce9c0cd1f2ad8266a7045182607ce51df76"
integrity sha512-3B3Y5dzIyepO6EQJ7agx97bPmwg1dcOY46q2lqviHnMxNk3Sv079nSNCaztjQlo0VR0qu2JgVXhWi5Lw9WBN8A==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
"@radix-ui/react-switch" "^1.1.4"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/table@0.3.7":
version "0.3.7"
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"

View File

@@ -120,6 +120,8 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
}
}
resultData.Rows = filteredRows
case *qbtypes.ScalarData:
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
}
filteredData = append(filteredData, result)
@@ -145,6 +147,39 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
return true
}
func filterScalarDataIPs(columns []*qbtypes.ColumnDescriptor, data [][]any) [][]any {
// Find column indices for server address fields
serverColIndices := make([]int, 0)
for i, col := range columns {
if col.Name == derivedKeyHTTPHost {
serverColIndices = append(serverColIndices, i)
}
}
if len(serverColIndices) == 0 {
return data
}
filtered := make([][]any, 0, len(data))
for _, row := range data {
includeRow := true
for _, colIdx := range serverColIndices {
if colIdx < len(row) {
if strVal, ok := row[colIdx].(string); ok {
if net.ParseIP(strVal) != nil {
includeRow = false
break
}
}
}
}
if includeRow {
filtered = append(filtered, row)
}
}
return filtered
}
func shouldIncludeRow(row *qbtypes.RawRow) bool {
if row.Data != nil {
if domainVal, ok := row.Data[derivedKeyHTTPHost]; ok {

View File

@@ -117,6 +117,59 @@ func TestFilterResponse(t *testing.T) {
},
},
},
{
name: "should filter out IP addresses from scalar data",
input: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"192.168.1.1", 10},
{"example.com", 20},
{"10.0.0.1", 5},
},
},
},
},
},
},
expected: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"example.com", 20},
},
},
},
},
},
},
},
}
for _, tt := range tests {