mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-12 04:22:06 +00:00
Compare commits
1 Commits
test/uplot
...
sso-mappin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c57ab4d76a |
@@ -12,8 +12,6 @@ export interface MockUPlotInstance {
|
||||
export interface MockUPlotPaths {
|
||||
spline: jest.Mock;
|
||||
bars: jest.Mock;
|
||||
linear: jest.Mock;
|
||||
stepped: jest.Mock;
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
@@ -25,23 +23,10 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
jest.fn(() => ({
|
||||
name, // To test if the correct pathBuilder is used
|
||||
stroke: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
}));
|
||||
|
||||
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
|
||||
const mockPaths = {
|
||||
spline: jest.fn(() => createMockPathBuilder('spline')),
|
||||
bars: jest.fn(() => createMockPathBuilder('bars')),
|
||||
linear: jest.fn(() => createMockPathBuilder('linear')),
|
||||
stepped: jest.fn((opts?: { align?: number }) =>
|
||||
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
|
||||
),
|
||||
// Create mock paths
|
||||
const mockPaths: MockUPlotPaths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock static methods
|
||||
|
||||
@@ -7,7 +7,10 @@ import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
import {
|
||||
GettableAuthDomain,
|
||||
GoogleAuthConfig,
|
||||
} from 'types/api/v1/domains/list';
|
||||
import { PostableAuthDomain } from 'types/api/v1/domains/post';
|
||||
|
||||
import AuthnProviderSelector from './AuthnProviderSelector';
|
||||
@@ -51,9 +54,35 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const samlEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
|
||||
|
||||
const getGoogleAuthConfig = (): GoogleAuthConfig | undefined => {
|
||||
const config = form.getFieldValue('googleAuthConfig');
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Convert domainToAdminEmailList array to domainToAdminEmail Record
|
||||
const { domainToAdminEmailList, ...rest } = config;
|
||||
const domainToAdminEmail: Record<string, string> = {};
|
||||
|
||||
if (Array.isArray(domainToAdminEmailList)) {
|
||||
domainToAdminEmailList.forEach(
|
||||
(item: { domain?: string; adminEmail?: string }) => {
|
||||
if (item.domain && item.adminEmail) {
|
||||
domainToAdminEmail[item.domain] = item.adminEmail;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(Object.keys(domainToAdminEmail).length > 0 && { domainToAdminEmail }),
|
||||
};
|
||||
};
|
||||
|
||||
const onSubmitHandler = async (): Promise<void> => {
|
||||
const name = form.getFieldValue('name');
|
||||
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
|
||||
const googleAuthConfig = getGoogleAuthConfig();
|
||||
const samlConfig = form.getFieldValue('samlConfig');
|
||||
const oidcConfig = form.getFieldValue('oidcConfig');
|
||||
|
||||
@@ -93,14 +122,39 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open footer={null} onCancel={onClose}>
|
||||
<Modal
|
||||
open
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
width={authnProvider ? 980 : undefined}
|
||||
>
|
||||
<Form
|
||||
name="auth-domain"
|
||||
initialValues={defaultTo(record, {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
})}
|
||||
initialValues={defaultTo(
|
||||
record
|
||||
? {
|
||||
...record,
|
||||
googleAuthConfig: record.googleAuthConfig
|
||||
? {
|
||||
...record.googleAuthConfig,
|
||||
domainToAdminEmailList: record.googleAuthConfig.domainToAdminEmail
|
||||
? Object.entries(record.googleAuthConfig.domainToAdminEmail).map(
|
||||
([domain, adminEmail]) => ({
|
||||
domain,
|
||||
adminEmail,
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
},
|
||||
)}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="google-auth">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
<section className="google-auth__header">
|
||||
<h3 className="google-auth__title typography-label-medium-600">
|
||||
Edit Google Authentication
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph className="description">
|
||||
</h3>
|
||||
<p className="google-auth__description typography-paragraph-base-400">
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below.
|
||||
Read the{' '}
|
||||
<a
|
||||
@@ -25,50 +53,227 @@ 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="google-auth__columns">
|
||||
{/* Left Column - Core OAuth Settings */}
|
||||
<div className="google-auth__left">
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item name="name" className="google-auth__form-item">
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
htmlFor="google-client-id"
|
||||
>
|
||||
Client ID
|
||||
<Tooltip title="ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
htmlFor="google-client-secret"
|
||||
>
|
||||
Client Secret
|
||||
<Tooltip title="It is the application's secret.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<Input id="google-client-secret" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="Google OAuth2 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
<div className="google-auth__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} className="google-auth__label-icon" />
|
||||
</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="google-auth__right">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={
|
||||
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
|
||||
}
|
||||
onChange={handleWorkspaceGroupsChange}
|
||||
className="google-auth__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="workspace-groups"
|
||||
header={
|
||||
<div className="google-auth__collapse-header">
|
||||
{expandedSection !== 'workspace-groups' ? (
|
||||
<ChevronRight size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
<div className="google-auth__collapse-header-text">
|
||||
<h4 className="google-auth__section-title typography-label-base-600">
|
||||
Google Workspace Groups (Advanced)
|
||||
</h4>
|
||||
<p className="google-auth__section-description typography-paragraph-small-400">
|
||||
Enable group fetching to retrieve user groups from Google Workspace.
|
||||
Requires a Service Account with domain-wide delegation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="google-auth__group-content">
|
||||
<div className="google-auth__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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{fetchGroups && (
|
||||
<div className="google-auth__group-fields">
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'serviceAccountJson']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<TextArea
|
||||
id="google-service-account-json"
|
||||
rows={3}
|
||||
placeholder="Paste service account JSON"
|
||||
className="google-auth__textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<DomainMappingList
|
||||
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
|
||||
/>
|
||||
|
||||
<div className="google-auth__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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'allowedGroups']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<EmailTagInput placeholder="Type a group email and press Enter" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['googleAuthConfig', 'roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,209 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
|
||||
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">
|
||||
<div className="google-auth">
|
||||
<section className="google-auth__header">
|
||||
<h3 className="google-auth__title typography-label-medium-600">
|
||||
Edit OIDC Authentication
|
||||
</Typography.Text>
|
||||
</h3>
|
||||
<p className="google-auth__description typography-paragraph-base-400">
|
||||
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="google-auth__columns">
|
||||
{/* Left Column - Core OIDC Settings */}
|
||||
<div className="google-auth__left">
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item name="name" className="google-auth__form-item">
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'issuer']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'issuerAlias']}
|
||||
className="google-auth__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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
htmlFor="oidc-client-id"
|
||||
>
|
||||
Client ID
|
||||
<Tooltip title="The application's client ID from your OIDC provider.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'clientId']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
htmlFor="oidc-client-secret"
|
||||
>
|
||||
Client Secret
|
||||
<Tooltip title="The application's client secret from your OIDC provider.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'clientSecret']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<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="google-auth__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} className="google-auth__label-icon" />
|
||||
</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="google-auth__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} className="google-auth__label-icon" />
|
||||
</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 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
{/* Right Column - Advanced Settings */}
|
||||
<div className="google-auth__right">
|
||||
<ClaimMappingSection
|
||||
fieldNamePrefix={['oidcConfig', 'claimMapping']}
|
||||
isExpanded={expandedSection === 'claim-mapping'}
|
||||
onExpandChange={handleClaimMappingChange}
|
||||
/>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['oidcConfig', 'roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,177 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
|
||||
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">
|
||||
<div className="google-auth">
|
||||
<section className="google-auth__header">
|
||||
<h3 className="google-auth__title typography-label-medium-600">
|
||||
Edit SAML Authentication
|
||||
</Typography.Text>
|
||||
</h3>
|
||||
<p className="google-auth__description typography-paragraph-base-400">
|
||||
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="google-auth__columns">
|
||||
{/* Left Column - Core SAML Settings */}
|
||||
<div className="google-auth__left">
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item name="name" className="google-auth__form-item">
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<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="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label typography-label-base-500"
|
||||
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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlCert']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<TextArea
|
||||
id="saml-certificate"
|
||||
rows={3}
|
||||
placeholder="Paste X.509 certificate"
|
||||
className="google-auth__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="google-auth__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} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="SAML won’t 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="google-auth__right">
|
||||
<AttributeMappingSection
|
||||
fieldNamePrefix={['samlConfig', 'attributeMapping']}
|
||||
isExpanded={expandedSection === 'attribute-mapping'}
|
||||
onExpandChange={handleAttributeMappingChange}
|
||||
/>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['samlConfig', 'roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,248 @@
|
||||
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);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Form field layout ---
|
||||
&__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);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
// --- Checkbox row: label on left, checkbox on right ---
|
||||
&__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// --- Input styles (matching auth flow standards) ---
|
||||
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(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea should not have fixed height
|
||||
textarea {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// --- Textarea specific styles ---
|
||||
&__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(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Checkbox border visibility (lighter for dark modal bg) ---
|
||||
button[role='checkbox'] {
|
||||
border: 1px solid var(--l2-foreground) !important;
|
||||
border-radius: 2px;
|
||||
|
||||
&[data-state='checked'] {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapsible section ---
|
||||
&__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;
|
||||
|
||||
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 fields that scroll when "Fetch Groups" is on ---
|
||||
&__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;
|
||||
|
||||
// Remove bottom margins from children since we rely on gap
|
||||
.google-auth__field-group,
|
||||
.google-auth__checkbox-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox thin scrollbar
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
.callout {
|
||||
@@ -26,6 +251,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Light mode overrides ---
|
||||
.lightMode {
|
||||
.google-auth {
|
||||
input,
|
||||
textarea {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.saml {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
.attribute-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// --- Collapsible section ---
|
||||
&__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;
|
||||
|
||||
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 area with scroll ---
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox thin scrollbar
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
// --- Field group layout ---
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
// --- Input styles ---
|
||||
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(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Light mode overrides ---
|
||||
.lightMode {
|
||||
.attribute-mapping-section {
|
||||
input {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import './AttributeMappingSection.styles.scss';
|
||||
|
||||
interface AttributeMappingSectionProps {
|
||||
/** The form field name prefix for the attribute mapping configuration */
|
||||
fieldNamePrefix: string[];
|
||||
/** Whether the section is expanded (controlled mode) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse is toggled */
|
||||
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'] : [];
|
||||
|
||||
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">
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="attribute-mapping-section__collapse-header-text">
|
||||
<h4 className="attribute-mapping-section__section-title typography-label-base-600">
|
||||
Attribute Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="attribute-mapping-section__section-description typography-paragraph-small-400">
|
||||
Configure how SAML assertion attributes from your Identity Provider map
|
||||
to SigNoz user attributes. Leave empty to use default values. Note:
|
||||
Email is always extracted from the NameID assertion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="attribute-mapping-section__content">
|
||||
{/* Name Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label typography-label-base-500"
|
||||
htmlFor="name-attribute"
|
||||
>
|
||||
Name Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's display name. Default: 'name'">
|
||||
<CircleHelp
|
||||
size={14}
|
||||
className="attribute-mapping-section__label-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'nameAttribute']}
|
||||
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 typography-label-base-500"
|
||||
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}
|
||||
className="attribute-mapping-section__label-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'groupsAttribute']}
|
||||
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 typography-label-base-500"
|
||||
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}
|
||||
className="attribute-mapping-section__label-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'roleAttribute']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="role-attribute" placeholder="role" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingSection;
|
||||
@@ -0,0 +1,151 @@
|
||||
.claim-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// --- Collapsible section ---
|
||||
&__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;
|
||||
|
||||
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 area with scroll ---
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox thin scrollbar
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
// --- Field group layout ---
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
// --- Input styles ---
|
||||
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(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Light mode overrides ---
|
||||
.lightMode {
|
||||
.claim-mapping-section {
|
||||
input {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import './ClaimMappingSection.styles.scss';
|
||||
|
||||
interface ClaimMappingSectionProps {
|
||||
/** The form field name prefix for the claim mapping configuration */
|
||||
fieldNamePrefix: string[];
|
||||
/** Whether the section is expanded (controlled mode) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse is toggled */
|
||||
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'] : [];
|
||||
|
||||
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">
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="claim-mapping-section__collapse-header-text">
|
||||
<h4 className="claim-mapping-section__section-title typography-label-base-600">
|
||||
Claim Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="claim-mapping-section__section-description typography-paragraph-small-400">
|
||||
Configure how claims from your Identity Provider map to SigNoz user
|
||||
attributes. Leave empty to use default values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="claim-mapping-section__content">
|
||||
{/* Email Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label
|
||||
className="claim-mapping-section__label typography-label-base-500"
|
||||
htmlFor="email-claim"
|
||||
>
|
||||
Email Claim
|
||||
<Tooltip title="The claim key that contains the user's email address. Default: 'email'">
|
||||
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
|
||||
</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 typography-label-base-500"
|
||||
htmlFor="name-claim"
|
||||
>
|
||||
Name Claim
|
||||
<Tooltip title="The claim key that contains the user's display name. Default: 'name'">
|
||||
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
|
||||
</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 typography-label-base-500"
|
||||
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} className="claim-mapping-section__label-icon" />
|
||||
</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 typography-label-base-500"
|
||||
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} className="claim-mapping-section__label-icon" />
|
||||
</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;
|
||||
@@ -0,0 +1,118 @@
|
||||
.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: #e5484d !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: #e5484d !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1) !important;
|
||||
opacity: 0.9 !important;
|
||||
color: #e5484d !important;
|
||||
|
||||
svg {
|
||||
color: #e5484d !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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode overrides
|
||||
.lightMode {
|
||||
.domain-mapping-list {
|
||||
&__row input {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
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@]+$/;
|
||||
|
||||
interface DomainMappingListProps {
|
||||
/** The form field name prefix for the domain mapping list */
|
||||
fieldNamePrefix: string[];
|
||||
}
|
||||
|
||||
function DomainMappingList({
|
||||
fieldNamePrefix,
|
||||
}: DomainMappingListProps): JSX.Element {
|
||||
const validateEmail = useCallback(
|
||||
(_: 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();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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 "*" 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;
|
||||
@@ -0,0 +1,30 @@
|
||||
.email-tag-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-search {
|
||||
// margin-inline-start: 0 !important;
|
||||
|
||||
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(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
|
||||
import './EmailTagInput.styles.scss';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface EmailTagInputProps {
|
||||
/** Current value (array of email strings) */
|
||||
value?: string[];
|
||||
/** Change handler */
|
||||
onChange?: (value: string[]) => void;
|
||||
/** Placeholder text */
|
||||
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;
|
||||
@@ -0,0 +1,328 @@
|
||||
.role-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 24px;
|
||||
|
||||
// --- Collapsible section ---
|
||||
&__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;
|
||||
|
||||
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 area with scroll ---
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox thin scrollbar
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
// --- Field group layout ---
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
// --- Checkbox row ---
|
||||
&__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// --- Select styling ---
|
||||
&__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(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group mappings section ---
|
||||
&__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: #e5484d !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: #e5484d !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1) !important;
|
||||
opacity: 0.9 !important;
|
||||
color: #e5484d !important;
|
||||
|
||||
svg {
|
||||
color: #e5484d !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(--bg-robin-500) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input styles ---
|
||||
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(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Light mode overrides ---
|
||||
.lightMode {
|
||||
.role-mapping-section {
|
||||
input {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__select {
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
background: var(--bg-vanilla-200) !important;
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--text-ink-500) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Select, Tooltip } from 'antd';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import './RoleMappingSection.styles.scss';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'VIEWER', label: 'VIEWER' },
|
||||
{ value: 'EDITOR', label: 'EDITOR' },
|
||||
{ value: 'ADMIN', label: 'ADMIN' },
|
||||
];
|
||||
|
||||
interface RoleMappingSectionProps {
|
||||
/** The form field name prefix for the role mapping configuration */
|
||||
fieldNamePrefix: string[];
|
||||
/** Whether the section is expanded (controlled mode) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse is toggled */
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function RoleMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: RoleMappingSectionProps): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
const useRoleAttributeDirectly = Form.useWatch(
|
||||
[...fieldNamePrefix, 'useRoleAttributeDirectly'],
|
||||
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'] : [];
|
||||
|
||||
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">
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="role-mapping-section__collapse-header-text">
|
||||
<h4 className="role-mapping-section__section-title typography-label-base-600">
|
||||
Role Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="role-mapping-section__section-description typography-paragraph-small-400">
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="role-mapping-section__content">
|
||||
{/* Default Role */}
|
||||
<div className="role-mapping-section__field-group">
|
||||
<label
|
||||
className="role-mapping-section__label typography-label-base-500"
|
||||
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} className="role-mapping-section__label-icon" />
|
||||
</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>
|
||||
|
||||
{/* Use Role Attribute Directly */}
|
||||
<div className="role-mapping-section__checkbox-row">
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'useRoleAttributeDirectly']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="use-role-attribute-directly"
|
||||
labelName="Use Role Attribute Directly"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
[...fieldNamePrefix, 'useRoleAttributeDirectly'],
|
||||
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} className="role-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Group to Role Mappings - only show when useRoleAttributeDirectly is false */}
|
||||
{!useRoleAttributeDirectly && (
|
||||
<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, 'groupMappings']}>
|
||||
{(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;
|
||||
@@ -4,12 +4,12 @@ import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.BAR,
|
||||
PANEL_TYPES.PIE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -114,6 +114,81 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate axis size from existing size property
|
||||
*/
|
||||
private getExistingAxisSize(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(self, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
private calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring, no-param-reassign
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Y-axis dynamic size calculator
|
||||
*/
|
||||
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
const gap = this.props.gap ?? 5;
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += this.calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic size calculator for Y-axis
|
||||
*/
|
||||
@@ -127,7 +202,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
|
||||
// Y-axis needs dynamic sizing based on text width
|
||||
if (scaleKey === 'y') {
|
||||
return buildYAxisSizeCalculator(this.props.gap ?? 5);
|
||||
return this.buildYAxisSizeCalculator();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -15,33 +15,7 @@ import {
|
||||
* Builder for uPlot series configuration
|
||||
* Handles creation of series settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path builders are static and shared across all instances of UPlotSeriesBuilder
|
||||
*/
|
||||
let builders: PathBuilders | null = null;
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
constructor(props: SeriesProps) {
|
||||
super(props);
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildLineConfig({
|
||||
lineColor,
|
||||
lineWidth,
|
||||
@@ -224,6 +198,8 @@ interface PathBuilders {
|
||||
[key: string]: Series.PathBuilder;
|
||||
}
|
||||
|
||||
let builders: PathBuilders | null = null;
|
||||
|
||||
/**
|
||||
* Get path builder based on draw style and interpolation
|
||||
*/
|
||||
@@ -231,8 +207,23 @@ function getPathBuilder(
|
||||
style: DrawStyle,
|
||||
lineInterpolation?: LineInterpolation,
|
||||
): Series.PathBuilder {
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
if (style === DrawStyle.Line) {
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { AxisProps } from '../types';
|
||||
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn(),
|
||||
}));
|
||||
|
||||
const createAxisProps = (overrides: Partial<AxisProps> = {}): AxisProps => ({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
isDarkMode: false,
|
||||
show: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('builds basic axis config with defaults', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(true);
|
||||
expect(config.side).toBe(2);
|
||||
expect(config.gap).toBe(5);
|
||||
|
||||
// Default grid and ticks are created
|
||||
expect(config.grid).toEqual({
|
||||
stroke: 'rgba(0,0,0,0.5)',
|
||||
width: 0.2,
|
||||
show: true,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets config values when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
show: false,
|
||||
side: 0,
|
||||
gap: 10,
|
||||
grid: {
|
||||
stroke: '#ff0000',
|
||||
width: 1,
|
||||
show: false,
|
||||
},
|
||||
ticks: {
|
||||
stroke: '#00ff00',
|
||||
width: 1,
|
||||
show: false,
|
||||
size: 10,
|
||||
},
|
||||
values: ['1', '2', '3'],
|
||||
space: 20,
|
||||
size: 100,
|
||||
stroke: '#0000ff',
|
||||
}),
|
||||
);
|
||||
const config = builder.getConfig();
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.gap).toBe(10);
|
||||
expect(config.grid).toEqual({
|
||||
stroke: '#ff0000',
|
||||
width: 1,
|
||||
show: false,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
stroke: '#00ff00',
|
||||
width: 1,
|
||||
show: false,
|
||||
size: 10,
|
||||
});
|
||||
expect(config.values).toEqual(['1', '2', '3']);
|
||||
expect(config.space).toBe(20);
|
||||
expect(config.size).toBe(100);
|
||||
expect(config.stroke).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('merges custom grid config over defaults and respects isDarkMode and isLogScale', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
isDarkMode: true,
|
||||
isLogScale: true,
|
||||
grid: {
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.grid).toEqual({
|
||||
// stroke falls back to theme-based default when not provided
|
||||
stroke: 'rgba(231,233,237,0.3)',
|
||||
// provided width overrides default
|
||||
width: 1,
|
||||
// show falls back to default when not provided
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
ticks: customTicks,
|
||||
}),
|
||||
);
|
||||
const withoutTicks = new UPlotAxisBuilder(createAxisProps());
|
||||
|
||||
expect(withTicks.getConfig().ticks).toBe(customTicks);
|
||||
expect(withoutTicks.getConfig().ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds Y-axis values formatter that delegates to getToolTipValue', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = yBuilder.getConfig();
|
||||
expect(typeof config.values).toBe('function');
|
||||
|
||||
(getToolTipValue as jest.Mock).mockImplementation(
|
||||
(value: string, unit?: string, precision?: unknown) =>
|
||||
`formatted:${value}:${unit}:${precision}`,
|
||||
);
|
||||
|
||||
// Simulate uPlot calling the values formatter
|
||||
const valuesFn = (config.values as unknown) as (
|
||||
self: uPlot,
|
||||
vals: unknown[],
|
||||
) => string[];
|
||||
const result = valuesFn({} as uPlot, [1, null, 2, Number.NaN]);
|
||||
|
||||
expect(getToolTipValue).toHaveBeenCalledTimes(2);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(1, '1', 'ms', 3);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(2, '2', 'ms', 3);
|
||||
|
||||
// Null/NaN values should map to empty strings
|
||||
expect(result).toEqual(['formatted:1:ms:3', '', 'formatted:2:ms:3', '']);
|
||||
});
|
||||
|
||||
it('adds dynamic size calculator only for Y-axis when size is not provided', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
}),
|
||||
);
|
||||
const xBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
}),
|
||||
);
|
||||
|
||||
const yConfig = yBuilder.getConfig();
|
||||
const xConfig = xBuilder.getConfig();
|
||||
|
||||
expect(typeof yConfig.size).toBe('function');
|
||||
expect(xConfig.size).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses explicit size function when provided', () => {
|
||||
const sizeFn: uPlot.Axis.Size = jest.fn(() => 100) as uPlot.Axis.Size;
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
size: sizeFn,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.size).toBe(sizeFn);
|
||||
});
|
||||
|
||||
it('builds stroke color based on stroke and isDarkMode', () => {
|
||||
const explicitStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: '#ff0000',
|
||||
}),
|
||||
);
|
||||
const darkStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: true,
|
||||
}),
|
||||
);
|
||||
const lightStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(explicitStroke.getConfig().stroke).toBe('#ff0000');
|
||||
expect(darkStroke.getConfig().stroke).toBe('white');
|
||||
expect(lightStroke.getConfig().stroke).toBe('black');
|
||||
});
|
||||
|
||||
it('uses explicit values formatter when provided', () => {
|
||||
const customValues: uPlot.Axis.Values = jest.fn(() => ['a', 'b', 'c']);
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
values: customValues,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(customValues);
|
||||
});
|
||||
|
||||
it('returns undefined values for scaleKey neither x nor y', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'custom' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes space in config when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', space: 50 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
|
||||
const timeSeriesBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('should return the existing size when cycleNum > 1', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'y' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: { measureText: jest.fn(() => ({ width: 60 })), font: '' },
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['100', '200'],
|
||||
0,
|
||||
2, // cycleNum > 1
|
||||
);
|
||||
|
||||
expect(result).toBe(80);
|
||||
});
|
||||
|
||||
it('should invoke the size calculator and compute from text width when cycleNum <= 1', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', gap: 8 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
ticks: { size: 12 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const measureText = jest.fn(() => ({ width: 48 }));
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: {
|
||||
measureText,
|
||||
get font() {
|
||||
return '';
|
||||
},
|
||||
set font(_v: string) {
|
||||
/* noop */
|
||||
},
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['10', '2000ms'],
|
||||
0,
|
||||
0, // cycleNum <= 1
|
||||
);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('2000ms');
|
||||
expect(result).toBeGreaterThanOrEqual(12 + 8);
|
||||
});
|
||||
|
||||
it('merge updates axis props', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', label: 'Original' }),
|
||||
);
|
||||
|
||||
builder.merge({ label: 'Merged', yAxisUnit: 'bytes' });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.label).toBe('Merged');
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const getStoredSeriesVisibilityMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
) as {
|
||||
getStoredSeriesVisibility: jest.Mock;
|
||||
};
|
||||
|
||||
describe('UPlotConfigBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createSeriesProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns correct save selection preference flag from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
shouldSaveSelectionPreference: true,
|
||||
});
|
||||
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns widgetId from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
|
||||
|
||||
expect(builder.getWidgetId()).toBe('widget-123');
|
||||
});
|
||||
|
||||
it('sets tzDate from constructor and includes it in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder({ tzDate });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not call onDragSelect for click without drag (width === 0)', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 10, width: 0 },
|
||||
posToVal: jest.fn(),
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// Simulate uPlot calling the hook
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const posToVal = jest
|
||||
.fn()
|
||||
// left position
|
||||
.mockReturnValueOnce(100)
|
||||
// left + width
|
||||
.mockReturnValueOnce(110);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 50, width: 20 },
|
||||
posToVal,
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).toHaveBeenCalledTimes(1);
|
||||
// 100 and 110 seconds converted to milliseconds
|
||||
expect(onDragSelect).toHaveBeenCalledWith(100_000, 110_000);
|
||||
});
|
||||
|
||||
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const drawHook = jest.fn();
|
||||
|
||||
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
|
||||
|
||||
let config = builder.getConfig();
|
||||
expect(config.hooks?.draw).toContain(drawHook);
|
||||
|
||||
// Remove and ensure it no longer appears in config
|
||||
remove();
|
||||
config = builder.getConfig();
|
||||
expect(config.hooks?.draw ?? []).not.toContain(drawHook);
|
||||
});
|
||||
|
||||
it('adds axes, scales, and series and wires them into the final config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
// Add axis and scale
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addScale({ scaleKey: 'y' });
|
||||
|
||||
// Add two series – legend indices should start from 1 (0 is the timestamp series)
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Axes
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].scale).toBe('y');
|
||||
|
||||
// Scales are returned as an object keyed by scaleKey
|
||||
expect(config.scales).toBeDefined();
|
||||
expect(Object.keys(config.scales ?? {})).toContain('y');
|
||||
|
||||
// Series: base timestamp + 2 data series
|
||||
expect(config.series).toHaveLength(3);
|
||||
// Base series (index 0) has a value formatter that returns empty string
|
||||
const baseSeries = config.series?.[0] as { value?: () => string };
|
||||
expect(typeof baseSeries?.value).toBe('function');
|
||||
expect(baseSeries?.value?.()).toBe('');
|
||||
|
||||
// Legend items align with series and carry label and color from series config
|
||||
const legendItems = builder.getLegendItems();
|
||||
expect(Object.keys(legendItems)).toEqual(['1', '2']);
|
||||
expect(legendItems[1].seriesIndex).toBe(1);
|
||||
expect(legendItems[1].label).toBe('Requests');
|
||||
expect(legendItems[2].label).toBe('Errors');
|
||||
});
|
||||
|
||||
it('merges axis when addAxis is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].label).toBe('Updated Label');
|
||||
expect(config.axes?.[0].show).toBe(false);
|
||||
});
|
||||
|
||||
it('merges scale when addScale is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addScale({ scaleKey: 'y', min: 0 });
|
||||
builder.addScale({ scaleKey: 'y', max: 100 });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Only one scale entry for 'y' (merge path used, no duplicate added)
|
||||
expect(config.scales).toBeDefined();
|
||||
const scales = config.scales ?? {};
|
||||
expect(Object.keys(scales)).toEqual(['y']);
|
||||
expect(scales.y?.range).toBeDefined();
|
||||
});
|
||||
|
||||
it('restores visibility state from localStorage when selectionPreferencesSource is LOCAL_STORAGE', () => {
|
||||
const visibilityMap = new Map<string, boolean>([
|
||||
['Requests', true],
|
||||
['Errors', false],
|
||||
]);
|
||||
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue(
|
||||
visibilityMap,
|
||||
);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// When any series is hidden, legend visibility is driven by the stored map
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].show).toBe(false);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
|
||||
builder.getLegendItems();
|
||||
builder.getConfig();
|
||||
|
||||
expect(
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds thresholds only once per scale key', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Only a single draw hook should be registered for the same scaleKey
|
||||
expect(drawHooks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('adds multiple thresholds when scale key is different', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
const thresholdsOptions2 = {
|
||||
scaleKey: 'y2',
|
||||
thresholds: [{ thresholdValue: 200 }],
|
||||
};
|
||||
builder.addThresholds(thresholdsOptions2);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Two draw hooks should be registered for different scaleKeys
|
||||
expect(drawHooks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('merges cursor configuration with defaults instead of replacing them', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.cursor?.drag?.setScale).toBe(false);
|
||||
// Points configuration from DEFAULT_CURSOR_CONFIG should still be present
|
||||
expect(config.cursor?.points).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds plugins and includes them in config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const plugin: uPlot.Plugin = {
|
||||
opts: (): void => {},
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
builder.addPlugin(plugin);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toContain(plugin);
|
||||
});
|
||||
|
||||
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
|
||||
|
||||
builder.setBands(bands);
|
||||
builder.setPadding([10, 20, 30, 40]);
|
||||
builder.setLegend({ show: true, live: true });
|
||||
builder.setFocus({ alpha: 0.5 });
|
||||
builder.setSelect({ left: 0, width: 0, top: 0, height: 0 });
|
||||
builder.setTzDate(tzDate);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toEqual(bands);
|
||||
expect(config.padding).toEqual([10, 20, 30, 40]);
|
||||
expect(config.legend).toEqual({ show: true, live: true });
|
||||
expect(config.focus).toEqual({ alpha: 0.5 });
|
||||
expect(config.select).toEqual({ left: 0, width: 0, top: 0, height: 0 });
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not include plugins when none added', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not include bands when empty', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import * as scaleUtils from '../../utils/scale';
|
||||
import type { ScaleProps } from '../types';
|
||||
import { DistributionType } from '../types';
|
||||
import { UPlotScaleBuilder } from '../UPlotScaleBuilder';
|
||||
|
||||
const createScaleProps = (overrides: Partial<ScaleProps> = {}): ScaleProps => ({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
distribution: DistributionType.Linear,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotScaleBuilder', () => {
|
||||
const getFallbackMinMaxSpy = jest.spyOn(
|
||||
scaleUtils,
|
||||
'getFallbackMinMaxTimeStamp',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes softMin/softMax correctly when both are 0 (treated as unset)', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 0,
|
||||
softMax: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Non-time scale so config path uses thresholds pipeline; we just care that
|
||||
// adjustSoftLimitsWithThresholds receives null soft limits instead of 0/0.
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(null, null, undefined, undefined);
|
||||
});
|
||||
|
||||
it('handles time scales using explicit min/max and rounds max down to the previous minute', () => {
|
||||
const min = 1_700_000_000; // seconds
|
||||
const max = 1_700_000_600; // seconds
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min,
|
||||
max,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const xScale = config.x;
|
||||
|
||||
expect(xScale.time).toBe(true);
|
||||
expect(xScale.auto).toBe(false);
|
||||
expect(Array.isArray(xScale.range)).toBe(true);
|
||||
|
||||
const [resolvedMin, resolvedMax] = xScale.range as [number, number];
|
||||
|
||||
// min is passed through
|
||||
expect(resolvedMin).toBe(min);
|
||||
|
||||
// max is coerced to "endTime - 1 minute" and rounded down to minute precision
|
||||
const oneMinuteAgoTimestamp = (max - 60) * 1000;
|
||||
const currentDate = new Date(oneMinuteAgoTimestamp);
|
||||
currentDate.setSeconds(0);
|
||||
currentDate.setMilliseconds(0);
|
||||
const expectedMax = Math.floor(currentDate.getTime() / 1000);
|
||||
|
||||
expect(resolvedMax).toBe(expectedMax);
|
||||
});
|
||||
|
||||
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
|
||||
getFallbackMinMaxSpy.mockReturnValue({
|
||||
fallbackMin: 100,
|
||||
fallbackMax: 200,
|
||||
});
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [resolvedMin, resolvedMax] = config.x.range as [number, number];
|
||||
|
||||
expect(getFallbackMinMaxSpy).toHaveBeenCalled();
|
||||
expect(resolvedMin).toBe(100);
|
||||
// max is aligned to "fallbackMax - 60 seconds" minute boundary
|
||||
expect(resolvedMax).toBeLessThanOrEqual(200);
|
||||
expect(resolvedMax).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('pipes limits through soft-limit adjustment and log-scale normalization before range config', () => {
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
const normalizeSpy = jest.spyOn(scaleUtils, 'normalizeLogScaleLimits');
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 10 }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 1,
|
||||
softMax: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
distribution: DistributionType.Logarithmic,
|
||||
thresholds,
|
||||
logBase: 2,
|
||||
padMinBy: 0.1,
|
||||
padMaxBy: 0.2,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(1, 5, thresholds.thresholds, 'ms');
|
||||
expect(normalizeSpy).toHaveBeenCalledWith({
|
||||
distr: DistributionType.Logarithmic,
|
||||
logBase: 2,
|
||||
limits: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
softMin: expect.anything(),
|
||||
softMax: expect.anything(),
|
||||
},
|
||||
});
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes distribution config for non-time scales and wires range function when range is not provided', () => {
|
||||
const createRangeFnSpy = jest.spyOn(scaleUtils, 'createRangeFunction');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
distribution: DistributionType.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(createRangeFnSpy).toHaveBeenCalled();
|
||||
|
||||
// range should be a function when not provided explicitly
|
||||
expect(typeof yScale.range).toBe('function');
|
||||
|
||||
// distribution config should be applied
|
||||
expect(yScale.distr).toBeDefined();
|
||||
expect(yScale.log).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects explicit range function when provided on props', () => {
|
||||
const explicitRange: uPlot.Scale.Range = jest.fn(() => [
|
||||
0,
|
||||
10,
|
||||
]) as uPlot.Scale.Range;
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
range: explicitRange,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(yScale.range).toBe(explicitRange);
|
||||
});
|
||||
|
||||
it('derives auto flag when not explicitly provided, based on hasFixedRange and time', () => {
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
min: 0,
|
||||
max: 100,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
// For non-time scale with fixed min/max, hasFixedRange is true → auto should remain false
|
||||
expect(yScale.auto).toBe(false);
|
||||
});
|
||||
|
||||
it('merge updates internal min/max/soft limits while preserving other props', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
min: 0,
|
||||
max: 10,
|
||||
softMin: 1,
|
||||
softMax: 9,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.merge({
|
||||
min: 2,
|
||||
softMax: undefined,
|
||||
});
|
||||
|
||||
expect(builder.props.min).toBe(2);
|
||||
expect(builder.props.softMax).toBe(undefined);
|
||||
expect(builder.props.max).toBe(10);
|
||||
expect(builder.props.softMin).toBe(1);
|
||||
expect(builder.props.time).toBe(false);
|
||||
expect(builder.props.scaleKey).toBe('y');
|
||||
expect(builder.props.distribution).toBe(DistributionType.Linear);
|
||||
expect(builder.props.thresholds).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from '../types';
|
||||
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
isDarkMode: false,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
interface MockPath extends uPlot.Series.Paths {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
describe('UPlotSeriesBuilder', () => {
|
||||
it('maps basic props into uPlot series config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Latency',
|
||||
spanGaps: true,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('y');
|
||||
expect(config.label).toBe('Latency');
|
||||
expect(config.spanGaps).toBe(true);
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.pxAlign).toBe(true);
|
||||
expect(typeof config.value).toBe('function');
|
||||
});
|
||||
|
||||
it('uses explicit lineColor when provided, regardless of mapping', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineColor: '#ff00ff',
|
||||
colorMapping: { Requests: '#00ff00' },
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#ff00ff');
|
||||
});
|
||||
|
||||
it('falls back to theme colors when no label is provided', () => {
|
||||
const darkBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: true,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
const lightBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: false,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const darkConfig = darkBuilder.getConfig();
|
||||
const lightConfig = lightBuilder.getConfig();
|
||||
|
||||
expect(darkConfig.stroke).toBe(themeColors.white);
|
||||
expect(lightConfig.stroke).toBe(themeColors.black);
|
||||
});
|
||||
|
||||
it('uses colorMapping when available and no explicit lineColor is provided', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Requests',
|
||||
colorMapping: { Requests: '#123456' },
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#123456');
|
||||
});
|
||||
|
||||
it('passes through a custom pathBuilder when provided', () => {
|
||||
const customPaths = (jest.fn() as unknown) as uPlot.Series.PathBuilder;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pathBuilder: customPaths,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.paths).toBe(customPaths);
|
||||
});
|
||||
|
||||
it('does not build line paths when drawStyle is Points, but still renders points', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Points,
|
||||
pointSize: 4,
|
||||
lineWidth: 2,
|
||||
lineColor: '#aa00aa',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(typeof config.paths).toBe('function');
|
||||
expect(config.paths && config.paths({} as uPlot, 1, 0, 10)).toBeNull();
|
||||
|
||||
expect(config.points).toBeDefined();
|
||||
expect(config.points?.stroke).toBe('#aa00aa');
|
||||
expect(config.points?.fill).toBe('#aa00aa');
|
||||
expect(config.points?.show).toBe(true);
|
||||
expect(config.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('derives point size based on lineWidth and pointSize', () => {
|
||||
const smallPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 4,
|
||||
pointSize: 2,
|
||||
}),
|
||||
);
|
||||
const largePointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 2,
|
||||
pointSize: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const smallConfig = smallPointsBuilder.getConfig();
|
||||
const largeConfig = largePointsBuilder.getConfig();
|
||||
|
||||
expect(smallConfig.points?.size).toBeUndefined();
|
||||
expect(largeConfig.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('uses pointsBuilder when provided instead of default visibility logic', () => {
|
||||
const pointsBuilder: uPlot.Series.Points.Show = jest.fn(
|
||||
() => true,
|
||||
) as uPlot.Series.Points.Show;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsBuilder,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.show).toBe(pointsBuilder);
|
||||
});
|
||||
|
||||
it('respects VisibilityMode for point visibility when no custom pointsBuilder is given', () => {
|
||||
const neverPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Never,
|
||||
}),
|
||||
);
|
||||
const alwaysPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Always,
|
||||
}),
|
||||
);
|
||||
|
||||
const neverConfig = neverPointsBuilder.getConfig();
|
||||
const alwaysConfig = alwaysPointsBuilder.getConfig();
|
||||
|
||||
expect(neverConfig.points?.show).toBe(false);
|
||||
expect(alwaysConfig.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('applies LineStyle.Dashed and lineCap to line config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineStyle: LineStyle.Dashed,
|
||||
lineCap: 'round' as CanvasLineCap,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.dash).toEqual([10, 10]);
|
||||
expect(config.cap).toBe('round');
|
||||
});
|
||||
|
||||
it('builds default paths for Line drawStyle and invokes the path builder', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
const result = config.paths?.({} as uPlot, 1, 0, 10);
|
||||
expect((result as MockPath).name).toBe('linear');
|
||||
});
|
||||
|
||||
it('uses StepBefore and StepAfter interpolation for line paths', () => {
|
||||
const stepBeforeBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepBefore,
|
||||
}),
|
||||
);
|
||||
const stepAfterBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepAfter,
|
||||
}),
|
||||
);
|
||||
|
||||
const stepBeforeConfig = stepBeforeBuilder.getConfig();
|
||||
const stepAfterConfig = stepAfterBuilder.getConfig();
|
||||
const stepBeforePath = stepBeforeConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
const stepAfterPath = stepAfterConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
expect((stepBeforePath as MockPath).name).toBe('stepped-(-1)');
|
||||
expect((stepAfterPath as MockPath).name).toBe('stepped-(1)');
|
||||
});
|
||||
|
||||
it('defaults to spline interpolation when lineInterpolation is Spline or undefined', () => {
|
||||
const splineBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
}),
|
||||
);
|
||||
const defaultBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Line }),
|
||||
);
|
||||
|
||||
const splineConfig = splineBuilder.getConfig();
|
||||
const defaultConfig = defaultBuilder.getConfig();
|
||||
|
||||
const splinePath = splineConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
const defaultPath = defaultConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
|
||||
expect((splinePath as MockPath).name).toBe('spline');
|
||||
expect((defaultPath as MockPath).name).toBe('spline');
|
||||
});
|
||||
|
||||
it('uses generateColor when label has no colorMapping and no lineColor', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'CustomSeries',
|
||||
colorMapping: {},
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.stroke).toBe('#E64A3C');
|
||||
});
|
||||
|
||||
it('passes through pointsFilter when provided', () => {
|
||||
const pointsFilter: uPlot.Series.Points.Filter = jest.fn(
|
||||
(_self, _seriesIdx, _show) => null,
|
||||
);
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsFilter,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.filter).toBe(pointsFilter);
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
import {
|
||||
buildYAxisSizeCalculator,
|
||||
calculateTextWidth,
|
||||
getExistingAxisSize,
|
||||
} from '../axis';
|
||||
|
||||
describe('axis utils', () => {
|
||||
describe('calculateTextWidth', () => {
|
||||
it('returns 0 when values are undefined or empty', () => {
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
measureText: jest.fn(),
|
||||
font: '',
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// internally the type is string but it is an array of strings
|
||||
const mockAxis: Axis = { font: (['12px sans-serif'] as unknown) as string };
|
||||
|
||||
expect(calculateTextWidth(mockSelf, mockAxis, undefined)).toBe(0);
|
||||
expect(calculateTextWidth(mockSelf, mockAxis, [])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when longest value is empty string or axis has no usable font', () => {
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
measureText: jest.fn(),
|
||||
font: '',
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const axisWithoutFont: Axis = { font: '' };
|
||||
const axisWithEmptyFontArray: Axis = { font: '' };
|
||||
|
||||
expect(calculateTextWidth(mockSelf, axisWithoutFont, [''])).toBe(0);
|
||||
expect(
|
||||
calculateTextWidth(mockSelf, axisWithEmptyFontArray, ['a', 'bb']),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('measures longest value using canvas context and axis font', () => {
|
||||
const measureText = jest.fn(() => ({ width: 100 }));
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const mockAxis: Axis = { font: (['14px Arial'] as unknown) as string };
|
||||
const values = ['1', '1234', '12'];
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
|
||||
const result = calculateTextWidth(mockSelf, mockAxis, values);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('1234');
|
||||
expect(mockSelf.ctx.font).toBe('14px Arial');
|
||||
expect(result).toBe(100 / dpr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExistingAxisSize', () => {
|
||||
it('returns internal _size when present', () => {
|
||||
const axis: any = {
|
||||
_size: 42,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
const result = getExistingAxisSize({
|
||||
uplotInstance: ({} as unknown) as uPlot,
|
||||
axis,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('invokes size function when _size is not set', () => {
|
||||
const sizeFn = jest.fn(() => 24);
|
||||
const axis: Axis = { size: sizeFn };
|
||||
const instance = ({} as unknown) as uPlot;
|
||||
|
||||
const result = getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis,
|
||||
values: ['10', '20'],
|
||||
axisIdx: 1,
|
||||
cycleNum: 2,
|
||||
});
|
||||
|
||||
expect(sizeFn).toHaveBeenCalledWith(instance, ['10', '20'], 1, 2);
|
||||
expect(result).toBe(24);
|
||||
});
|
||||
|
||||
it('returns numeric size or 0 when neither _size nor size are provided', () => {
|
||||
const axisWithSize: Axis = { size: 16 };
|
||||
const axisWithoutSize: Axis = {};
|
||||
const instance = ({} as unknown) as uPlot;
|
||||
|
||||
expect(
|
||||
getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis: axisWithSize,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
}),
|
||||
).toBe(16);
|
||||
|
||||
expect(
|
||||
getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis: axisWithoutSize,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildYAxisSizeCalculator', () => {
|
||||
it('delegates to getExistingAxisSize when cycleNum > 1', () => {
|
||||
const sizeCalculator = buildYAxisSizeCalculator(5);
|
||||
|
||||
const axis: any = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 60 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['10', '20'], 0, 2);
|
||||
|
||||
expect(result).toBe(80);
|
||||
expect(measureText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes size from ticks, gap and text width when cycleNum <= 1', () => {
|
||||
const gap = 7;
|
||||
const sizeCalculator = buildYAxisSizeCalculator(gap);
|
||||
|
||||
const axis: any = {
|
||||
ticks: { size: 12 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 50 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
const expected = Math.ceil(12 + gap + 50 / dpr);
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['short', 'the-longest'], 0, 0);
|
||||
expect(measureText).toHaveBeenCalledWith('the-longest');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses 0 ticks size when ticks are not defined', () => {
|
||||
const gap = 4;
|
||||
const sizeCalculator = buildYAxisSizeCalculator(gap);
|
||||
|
||||
const axis: any = {
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 40 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
const expected = Math.ceil(gap + 40 / dpr);
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['1', '123'], 0, 1);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
export function calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
export function getExistingAxisSize({
|
||||
uplotInstance,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
}: {
|
||||
uplotInstance: uPlot;
|
||||
axis: Axis;
|
||||
values?: string[];
|
||||
axisIdx: number;
|
||||
cycleNum: number;
|
||||
}): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(uplotInstance, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
export function buildYAxisSizeCalculator(gap: number): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return getExistingAxisSize({
|
||||
uplotInstance: self,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
});
|
||||
}
|
||||
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
@@ -17,17 +17,41 @@ export interface GettableAuthDomain {
|
||||
oidcConfig?: OIDCConfig;
|
||||
}
|
||||
|
||||
export interface SAMLAttributeMapping {
|
||||
nameAttribute?: string;
|
||||
groupsAttribute?: string;
|
||||
roleAttribute?: string;
|
||||
}
|
||||
|
||||
export interface SAMLConfig {
|
||||
samlEntity: string;
|
||||
samlIdp: string;
|
||||
samlCert: string;
|
||||
insecureSkipAuthNRequestsSigned: boolean;
|
||||
attributeMapping?: SAMLAttributeMapping;
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface RoleMappingConfig {
|
||||
defaultRole?: 'VIEWER' | 'EDITOR' | 'ADMIN';
|
||||
useRoleAttributeDirectly?: boolean;
|
||||
groupMappings?: Array<{
|
||||
groupName: string;
|
||||
role: 'VIEWER' | 'EDITOR' | 'ADMIN';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GoogleAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectURI: string;
|
||||
insecureSkipEmailVerified?: boolean;
|
||||
fetchGroups?: boolean;
|
||||
serviceAccountJson?: string;
|
||||
domainToAdminEmail?: Record<string, string>;
|
||||
fetchTransitiveGroupMembership?: boolean;
|
||||
allowedGroups?: string[];
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface OIDCConfig {
|
||||
@@ -38,10 +62,14 @@ export interface OIDCConfig {
|
||||
claimMapping: ClaimMapping;
|
||||
insecureSkipEmailVerified: boolean;
|
||||
getUserInfo: boolean;
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface ClaimMapping {
|
||||
email: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
groups?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface AuthNProviderInfo {
|
||||
|
||||
@@ -11,17 +11,41 @@ export interface Config {
|
||||
oidcConfig?: OIDCConfig;
|
||||
}
|
||||
|
||||
export interface RoleMappingConfig {
|
||||
defaultRole?: 'VIEWER' | 'EDITOR' | 'ADMIN';
|
||||
useRoleAttributeDirectly?: boolean;
|
||||
groupMappings?: Array<{
|
||||
groupName: string;
|
||||
role: 'VIEWER' | 'EDITOR' | 'ADMIN';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SAMLAttributeMapping {
|
||||
nameAttribute?: string;
|
||||
groupsAttribute?: string;
|
||||
roleAttribute?: string;
|
||||
}
|
||||
|
||||
export interface SAMLConfig {
|
||||
samlEntity: string;
|
||||
samlIdp: string;
|
||||
samlCert: string;
|
||||
insecureSkipAuthNRequestsSigned: boolean;
|
||||
attributeMapping?: SAMLAttributeMapping;
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface GoogleAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectURI: string;
|
||||
insecureSkipEmailVerified?: boolean;
|
||||
fetchGroups?: boolean;
|
||||
serviceAccountJson?: string;
|
||||
domainToAdminEmail?: Record<string, string>;
|
||||
fetchTransitiveGroupMembership?: boolean;
|
||||
allowedGroups?: string[];
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface OIDCConfig {
|
||||
@@ -32,8 +56,12 @@ export interface OIDCConfig {
|
||||
claimMapping: ClaimMapping;
|
||||
insecureSkipEmailVerified: boolean;
|
||||
getUserInfo: boolean;
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface ClaimMapping {
|
||||
email: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
groups?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@@ -9,17 +9,41 @@ export interface UpdatableAuthDomain {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RoleMappingConfig {
|
||||
defaultRole?: 'VIEWER' | 'EDITOR' | 'ADMIN';
|
||||
useRoleAttributeDirectly?: boolean;
|
||||
groupMappings?: Array<{
|
||||
groupName: string;
|
||||
role: 'VIEWER' | 'EDITOR' | 'ADMIN';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SAMLAttributeMapping {
|
||||
nameAttribute?: string;
|
||||
groupsAttribute?: string;
|
||||
roleAttribute?: string;
|
||||
}
|
||||
|
||||
export interface SAMLConfig {
|
||||
samlEntity: string;
|
||||
samlIdp: string;
|
||||
samlCert: string;
|
||||
insecureSkipAuthNRequestsSigned: boolean;
|
||||
attributeMapping?: SAMLAttributeMapping;
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface GoogleAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectURI: string;
|
||||
insecureSkipEmailVerified?: boolean;
|
||||
fetchGroups?: boolean;
|
||||
serviceAccountJson?: string;
|
||||
domainToAdminEmail?: Record<string, string>;
|
||||
fetchTransitiveGroupMembership?: boolean;
|
||||
allowedGroups?: string[];
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface OIDCConfig {
|
||||
@@ -30,8 +54,12 @@ export interface OIDCConfig {
|
||||
claimMapping: ClaimMapping;
|
||||
insecureSkipEmailVerified: boolean;
|
||||
getUserInfo: boolean;
|
||||
roleMapping?: RoleMappingConfig;
|
||||
}
|
||||
|
||||
export interface ClaimMapping {
|
||||
email: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
groups?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user